@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.
@@ -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.380",
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.380",
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 { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
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
- const SESSION_CLEANUP_MODES = new Set(['preserve', 'stop', 'delete_stopped', 'stop_and_delete']);
155
- const SPAWNED_SESSION_VISIBILITY_MODES = new Set(['visible', 'hidden']);
156
-
157
- function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy {
158
- const autoFastForward = normalizeAutoFastForwardPolicy({
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 { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
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({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }));
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({ ...DEFAULT_MESH_POLICY, ...(mesh.policy || {}) }),
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
- if (startedTaskId || sessionHasActiveAssignment(args.meshId, sessionId)) {
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(`