@adhdev/daemon-core 0.9.82-rc.380 → 0.9.82-rc.382
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.d.ts +4 -2
- package/dist/index.js +302 -61
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +295 -61
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-forwarding.d.ts +1 -0
- package/dist/mesh/mesh-runtime-store.d.ts +12 -0
- package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
- package/dist/providers/cli-provider-instance.d.ts +18 -0
- package/dist/repo-mesh-types.d.ts +81 -0
- package/package.json +2 -2
- package/src/commands/high-family/mesh-status.ts +36 -0
- package/src/config/mesh-config.ts +6 -58
- package/src/index.ts +13 -0
- package/src/mesh/coordinator-prompt.ts +3 -3
- package/src/mesh/mesh-event-forwarding.ts +63 -2
- package/src/mesh/mesh-runtime-store.ts +23 -0
- package/src/mesh/mesh-scheduling-runtime.ts +199 -0
- package/src/providers/cli-provider-instance.ts +27 -0
- package/src/repo-mesh-types.ts +164 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
|
+
export declare function recoverMeshIdByCoordinatorAndNode(coordinatorDaemonId: string, nodeId: string): string;
|
|
2
3
|
export declare function resolveForwardEventMeshId(components: DaemonComponents, payload: Record<string, unknown>): string;
|
|
3
4
|
export declare function __resetMeshWorkspaceCacheForTests(): void;
|
|
4
5
|
export declare function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -128,6 +128,18 @@ export declare class MeshRuntimeStore {
|
|
|
128
128
|
updatedAt: string;
|
|
129
129
|
}>;
|
|
130
130
|
updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
|
|
131
|
+
/**
|
|
132
|
+
* MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
|
|
133
|
+
* non-terminal direct dispatch a session owns. Returns the task_id only when the session
|
|
134
|
+
* holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
|
|
135
|
+
* lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
|
|
136
|
+
* unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
|
|
137
|
+
* two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
|
|
138
|
+
* return null and the caller MUST NOT fall back to the session_id sweep that would flip a
|
|
139
|
+
* sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
|
|
140
|
+
* only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
|
|
141
|
+
*/
|
|
142
|
+
getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null;
|
|
131
143
|
cleanupTerminalDirectDispatches(olderThanMs: number): void;
|
|
132
144
|
deleteDirectDispatches(meshId: string): void;
|
|
133
145
|
/**
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { RepoMeshNodePolicy, RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
2
|
+
import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
3
|
+
/** Per-(node, provider) cap and its current consumption. */
|
|
4
|
+
export interface MeshNodeProviderSchedulingRuntime {
|
|
5
|
+
providerType: string;
|
|
6
|
+
/** Declared maxParallel cap for this (node, provider). Omitted when uncapped. */
|
|
7
|
+
maxParallel?: number;
|
|
8
|
+
/** Active (status='assigned') tasks on this node claimed by this provider. */
|
|
9
|
+
activeAssigned: number;
|
|
10
|
+
/** True when activeAssigned has reached maxParallel (a further claim is refused). */
|
|
11
|
+
capReached: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Per-node scheduling-runtime projection. */
|
|
14
|
+
export interface MeshNodeSchedulingRuntime {
|
|
15
|
+
nodeId: string;
|
|
16
|
+
/** Active (status='assigned') task count on this node — the least-loaded rank key. */
|
|
17
|
+
load: number;
|
|
18
|
+
/** Soft scheduling priority (PRIORITY rank key; higher = preferred). */
|
|
19
|
+
schedulingPriority: number;
|
|
20
|
+
/** Per-node concurrent-session cap, when configured. */
|
|
21
|
+
maxConcurrentSessions?: number;
|
|
22
|
+
/** Per-(node, provider) caps + consumption, when providerRoles declares any. */
|
|
23
|
+
providerRoles?: MeshNodeProviderSchedulingRuntime[];
|
|
24
|
+
/**
|
|
25
|
+
* True when the node currently cannot claim a NEW write (non-readonly) task —
|
|
26
|
+
* either the global write cap is exhausted or a node-local gate (active write
|
|
27
|
+
* assignment / a fully-consumed provider cap / session cap) blocks it.
|
|
28
|
+
*/
|
|
29
|
+
capReached: boolean;
|
|
30
|
+
/** Structured reasons backing capReached (empty when the node can take work). */
|
|
31
|
+
capReasons: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Mesh-level scheduling-runtime projection. */
|
|
34
|
+
export interface MeshSchedulingRuntime {
|
|
35
|
+
/** Resolved tie-break strategy (defaults to 'first_eligible'). */
|
|
36
|
+
strategy: RepoMeshSchedulingStrategy;
|
|
37
|
+
/** Effective global write-task parallel cap (clamped). */
|
|
38
|
+
maxParallelTasks: number;
|
|
39
|
+
/** Derived read-only diagnosis cap (max(2, 2× write cap)) — mirrors the claim path. */
|
|
40
|
+
maxReadonlyParallelTasks: number;
|
|
41
|
+
/** Current global write (non-readonly) assigned-task load. */
|
|
42
|
+
activeWriteAssigned: number;
|
|
43
|
+
/** Current global read-only assigned-task load. */
|
|
44
|
+
activeReadonlyAssigned: number;
|
|
45
|
+
/** True when the global write cap is exhausted (no new write task can launch). */
|
|
46
|
+
globalWriteCapReached: boolean;
|
|
47
|
+
/** True when the read-only diagnosis cap is exhausted. */
|
|
48
|
+
globalReadonlyCapReached: boolean;
|
|
49
|
+
/** Per-node projections, in mesh config order. */
|
|
50
|
+
nodes: MeshNodeSchedulingRuntime[];
|
|
51
|
+
}
|
|
52
|
+
interface MeshLike {
|
|
53
|
+
policy?: {
|
|
54
|
+
schedulingStrategy?: unknown;
|
|
55
|
+
maxParallelTasks?: unknown;
|
|
56
|
+
} | null;
|
|
57
|
+
nodes?: Array<{
|
|
58
|
+
id?: string;
|
|
59
|
+
nodeId?: string;
|
|
60
|
+
node_id?: string;
|
|
61
|
+
policy?: RepoMeshNodePolicy | null;
|
|
62
|
+
isLocalWorktree?: boolean;
|
|
63
|
+
}> | null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the scheduling-runtime projection for a mesh from its config + a snapshot of
|
|
67
|
+
* its work queue. Pure and side-effect free — safe to call on any read path.
|
|
68
|
+
*
|
|
69
|
+
* The derivation deliberately mirrors maybeAutoLaunchOneQueueSession's gates so the
|
|
70
|
+
* exposed "capReached/capReasons" match why a real claim would be refused:
|
|
71
|
+
* • global write cap → activeWriteAssigned >= maxParallelTasks
|
|
72
|
+
* • node write conflict→ a node already holding an assigned write task (worktree isolation)
|
|
73
|
+
* • provider cap → a (node, provider) at its declared maxParallel
|
|
74
|
+
* It does NOT re-evaluate eligibility/required-tags (those are task-specific); it only
|
|
75
|
+
* reports the capacity picture, which is what an operator needs to read load balance.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildMeshSchedulingRuntime(mesh: MeshLike | null | undefined, queue: MeshWorkQueueEntry[]): MeshSchedulingRuntime;
|
|
78
|
+
export {};
|
|
@@ -150,6 +150,24 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
150
150
|
* terminal state. Leaving meshNodeFor pinned would route this session's
|
|
151
151
|
* subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
|
|
152
152
|
* coordinator as if they were task completions.
|
|
153
|
+
*
|
|
154
|
+
* MESHID-DROP-ON-DETACH (Fix C): a coordinator-LAUNCHED worker session
|
|
155
|
+
* (launchedByCoordinator) holds its mesh membership (meshNodeFor / meshNodeId /
|
|
156
|
+
* meshCoordinatorDaemonId) at the SESSION level — set once at launch
|
|
157
|
+
* (mesh_launch_session / queue auto-launch), independent of any single task.
|
|
158
|
+
* The original detach wiped meshNodeFor + meshNodeId together with the
|
|
159
|
+
* task-level meshActiveTaskId, so the FIRST task completion stripped the
|
|
160
|
+
* membership and EVERY subsequent completion forwarded with meshId absent —
|
|
161
|
+
* resolveWorkerDelegateRouting fell to mesh_unresolved and the coordinator
|
|
162
|
+
* rejected the forward "meshId required". For a launched member we therefore
|
|
163
|
+
* clear ONLY the task-level marker (meshActiveTaskId) and preserve the
|
|
164
|
+
* session-level membership so its next task's completion still resolves.
|
|
165
|
+
* A task-less ad-hoc turn on a preserved-membership session is NOT misrouted:
|
|
166
|
+
* its completion carries no taskId and the session holds no active assignment,
|
|
167
|
+
* so the forwarder's WARMUPGAP guard skips the dispatch-row flip (it only
|
|
168
|
+
* injects a benign task-less notification). A NON-launched session (a plain CLI
|
|
169
|
+
* session adopted by mesh_send_task --direct, launchedByCoordinator falsy)
|
|
170
|
+
* keeps the original full clear so an ad-hoc session is never left pinned.
|
|
153
171
|
*/
|
|
154
172
|
detachMeshAssignment(): void;
|
|
155
173
|
/**
|
|
@@ -279,6 +279,34 @@ export interface RepoMeshNodePolicy {
|
|
|
279
279
|
initSubmodulesOnClone?: boolean;
|
|
280
280
|
}
|
|
281
281
|
export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
|
|
282
|
+
/** Min/max bounds for the global write-task parallel cap. */
|
|
283
|
+
export declare const MESH_MAX_PARALLEL_TASKS_MIN = 1;
|
|
284
|
+
export declare const MESH_MAX_PARALLEL_TASKS_MAX = 8;
|
|
285
|
+
/**
|
|
286
|
+
* Resolve the effective global write-task parallel cap from a raw policy value,
|
|
287
|
+
* clamped to [MESH_MAX_PARALLEL_TASKS_MIN, MESH_MAX_PARALLEL_TASKS_MAX] and
|
|
288
|
+
* defaulting to DEFAULT_MESH_POLICY.maxParallelTasks for a missing/NaN value.
|
|
289
|
+
* Both the config write path and the runtime scheduler read the cap through this
|
|
290
|
+
* helper so they can never disagree on what "max parallel" means.
|
|
291
|
+
*/
|
|
292
|
+
export declare function resolveMaxParallelTasks(value: unknown): number;
|
|
293
|
+
/**
|
|
294
|
+
* Normalize an autoFastForward sub-policy, filling defaults and dropping an
|
|
295
|
+
* invalid maxBehind. Mirrors the (previously mesh-config-local) shape so the merge
|
|
296
|
+
* always emits a fully-populated, valid autoFastForward object.
|
|
297
|
+
*/
|
|
298
|
+
export declare function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']>;
|
|
299
|
+
/**
|
|
300
|
+
* Canonical merge+normalize for a RepoMeshPolicy. Layers (lowest→highest):
|
|
301
|
+
* DEFAULT_MESH_POLICY → base (existing persisted policy) → patch (incoming change),
|
|
302
|
+
* then applies every per-field normalizer so the result is always valid regardless
|
|
303
|
+
* of what a hand-edited meshes.json or a partial patch contained.
|
|
304
|
+
*
|
|
305
|
+
* Persistence economy is preserved: schedulingStrategy is dropped when it
|
|
306
|
+
* normalizes to the 'first_eligible' default, and autoConvergeCodeChange is dropped
|
|
307
|
+
* unless explicitly true — so an untouched meshes.json stays byte-for-byte the same.
|
|
308
|
+
*/
|
|
309
|
+
export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy;
|
|
282
310
|
/**
|
|
283
311
|
* Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
|
|
284
312
|
* governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
|
|
@@ -464,6 +492,41 @@ export interface LocalMeshNodeEntry {
|
|
|
464
492
|
relatedRepos?: RepoMeshRelatedRepo[];
|
|
465
493
|
role?: RepoMeshDaemonRole;
|
|
466
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Per-(node, provider) cap + consumption, as surfaced on a node's scheduling
|
|
497
|
+
* status. Wire-shape mirror of MeshNodeProviderSchedulingRuntime.
|
|
498
|
+
*/
|
|
499
|
+
export interface RepoMeshNodeProviderSchedulingStatus {
|
|
500
|
+
providerType: string;
|
|
501
|
+
maxParallel?: number;
|
|
502
|
+
activeAssigned: number;
|
|
503
|
+
capReached: boolean;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Per-node scheduling runtime exposed on RepoMeshNodeStatus.scheduling. Carried in
|
|
507
|
+
* full by verbose mesh_status; compact mesh_status sends only {load, capReached}.
|
|
508
|
+
*/
|
|
509
|
+
export interface RepoMeshNodeSchedulingStatus {
|
|
510
|
+
load: number;
|
|
511
|
+
schedulingPriority?: number;
|
|
512
|
+
maxConcurrentSessions?: number;
|
|
513
|
+
providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
|
|
514
|
+
capReached: boolean;
|
|
515
|
+
capReasons?: string[];
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Mesh-level scheduling rollup exposed on RepoMeshStatus.scheduling: which tie-break
|
|
519
|
+
* strategy is live and how much of the global parallel caps is consumed.
|
|
520
|
+
*/
|
|
521
|
+
export interface RepoMeshSchedulingStatus {
|
|
522
|
+
strategy: RepoMeshSchedulingStrategy;
|
|
523
|
+
maxParallelTasks: number;
|
|
524
|
+
maxReadonlyParallelTasks: number;
|
|
525
|
+
activeWriteAssigned: number;
|
|
526
|
+
activeReadonlyAssigned: number;
|
|
527
|
+
globalWriteCapReached: boolean;
|
|
528
|
+
globalReadonlyCapReached: boolean;
|
|
529
|
+
}
|
|
467
530
|
export interface RepoMeshStatus {
|
|
468
531
|
meshId: string;
|
|
469
532
|
meshName: string;
|
|
@@ -474,6 +537,11 @@ export interface RepoMeshStatus {
|
|
|
474
537
|
nodes: RepoMeshNodeStatus[];
|
|
475
538
|
queue?: RepoMeshQueueStatus;
|
|
476
539
|
ledger?: RepoMeshLedgerStatus;
|
|
540
|
+
/**
|
|
541
|
+
* Mesh-level scheduling rollup (strategy + global cap consumption). Omitted by
|
|
542
|
+
* daemons predating the scheduling-runtime exposure — treat as optional.
|
|
543
|
+
*/
|
|
544
|
+
scheduling?: RepoMeshSchedulingStatus;
|
|
477
545
|
/**
|
|
478
546
|
* Mission summaries for the dashboard overview. Active/paused missions plus a
|
|
479
547
|
* capped, newest-first slice of completed/abandoned history. Omitted by older
|
|
@@ -545,6 +613,19 @@ export interface RepoMeshNodeStatus {
|
|
|
545
613
|
lastSeenAt?: string;
|
|
546
614
|
updatedAt?: string;
|
|
547
615
|
connection?: RepoMeshPeerConnectionStatus;
|
|
616
|
+
/**
|
|
617
|
+
* Per-node scheduling runtime (load / priority / provider caps / claim-block
|
|
618
|
+
* reasons). Verbose mesh_status carries the full shape; compact carries only
|
|
619
|
+
* {load, capReached}. Omitted by daemons predating the exposure.
|
|
620
|
+
*/
|
|
621
|
+
scheduling?: RepoMeshNodeSchedulingStatus;
|
|
622
|
+
/**
|
|
623
|
+
* Stale-daemon-build marker: the live daemon's build commit is a strict ancestor
|
|
624
|
+
* of this node's workspace HEAD (merged code not yet live). Best-effort, set by
|
|
625
|
+
* mesh_status when the git probe reports daemonBuildBehind; shape is daemon-defined
|
|
626
|
+
* (scope/isDaemonAffecting flags). Omitted when the build is current.
|
|
627
|
+
*/
|
|
628
|
+
staleDaemonBuild?: Record<string, unknown>;
|
|
548
629
|
error?: string;
|
|
549
630
|
}
|
|
550
631
|
export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
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.382",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.382",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -107,6 +107,19 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
107
107
|
const queue = getQueue(meshId);
|
|
108
108
|
const queueSummary = getMeshQueueStats(meshId);
|
|
109
109
|
|
|
110
|
+
// Scheduling-runtime projection — the load-balancer's live view (tie-break
|
|
111
|
+
// strategy, global parallel caps + consumption, per-node load/priority/provider
|
|
112
|
+
// caps). Built from the SAME helper + args the MCP `mesh_status` tool uses
|
|
113
|
+
// (mesh-tools-status.ts: buildMeshSchedulingRuntime(mesh, getQueue(mesh.id)))
|
|
114
|
+
// so the dashboard surface and the coordinator MCP surface render an identical
|
|
115
|
+
// runtime. Without this the dashboard Status/Runtime tab showed the
|
|
116
|
+
// "not reported by this daemon (older build)" fallback (RepoMeshStatus.scheduling
|
|
117
|
+
// was never populated by this daemon-command producer). Computed once so each
|
|
118
|
+
// node entry can attach its slice and statusResult can carry the mesh rollup.
|
|
119
|
+
const { buildMeshSchedulingRuntime } = await import('../../mesh/mesh-scheduling-runtime.js');
|
|
120
|
+
const schedulingRuntime = buildMeshSchedulingRuntime(mesh, queue);
|
|
121
|
+
const schedulingByNode = new Map(schedulingRuntime.nodes.map(n => [n.nodeId, n]));
|
|
122
|
+
|
|
110
123
|
const { readLedgerEntries, getLedgerSummary } = await import('../../mesh/mesh-ledger.js');
|
|
111
124
|
const ledgerEntries = readLedgerEntries(meshId, { tail: 20 });
|
|
112
125
|
const asyncRefineLedgerEntries = readLedgerEntries(meshId, { tail: 100 });
|
|
@@ -260,6 +273,16 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
260
273
|
activeSessionDetails: [],
|
|
261
274
|
launchReady: false,
|
|
262
275
|
};
|
|
276
|
+
// Per-node scheduling slice (load / priority / provider caps / claim-block
|
|
277
|
+
// reasons) read by the dashboard's MeshNodeSchedulingBadges. Full shape —
|
|
278
|
+
// unlike the MCP compact path this is a dashboard surface, so it keeps the
|
|
279
|
+
// whole RepoMeshNodeSchedulingStatus. Redundant nodeId dropped (the entry
|
|
280
|
+
// already carries it).
|
|
281
|
+
const nodeScheduling = schedulingByNode.get(nodeId);
|
|
282
|
+
if (nodeScheduling) {
|
|
283
|
+
const { nodeId: _omitNodeId, ...nodeSchedulingRest } = nodeScheduling;
|
|
284
|
+
status.scheduling = nodeSchedulingRest;
|
|
285
|
+
}
|
|
263
286
|
if (isSelfNode) {
|
|
264
287
|
status.connection = {
|
|
265
288
|
perspective: 'selected_coordinator',
|
|
@@ -511,6 +534,19 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
|
|
|
511
534
|
branchConvergenceSummary: summarizeInlineMeshBranchConvergence(nodeStatuses),
|
|
512
535
|
...(previewFreshness ? { previewFreshness, deployFreshness: previewFreshness } : {}),
|
|
513
536
|
nodes: nodeStatuses,
|
|
537
|
+
// Mesh-level scheduling rollup (strategy + global cap consumption). Mirrors
|
|
538
|
+
// the MCP `mesh_status` tool's `scheduling` block field-for-field so both
|
|
539
|
+
// surfaces read the same runtime; per-node detail lives on each
|
|
540
|
+
// nodes[].scheduling above.
|
|
541
|
+
scheduling: {
|
|
542
|
+
strategy: schedulingRuntime.strategy,
|
|
543
|
+
maxParallelTasks: schedulingRuntime.maxParallelTasks,
|
|
544
|
+
maxReadonlyParallelTasks: schedulingRuntime.maxReadonlyParallelTasks,
|
|
545
|
+
activeWriteAssigned: schedulingRuntime.activeWriteAssigned,
|
|
546
|
+
activeReadonlyAssigned: schedulingRuntime.activeReadonlyAssigned,
|
|
547
|
+
globalWriteCapReached: schedulingRuntime.globalWriteCapReached,
|
|
548
|
+
globalReadonlyCapReached: schedulingRuntime.globalReadonlyCapReached,
|
|
549
|
+
},
|
|
514
550
|
queue: { tasks: queue, summary: queueSummary },
|
|
515
551
|
ledger: { entries: ledgerEntries, summary: ledgerSummary },
|
|
516
552
|
...(missions.length > 0 ? { missions } : {}),
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
RepoMeshHostMetadata,
|
|
23
23
|
RepoMeshDaemonRole,
|
|
24
24
|
} from '../repo-mesh-types.js';
|
|
25
|
-
import {
|
|
25
|
+
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
26
26
|
import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
|
|
27
27
|
|
|
28
28
|
// ─── Persistence ────────────────────────────────
|
|
@@ -151,63 +151,11 @@ export function normalizeRepoIdentity(remoteUrl: string): string {
|
|
|
151
151
|
|
|
152
152
|
// ─── CRUD Operations ────────────────────────────
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
...DEFAULT_MESH_POLICY.autoFastForward,
|
|
160
|
-
...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
|
|
161
|
-
...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
|
|
162
|
-
});
|
|
163
|
-
const policy: RepoMeshPolicy = {
|
|
164
|
-
...DEFAULT_MESH_POLICY,
|
|
165
|
-
...(base || {}),
|
|
166
|
-
...(patch || {}),
|
|
167
|
-
autoFastForward,
|
|
168
|
-
};
|
|
169
|
-
if (!['block', 'warn', 'checkpoint_then_continue'].includes(policy.dirtyWorkspaceBehavior)) {
|
|
170
|
-
policy.dirtyWorkspaceBehavior = 'warn';
|
|
171
|
-
}
|
|
172
|
-
const maxParallelTasks = Number(policy.maxParallelTasks);
|
|
173
|
-
policy.maxParallelTasks = Number.isFinite(maxParallelTasks) ? Math.max(1, Math.min(8, Math.floor(maxParallelTasks))) : 2;
|
|
174
|
-
policy.allowAutoPublishSubmoduleMainCommits = policy.allowAutoPublishSubmoduleMainCommits === true;
|
|
175
|
-
if (!SESSION_CLEANUP_MODES.has(String(policy.sessionCleanupOnNodeRemove))) {
|
|
176
|
-
policy.sessionCleanupOnNodeRemove = 'preserve';
|
|
177
|
-
}
|
|
178
|
-
if (!SPAWNED_SESSION_VISIBILITY_MODES.has(String(policy.spawnedSessionVisibility))) {
|
|
179
|
-
policy.spawnedSessionVisibility = DEFAULT_MESH_POLICY.spawnedSessionVisibility;
|
|
180
|
-
}
|
|
181
|
-
// Load-balancing: normalize the scheduling strategy so an invalid/blank value
|
|
182
|
-
// falls back to 'first_eligible' (strict no-change). Only persist the field when
|
|
183
|
-
// it is explicitly a non-default value to keep existing meshes.json untouched.
|
|
184
|
-
const normalizedStrategy = normalizeMeshSchedulingStrategy(policy.schedulingStrategy);
|
|
185
|
-
if (normalizedStrategy === 'first_eligible') {
|
|
186
|
-
delete policy.schedulingStrategy;
|
|
187
|
-
} else {
|
|
188
|
-
policy.schedulingStrategy = normalizedStrategy;
|
|
189
|
-
}
|
|
190
|
-
// Convergence routing: strict opt-in (default false). Only persist when explicitly
|
|
191
|
-
// enabled so existing meshes.json stays byte-for-byte untouched.
|
|
192
|
-
if (policy.autoConvergeCodeChange === true) {
|
|
193
|
-
policy.autoConvergeCodeChange = true;
|
|
194
|
-
} else {
|
|
195
|
-
delete policy.autoConvergeCodeChange;
|
|
196
|
-
}
|
|
197
|
-
return policy;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
|
|
201
|
-
const record = value && typeof value === 'object' && !Array.isArray(value)
|
|
202
|
-
? value as Record<string, unknown>
|
|
203
|
-
: {};
|
|
204
|
-
const maxBehind = Number(record.maxBehind);
|
|
205
|
-
return {
|
|
206
|
-
enabled: record.enabled !== false,
|
|
207
|
-
...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
|
|
208
|
-
requireCleanSubmodules: record.requireCleanSubmodules !== false,
|
|
209
|
-
};
|
|
210
|
-
}
|
|
154
|
+
// Single source of truth for default+merge+per-field normalization is
|
|
155
|
+
// mergeAndNormalizePolicy in repo-mesh-types.ts. This thin alias keeps the local
|
|
156
|
+
// call sites (createMesh/updateMesh) reading naturally while ensuring config
|
|
157
|
+
// writes go through the exact same normalizer the scheduler/display paths use.
|
|
158
|
+
const mergeMeshPolicy = mergeAndNormalizePolicy;
|
|
211
159
|
|
|
212
160
|
export function listMeshes(): LocalMeshEntry[] {
|
|
213
161
|
return loadMeshConfig().meshes;
|
package/src/index.ts
CHANGED
|
@@ -135,6 +135,9 @@ export type {
|
|
|
135
135
|
RepoMeshLedgerStatus,
|
|
136
136
|
MeshAsyncJobLifecycle,
|
|
137
137
|
RepoMeshSchedulingStrategy,
|
|
138
|
+
RepoMeshSchedulingStatus,
|
|
139
|
+
RepoMeshNodeSchedulingStatus,
|
|
140
|
+
RepoMeshNodeProviderSchedulingStatus,
|
|
138
141
|
} from './repo-mesh-types.js';
|
|
139
142
|
export {
|
|
140
143
|
DEFAULT_MESH_POLICY,
|
|
@@ -143,6 +146,12 @@ export {
|
|
|
143
146
|
DEFAULT_MESH_SCHEDULING_STRATEGY,
|
|
144
147
|
normalizeMeshSchedulingStrategy,
|
|
145
148
|
resolveNodeSchedulingPriority,
|
|
149
|
+
resolveProviderMaxParallel,
|
|
150
|
+
mergeAndNormalizePolicy,
|
|
151
|
+
normalizeAutoFastForwardPolicy,
|
|
152
|
+
resolveMaxParallelTasks,
|
|
153
|
+
MESH_MAX_PARALLEL_TASKS_MIN,
|
|
154
|
+
MESH_MAX_PARALLEL_TASKS_MAX,
|
|
146
155
|
MESH_CONVERGE_REFINE_TAG,
|
|
147
156
|
MESH_CONVERGE_FAST_FORWARD_TAG,
|
|
148
157
|
resolveAutoConvergeCodeChange,
|
|
@@ -246,6 +255,10 @@ export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary,
|
|
|
246
255
|
export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
|
|
247
256
|
export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
|
|
248
257
|
|
|
258
|
+
// ── Mesh Scheduling Runtime (observability projection) ──
|
|
259
|
+
export { buildMeshSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
|
|
260
|
+
export type { MeshSchedulingRuntime, MeshNodeSchedulingRuntime, MeshNodeProviderSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
|
|
261
|
+
|
|
249
262
|
// ── Mesh Host Ownership ──
|
|
250
263
|
export { buildMeshHostRequiredFailure, createDefaultMeshHostMetadata, isMeshHostOwner, normalizeMeshDaemonRole, requireMeshHostQueueOwner, resolveMeshHostStatus } from './mesh/mesh-host-ownership.js';
|
|
251
264
|
|
|
@@ -31,7 +31,7 @@ import type {
|
|
|
31
31
|
RepoMeshStatus,
|
|
32
32
|
RepoMeshNodeStatus,
|
|
33
33
|
} from '../repo-mesh-types.js';
|
|
34
|
-
import {
|
|
34
|
+
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Cheap, locally-derived "what just happened" snapshot for the coordinator
|
|
@@ -196,7 +196,7 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
|
|
|
196
196
|
if (operatingNotes) sections.push(operatingNotes);
|
|
197
197
|
|
|
198
198
|
// ── Policy ──
|
|
199
|
-
sections.push(buildPolicySection(
|
|
199
|
+
sections.push(buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)));
|
|
200
200
|
|
|
201
201
|
// ── Tools ──
|
|
202
202
|
sections.push(TOOLS_SECTION);
|
|
@@ -278,7 +278,7 @@ function expandPromptPlaceholders(template: string, ctx: CoordinatorPromptContex
|
|
|
278
278
|
mission: ctx.missionSection?.trim() || '',
|
|
279
279
|
recentActivity: buildRecentActivitySection(ctx.recentActivity) || '',
|
|
280
280
|
operatingNotes: buildOperatingNotesSection(ctx.operatingNotes) || '',
|
|
281
|
-
policy: buildPolicySection(
|
|
281
|
+
policy: buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)),
|
|
282
282
|
tools: TOOLS_SECTION,
|
|
283
283
|
workflow: WORKFLOW_SECTION,
|
|
284
284
|
rules: buildRulesSection(coordinatorCliType),
|
|
@@ -9,6 +9,7 @@ import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus, recordCompl
|
|
|
9
9
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
10
10
|
import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
|
|
11
11
|
import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
|
|
12
|
+
import { resolveMeshHostStatus } from './mesh-host-ownership.js';
|
|
12
13
|
import { enqueueUnresolvedDelegateForward, peekUnresolvedDelegateForwards, ackUnresolvedDelegateForward } from './mesh-unresolved-forward-outbox.js';
|
|
13
14
|
import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
|
|
14
15
|
import { getLastDisplayMessage } from '../status/snapshot.js';
|
|
@@ -92,6 +93,38 @@ function recoverMeshIdByNodeId(nodeId: string): string {
|
|
|
92
93
|
return '';
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
// MESHID-DROP coordinator-anchor recovery (Fix B): the last-resort meshId recovery for an
|
|
97
|
+
// unresolved-delegate forward whose payload carries the worker's coordinator anchor
|
|
98
|
+
// (meshCoordinatorDaemonId) but no resolvable meshId — neither workspace nor nodeId scan
|
|
99
|
+
// matched (a freshly-cloned worktree node not yet registered under the forwarded id form, or
|
|
100
|
+
// an empty payload nodeId). The receiving daemon IS the coordinator/host, so scope to the
|
|
101
|
+
// meshes IT hosts whose host daemon matches the anchor (daemonIdsEquivalent — never a raw
|
|
102
|
+
// compare, so daemon_mach_/mach_/standalone_ forms all match the same machine). Among those:
|
|
103
|
+
// • a nodeId → the mesh whose nodes contain it (meshNodeIdMatches 3-form) wins;
|
|
104
|
+
// • no nodeId → fall back to the anchor's SINGLE hosted mesh only. Ambiguity (the anchor
|
|
105
|
+
// hosts >1 mesh and no node disambiguates) returns '' rather than guess — a wrong meshId
|
|
106
|
+
// would inject the completion into an unrelated mesh's ledger, worse than the retry-cap drop.
|
|
107
|
+
export function recoverMeshIdByCoordinatorAndNode(coordinatorDaemonId: string, nodeId: string): string {
|
|
108
|
+
if (!coordinatorDaemonId) return '';
|
|
109
|
+
const hosted = listMeshes().filter(mesh => {
|
|
110
|
+
const host = resolveMeshHostStatus(mesh);
|
|
111
|
+
return host.role === 'host'
|
|
112
|
+
&& (!host.hostDaemonId || daemonIdsEquivalent(host.hostDaemonId, coordinatorDaemonId));
|
|
113
|
+
});
|
|
114
|
+
if (hosted.length === 0) return '';
|
|
115
|
+
if (nodeId) {
|
|
116
|
+
const byNode = hosted.find(mesh =>
|
|
117
|
+
Array.isArray(mesh.nodes) && mesh.nodes.some((n: any) => meshNodeIdMatches(n, nodeId)));
|
|
118
|
+
if (byNode) return readNonEmptyString(byNode.id);
|
|
119
|
+
// nodeId present but matched no hosted mesh — do NOT fall through to the single-mesh
|
|
120
|
+
// guess; the node belongs to a mesh we don't host (or under a different id), and
|
|
121
|
+
// guessing would misroute. Stay unresolved.
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
// No nodeId to disambiguate: only safe when the anchor hosts exactly one mesh.
|
|
125
|
+
return hosted.length === 1 ? readNonEmptyString(hosted[0].id) : '';
|
|
126
|
+
}
|
|
127
|
+
|
|
95
128
|
// RECONCILE-MESHID-DROP: WORKER-side meshId resolution for an unresolved-delegate
|
|
96
129
|
// forward payload. forwardUnresolvedDelegateEvent omits meshId by design (the worker
|
|
97
130
|
// "can't resolve it") and relies on the COORDINATOR recovering it from workspace/nodeId.
|
|
@@ -886,8 +919,24 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
886
919
|
// sibling/stale dispatch row this event does not own, marking it 'acked' prematurely and
|
|
887
920
|
// hiding a genuine non-delivery. Skip the dispatch ack for that ghost case (the delivery
|
|
888
921
|
// acks below are bound to actual deliveries and stay a no-op for a warmup session).
|
|
889
|
-
|
|
922
|
+
//
|
|
923
|
+
// MESH-DISPATCH-MISROUTE (fix 3, consumer residual): when the event carries no taskId
|
|
924
|
+
// (a legacy/relayed worker whose producer never stamped meshActiveTaskId) but the
|
|
925
|
+
// session owns EXACTLY ONE active dispatch, resolve that row's taskId and flip it by PK
|
|
926
|
+
// instead of the session_id sweep — the sweep flips every non-terminal row for the
|
|
927
|
+
// session ("may flip a sibling dispatch row"). With ≥2 active rows the owner is
|
|
928
|
+
// ambiguous, so resolvedAckTaskId stays undefined and we DROP the ack rather than
|
|
929
|
+
// mis-flip a sibling (the genuine ack arrives once the producer/reconcile names a task).
|
|
930
|
+
if (startedTaskId) {
|
|
890
931
|
updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
|
|
932
|
+
} else if (sessionHasActiveAssignment(args.meshId, sessionId)) {
|
|
933
|
+
const soleTaskId = (() => {
|
|
934
|
+
try { return MeshRuntimeStore.getInstance().getSoleActiveDirectDispatchTaskId(args.meshId, sessionId); }
|
|
935
|
+
catch { return null; }
|
|
936
|
+
})();
|
|
937
|
+
if (soleTaskId) {
|
|
938
|
+
updateDirectDispatchStatus(args.meshId, sessionId, 'acked', soleTaskId);
|
|
939
|
+
}
|
|
891
940
|
}
|
|
892
941
|
const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
|
|
893
942
|
try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
|
|
@@ -1218,7 +1267,14 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
1218
1267
|
// The nodeId is a stable coordinator-side fact and resolves timing-independently.
|
|
1219
1268
|
const meshId = readNonEmptyString(payload.meshId)
|
|
1220
1269
|
|| (workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '')
|
|
1221
|
-
|| recoverMeshIdByNodeId(nodeId)
|
|
1270
|
+
|| recoverMeshIdByNodeId(nodeId)
|
|
1271
|
+
// Fix B last resort: workspace + nodeId scan both missed, but the worker stamped
|
|
1272
|
+
// its coordinator anchor onto the forward (forwardUnresolvedDelegateEvent). Recover
|
|
1273
|
+
// via the hosted mesh that anchor owns (+ node disambiguation), guarding ambiguity.
|
|
1274
|
+
|| recoverMeshIdByCoordinatorAndNode(
|
|
1275
|
+
readNonEmptyString(payload.meshCoordinatorDaemonId) || readNonEmptyString(payload.coordinatorDaemonId),
|
|
1276
|
+
nodeId,
|
|
1277
|
+
);
|
|
1222
1278
|
if (!meshId) {
|
|
1223
1279
|
// EVTTRACE: forwarded event rejected at receive — no meshId could be resolved
|
|
1224
1280
|
// (no payload.meshId, no workspace→mesh, no nodeId→mesh). Observation only.
|
|
@@ -1348,6 +1404,11 @@ function forwardUnresolvedDelegateEvent(
|
|
|
1348
1404
|
event: eventName,
|
|
1349
1405
|
nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId) || undefined,
|
|
1350
1406
|
workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
|
|
1407
|
+
// Fix B: carry the resolved coordinator anchor so the coordinator's receive-side
|
|
1408
|
+
// recovery (recoverMeshIdByCoordinatorAndNode) can match this forward to one of the
|
|
1409
|
+
// meshes it hosts when workspace + nodeId both miss. routing.coordinatorDaemonId is
|
|
1410
|
+
// the same anchor this forward is addressed to (coordinatorDaemonId below).
|
|
1411
|
+
meshCoordinatorDaemonId: coordinatorDaemonId,
|
|
1351
1412
|
};
|
|
1352
1413
|
// RECONCILE-MESHID-DROP: stamp meshId when the WORKER can resolve it (member node /
|
|
1353
1414
|
// live-session meshNodeFor). Historically omitted "because the worker can't resolve
|
|
@@ -903,6 +903,29 @@ export class MeshRuntimeStore {
|
|
|
903
903
|
`).run({ status, meshId, sessionId, updatedAt: now });
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/**
|
|
907
|
+
* MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
|
|
908
|
+
* non-terminal direct dispatch a session owns. Returns the task_id only when the session
|
|
909
|
+
* holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
|
|
910
|
+
* lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
|
|
911
|
+
* unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
|
|
912
|
+
* two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
|
|
913
|
+
* return null and the caller MUST NOT fall back to the session_id sweep that would flip a
|
|
914
|
+
* sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
|
|
915
|
+
* only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
|
|
916
|
+
*/
|
|
917
|
+
getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null {
|
|
918
|
+
if (!sessionId) return null;
|
|
919
|
+
const rows = this.db.prepare(`
|
|
920
|
+
SELECT task_id FROM mesh_direct_dispatches
|
|
921
|
+
WHERE mesh_id = ? AND session_id = ?
|
|
922
|
+
AND status NOT IN ('completed', 'failed', 'stale')
|
|
923
|
+
`).all(meshId, sessionId) as Array<{ task_id: string }>;
|
|
924
|
+
if (rows.length !== 1) return null;
|
|
925
|
+
const taskId = typeof rows[0]?.task_id === 'string' ? rows[0].task_id.trim() : '';
|
|
926
|
+
return taskId || null;
|
|
927
|
+
}
|
|
928
|
+
|
|
906
929
|
cleanupTerminalDirectDispatches(olderThanMs: number): void {
|
|
907
930
|
const cutoff = new Date(Date.now() - olderThanMs).toISOString();
|
|
908
931
|
this.db.prepare(`
|