@adhdev/daemon-core 0.9.82-rc.508 → 0.9.82-rc.509

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.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Node capability-slot resolution — the single source of truth for a node's
3
+ * effective slots (ORCHESTRATION_NODE_SLOTS.md). Every layer that needs a node's
4
+ * slots (queue claim/launch caps, scheduling-runtime status projection, coordinator
5
+ * prompt) resolves them here so the "explicit policy.slots, else legacy-derived"
6
+ * rule is applied identically everywhere.
7
+ *
8
+ * Kept as a tiny standalone module (rather than living in mesh-queue-assignment)
9
+ * so importing slot resolution does not drag in the whole assignment engine and to
10
+ * avoid an import cycle between the status builder and the assignment engine.
11
+ */
12
+ import { type NodeCapabilitySlot } from '@adhdev/mesh-shared';
13
+ /** Ordered, de-duplicated providerPriority from a node policy (defensive). */
14
+ export declare function normalizeProviderPriority(policy: unknown): string[];
15
+ /**
16
+ * Resolve a node's capability slots: explicit `policy.slots` when present, else
17
+ * derived from the legacy `providerPriority` + machine-global difficultyBrains.
18
+ * (The former per-provider `providerRoles` cap has been removed; a persisted
19
+ * meshes.json is migrated to slots on load, so by the time a node reaches routing
20
+ * its cap already lives on `slots[].maxParallel`.)
21
+ */
22
+ export declare function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[];
@@ -90,8 +90,8 @@ export declare class MeshRuntimeStore {
90
90
  /**
91
91
  * Count active (status='assigned') tasks on a (node, provider) combination,
92
92
  * matched by the assignedProviderType stamped on the payload at claim time.
93
- * Drives the per-(node, provider) maxParallel cap (RepoMeshNodePolicy
94
- * providerRoles). The active-assignment set for a single node is tiny, so
93
+ * Drives the per-(node, provider) maxParallel cap (summed across a provider's
94
+ * slots[].maxParallel). The active-assignment set for a single node is tiny, so
95
95
  * parsing payloads here is cheap and avoids a schema migration. Pre-cap legacy
96
96
  * rows (no provider stamp) and other providers on the same node do not consume
97
97
  * this provider's budget, so the cap is fully backward compatible.
@@ -19,7 +19,11 @@ export interface MeshNodeSchedulingRuntime {
19
19
  schedulingPriority: number;
20
20
  /** Per-node concurrent-session cap, when configured. */
21
21
  maxConcurrentSessions?: number;
22
- /** Per-(node, provider) caps + consumption, when providerRoles declares any. */
22
+ /**
23
+ * Per-(node, provider) caps + consumption, when the node's slots declare a
24
+ * maxParallel for any provider. (Field name kept for dashboard back-compat;
25
+ * the cap source is now slots[].maxParallel, not the removed providerRoles.)
26
+ */
23
27
  providerRoles?: MeshNodeProviderSchedulingRuntime[];
24
28
  /**
25
29
  * True when the node currently cannot claim a NEW write (non-readonly) task —
@@ -147,8 +147,8 @@ export interface MeshWorkQueueEntry {
147
147
  assignedSessionId?: string;
148
148
  /**
149
149
  * Provider type of the session that claimed the task. Recorded so the queue
150
- * can enforce per-(node, provider) maxParallel caps (RepoMeshNodePolicy
151
- * providerRoles) by counting active assignments grouped by node + provider.
150
+ * can enforce per-(node, provider) maxParallel caps (summed slots[].maxParallel)
151
+ * by counting active assignments grouped by node + provider.
152
152
  */
153
153
  assignedProviderType?: string;
154
154
  /** Human/operator reason for terminal cancellation. */
@@ -323,7 +323,7 @@ export declare function getMeshQueueRevision(meshId: string): string;
323
323
  *
324
324
  * `opts.providerType` is stamped onto the claimed entry (assignedProviderType) so
325
325
  * per-(node, provider) caps can be counted. `opts.providerMaxParallel`, when set,
326
- * is the enforced per-(node, provider) cap from RepoMeshNodePolicy.providerRoles:
326
+ * is the enforced per-(node, provider) cap (summed slots[].maxParallel):
327
327
  * a task is not assigned to this (node, provider) once it already has that many
328
328
  * active assignments. This composes with the global/taskMode caps (stricter wins).
329
329
  */
@@ -38,6 +38,7 @@ export type ExternalTranscriptProbe = {
38
38
  export declare const COMPLETED_FINALIZATION_RETRY_MS = 1000;
39
39
  export declare const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30000;
40
40
  export declare const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
41
+ export declare const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
41
42
  export declare const BACKGROUND_TASK_HOLD_MAX_MS: number;
42
43
  export declare const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60000;
43
44
  export declare const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12000;
@@ -114,6 +114,39 @@ export declare class CliProviderInstance implements ProviderInstance {
114
114
  * the nudge was NOT deferred) and leaked to the coordinator.
115
115
  */
116
116
  private static readonly AUTO_APPROVE_MASK_STALL_MS;
117
+ /**
118
+ * AUTOAPPROVE-FLAP-INBOX-MISSING: sticky-approval overlay window. Same time-tick
119
+ * hold idea as the FALSE-IDLE completion gate — an approval signal that was
120
+ * DOMINANT within this recent window is re-presented across a momentary busy blip
121
+ * instead of collapsing.
122
+ *
123
+ * RCA (live 2026-07-13): a claude-cli worker sitting at a Bash approval modal
124
+ * ("Do you want to proceed? ❯1.Yes") flaps waiting_approval↔busy on a ~2-3s period.
125
+ * The spec `approval→busy` transition fires whenever the footer/modal approval
126
+ * markers momentarily drop out of their parsed sections while the PRIOR command's
127
+ * residual spinner text ("✳ Checking vendor drift…") still matches the busy regex.
128
+ * On the busy frame the adapter reports status='generating', activeModal=null. That
129
+ * corrupts THREE consumers at once: (1) mesh_active_work samples 'generating' →
130
+ * collectPendingApprovals never sees 'awaiting_approval' → mesh_list_pending_approvals
131
+ * count:0 (inbox miss); (2) the auto-approve settle gate is torn down each busy phase
132
+ * so the 600ms settle never accrues → auto-approve never fires; (3) a mesh_approve
133
+ * landing on a busy frame hits "Not in approval state". The existing FLAP machinery
134
+ * (AUTO_APPROVE_FLAP_CONTINUITY_MS) only keeps the settle gate warm while status is
135
+ * STILL waiting_approval (buttons scrolled out) — it does nothing once the FSM fully
136
+ * commits to 'busy', and it never stabilises the status the inbox samples.
137
+ *
138
+ * Fix: when the raw adapter status flaps to generating/busy/idle but a
139
+ * waiting_approval WITH a concrete modal was observed within this window, overlay
140
+ * the cached modal and report status='waiting_approval'. This stabilized status
141
+ * feeds getState (→ inbox), detectStatusTransition (→ event emission), and
142
+ * maybeAutoApproveStatus (→ settle gate) uniformly, so the approval both registers
143
+ * in the inbox and settles for auto-approve across the flap. Bounded (a genuine
144
+ * resume that never returns to approval unmasks after this window) and scoped at the
145
+ * call site to autonomous mesh sessions. 4000ms bridges the observed ~2-3s flap with
146
+ * margin while staying well under AUTO_APPROVE_MASK_STALL_MS (a truly stalled/absent
147
+ * approval still surfaces).
148
+ */
149
+ private static readonly APPROVAL_STICKY_FLAP_MS;
117
150
  /**
118
151
  * FALSE-IDLE (inter-approval quiet valley): grace window after an auto-approve
119
152
  * (or mesh_approve) RESOLVES a modal during which a subsequent generating→idle
@@ -159,6 +192,9 @@ export declare class CliProviderInstance implements ProviderInstance {
159
192
  private autoApproveSettleTimer;
160
193
  private autoApproveInactiveSince;
161
194
  private autoApproveLastModalSeenAt;
195
+ private approvalStickyLastConcreteAt;
196
+ private approvalStickyModal;
197
+ private approvalStickyEntrySeq;
162
198
  private autoApproveMaskSince;
163
199
  private stalledApprovalNudgeEpisode;
164
200
  private readonly manualAttendance;
@@ -487,6 +523,24 @@ export declare class CliProviderInstance implements ProviderInstance {
487
523
  * the emitted event, exactly as each inline builder produced before.
488
524
  */
489
525
  private emitGeneratingCompleted;
526
+ /**
527
+ * AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay. Returns the adapterStatus a
528
+ * flap-prone claude-cli approval SHOULD present this frame — either the raw status
529
+ * unchanged, or, when the raw status has momentarily flapped OFF a recently-dominant
530
+ * concrete approval, a synthetic `waiting_approval` re-presenting the cached modal.
531
+ *
532
+ * Records the concrete approval whenever the raw status is waiting_approval WITH
533
+ * buttons. On a subsequent non-approval frame (the spec `approval→busy` flap), if that
534
+ * concrete approval was seen within APPROVAL_STICKY_FLAP_MS AND the engine has NOT
535
+ * resolved a modal since (lastApprovalResolvedAt not advanced past the sticky start),
536
+ * overlay the cached modal + waiting_approval so the inbox / auto-approve / mesh_approve
537
+ * all see the stable approval. A genuine resolution (auto-approve or mesh_approve fires
538
+ * resolveModal → lastApprovalResolvedAt advances) clears the sticky immediately, so a
539
+ * legitimate post-approval resume is NEVER masked as a lingering approval. Bounded by the
540
+ * window, and scoped to autonomous mesh sessions (a foreground/attended or non-mesh
541
+ * session, where a human answers the prompt, is returned untouched).
542
+ */
543
+ private stabilizeFlappingApprovalStatus;
490
544
  private maybeAutoApproveStatus;
491
545
  /**
492
546
  * Re-drive the auto-approve check after the settle quiet window elapses.
@@ -291,16 +291,15 @@ export interface RepoMeshRelatedRepo {
291
291
  * must satisfy both. Omitting `maxParallel` means this provider is bounded only
292
292
  * by the global/taskMode caps (full backward compatibility).
293
293
  *
294
- * Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags);
295
- * this entry carries no routing role. To route work to a specific node, advertise an
296
- * ordinary capability tag on the node and require it on the task.
294
+ * Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags).
295
+ * To route work to a specific node, advertise an ordinary capability tag on the
296
+ * node and require it on the task.
297
+ *
298
+ * NOTE: the per-(node, provider) parallelism cap now lives on `slots[].maxParallel`
299
+ * (see NodeCapabilitySlot). The former `providerRoles` field has been removed; a
300
+ * persisted meshes.json that still carries it is migrated to `slots` on load
301
+ * (see migrateLoadedMeshConfig).
297
302
  */
298
- export interface RepoMeshProviderRole {
299
- /** Provider type this entry governs (e.g. 'claude-cli', 'codex-cli'). */
300
- providerType: string;
301
- /** Max concurrent active tasks for this (node, provider). Omit = no per-provider cap. */
302
- maxParallel?: number;
303
- }
304
303
  export interface RepoMeshNodePolicy {
305
304
  readOnly?: boolean;
306
305
  canPush?: boolean;
@@ -315,26 +314,14 @@ export interface RepoMeshNodePolicy {
315
314
  schedulingPriority?: number;
316
315
  /** Ordered provider preference used when mesh_launch_session omits an explicit type. */
317
316
  providerPriority?: string[];
318
- /**
319
- * Per-(node, provider) parallelism declarations. Each entry binds a
320
- * providerType on THIS node to an optional maxParallel cap. maxParallel is
321
- * enforced as an additional, stricter-wins constraint on top of the global
322
- * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
323
- * before (global caps only). Routing is governed solely by required_tags.
324
- *
325
- * SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
326
- * when `slots` is absent, providerRoles + providerPriority + the machine-global
327
- * difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
328
- */
329
- providerRoles?: RepoMeshProviderRole[];
330
317
  /**
331
318
  * Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
332
319
  * AI tools" profile that is the single source of truth for task routing, MAGI
333
320
  * fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
334
321
  * + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
335
322
  * Order = preference. When absent, the scheduler derives slots from the legacy
336
- * providerPriority/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
337
- * existing nodes keep working without reconfiguration.
323
+ * providerPriority/difficultyBrains (deriveSlotsFromLegacy) so existing nodes
324
+ * keep working without reconfiguration.
338
325
  */
339
326
  slots?: NodeCapabilitySlot[];
340
327
  /**
@@ -448,13 +435,22 @@ export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined
448
435
  */
449
436
  export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null): boolean;
450
437
  /**
451
- * Resolve the enforced per-(node, provider) maxParallel cap, or undefined when
452
- * no finite, non-negative cap is declared for this provider. Used by the queue
453
- * claim path as a stricter-wins constraint layered on top of the global caps.
454
- * Case-insensitive, trimmed match on providerType. Defensive against malformed
455
- * config — non-object entries and blank providerTypes are skipped rather than throwing.
438
+ * Resolve the enforced per-(node, provider) maxParallel cap from a node's resolved
439
+ * capability slots, or undefined when no matching slot declares a finite cap. Used
440
+ * by the queue claim path as a stricter-wins constraint layered on top of the global
441
+ * caps. Case-insensitive, trimmed match on the slot's provider.
442
+ *
443
+ * When a node declares multiple slots for the same provider (e.g. distinct
444
+ * difficulty ranges), their caps SUM into a single per-(node, provider) pool — the
445
+ * provider can run up to the total across all its slots. Legacy-derived slots (via
446
+ * deriveSlotsFromLegacy) produce one slot per provider, so the sum equals that
447
+ * single slot's cap and behavior is preserved exactly.
448
+ *
449
+ * Callers pass the already-resolved slots (explicit policy.slots, else legacy-
450
+ * derived) — this keeps the resolver free of the difficultyBrains dependency and
451
+ * usable from any layer.
456
452
  */
457
- export declare function resolveProviderMaxParallel(nodePolicy: Pick<RepoMeshNodePolicy, 'providerRoles'> | null | undefined, providerType: string | null | undefined): number | undefined;
453
+ export declare function resolveProviderMaxParallel(slots: NodeCapabilitySlot[] | null | undefined, providerType: string | null | undefined): number | undefined;
458
454
  export interface RepoMeshNodeCapabilities {
459
455
  /** Node's OS, raw NodeJS.Platform value ("darwin"/"win32"/"linux"). For
460
456
  * remote member nodes this is stamped by the member daemon at join time
@@ -700,6 +696,11 @@ export interface RepoMeshNodeSchedulingStatus {
700
696
  load: number;
701
697
  schedulingPriority?: number;
702
698
  maxConcurrentSessions?: number;
699
+ /**
700
+ * Per-(node, provider) caps + consumption. Field name kept for dashboard
701
+ * back-compat; the cap source is now slots[].maxParallel (the removed
702
+ * policy.providerRoles no longer exists).
703
+ */
703
704
  providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
704
705
  capReached: boolean;
705
706
  capReasons?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.508",
3
+ "version": "0.9.82-rc.509",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.508",
51
- "@adhdev/session-host-core": "0.9.82-rc.508",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.509",
51
+ "@adhdev/session-host-core": "0.9.82-rc.509",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -489,17 +489,20 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
489
489
  const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'node addition');
490
490
  if (ownerFailure) return ownerFailure;
491
491
  try {
492
- const { addNode } = await import('../../config/mesh-config.js');
492
+ const { addNode, migrateProviderRolesToSlots } = await import('../../config/mesh-config.js');
493
493
  const providerPriority = Array.isArray(args?.providerPriority)
494
494
  ? args.providerPriority.map((type: any) => typeof type === 'string' ? type.trim() : '').filter(Boolean)
495
495
  : [];
496
496
  const readOnly = args?.readOnly === true;
497
+ // Back-compat: an incoming `providerRoles` arg (legacy callers) is folded
498
+ // into `slots[].maxParallel` — the field itself is no longer persisted.
497
499
  const providerRoles = normalizeProviderRoles(args?.providerRoles);
498
- const policy = {
500
+ const policy: Record<string, unknown> = {
499
501
  ...(readOnly ? { readOnly: true } : {}),
500
502
  ...(providerPriority.length ? { providerPriority } : {}),
501
503
  ...(providerRoles.length ? { providerRoles } : {}),
502
504
  };
505
+ if (providerRoles.length) migrateProviderRolesToSlots(policy);
503
506
  const role = normalizeMeshDaemonRole(args?.role);
504
507
  const daemonId = typeof args?.daemonId === 'string' && args.daemonId.trim() ? args.daemonId.trim() : undefined;
505
508
  const machineId = typeof args?.machineId === 'string' && args.machineId.trim() ? args.machineId.trim() : undefined;
@@ -537,7 +540,7 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
537
540
  const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'node update');
538
541
  if (ownerFailure) return ownerFailure;
539
542
  try {
540
- const { updateNode, normalizeCapabilityTags } = await import('../../config/mesh-config.js');
543
+ const { updateNode, normalizeCapabilityTags, migrateProviderRolesToSlots } = await import('../../config/mesh-config.js');
541
544
  const policy = args?.policy && typeof args.policy === 'object' && !Array.isArray(args.policy)
542
545
  ? { ...(args.policy as Record<string, unknown>) }
543
546
  : {};
@@ -552,18 +555,17 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
552
555
  delete (policy as any).providerPriority;
553
556
  }
554
557
  }
555
- // providerRoles: per-(node, provider) role label + maxParallel cap.
556
- // Passing an explicit (possibly empty) array clears/replaces the
557
- // declarations; omitting the arg leaves any value already on policy
558
- // untouched (a full policy object passed by the caller still carries it).
558
+ // Back-compat: a legacy `providerRoles` arg is folded into
559
+ // `slots[].maxParallel` the per-(node, provider) cap now lives on slots.
560
+ // The field itself is never persisted (migrateProviderRolesToSlots deletes
561
+ // it). A full policy object passed by the caller that still carries
562
+ // providerRoles is likewise migrated.
559
563
  if (Array.isArray(args?.providerRoles)) {
560
564
  const providerRoles = normalizeProviderRoles(args.providerRoles);
561
- if (providerRoles.length) {
562
- (policy as any).providerRoles = providerRoles;
563
- } else {
564
- delete (policy as any).providerRoles;
565
- }
565
+ if (providerRoles.length) (policy as any).providerRoles = providerRoles;
566
+ else delete (policy as any).providerRoles;
566
567
  }
568
+ migrateProviderRolesToSlots(policy);
567
569
  const patch: Record<string, unknown> = { policy: policy as any };
568
570
  if (typeof args?.systemPrompt === 'string') {
569
571
  const trimmed = (args.systemPrompt as string).trim();
@@ -1011,8 +1013,14 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
1011
1013
  }
1012
1014
 
1013
1015
  let node: any;
1016
+ const { migrateProviderRolesToSlots } = await import('../../config/mesh-config.js');
1014
1017
  if (meshRecord.inline) {
1015
1018
  const { randomUUID } = await import('crypto');
1019
+ const clonedPolicy: Record<string, unknown> = { ...(sourceNode.policy || {}) };
1020
+ // Defensive: a source policy that still carries the removed legacy
1021
+ // providerRoles (e.g. an inline node not yet load-migrated) has its cap
1022
+ // folded into slots so the clone never re-seeds providerRoles.
1023
+ migrateProviderRolesToSlots(clonedPolicy);
1016
1024
  node = {
1017
1025
  id: `node_${randomUUID().replace(/-/g, '')}`,
1018
1026
  workspace: result.worktreePath,
@@ -1020,7 +1028,7 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
1020
1028
  daemonId: sourceNode.daemonId,
1021
1029
  machineId: sourceNode.machineId ?? (sourceNode as any).machine_id,
1022
1030
  userOverrides: { ...(sourceNode.userOverrides || {}) },
1023
- policy: { ...(sourceNode.policy || {}) },
1031
+ policy: clonedPolicy as any,
1024
1032
  isLocalWorktree: true,
1025
1033
  worktreeBranch: result.branch,
1026
1034
  clonedFromNodeId: sourceNodeId,
@@ -1028,6 +1036,8 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
1028
1036
  ctx.updateInlineMeshNode(meshId, mesh, node);
1029
1037
  } else {
1030
1038
  const { addNode } = await import('../../config/mesh-config.js');
1039
+ const clonedPolicy: Record<string, unknown> = { ...(sourceNode.policy || {}) };
1040
+ migrateProviderRolesToSlots(clonedPolicy);
1031
1041
  node = addNode(meshId, {
1032
1042
  workspace: result.worktreePath,
1033
1043
  repoRoot: result.worktreePath,
@@ -1037,7 +1047,7 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
1037
1047
  isLocalWorktree: true,
1038
1048
  worktreeBranch: result.branch,
1039
1049
  clonedFromNodeId: sourceNodeId,
1040
- policy: { ...(sourceNode.policy || {}) },
1050
+ policy: clonedPolicy as any,
1041
1051
  });
1042
1052
  if (!node) return { success: false, error: 'Failed to register worktree node' };
1043
1053
  // Also reconcile the freshly-registered node into any warmed inline
@@ -22,8 +22,8 @@ import type {
22
22
  RepoMeshHostMetadata,
23
23
  RepoMeshDaemonRole,
24
24
  } from '../repo-mesh-types.js';
25
- import type { MagiKindPanelMap, MagiSlot, MagiTaskKind, DifficultyBrainMap } from '@adhdev/mesh-shared';
26
- import { normalizeDifficultyBrainMap, DEFAULT_DIFFICULTY_BRAINS } from '@adhdev/mesh-shared';
25
+ import type { MagiKindPanelMap, MagiSlot, MagiTaskKind, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
26
+ import { normalizeDifficultyBrainMap, DEFAULT_DIFFICULTY_BRAINS, normalizeNodeCapabilitySlots, deriveSlotsFromLegacy } from '@adhdev/mesh-shared';
27
27
  import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
28
28
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
29
29
 
@@ -63,12 +63,14 @@ function loadMeshConfig(): LocalMeshConfig {
63
63
  * outlived the feature that wrote it so the persisted config converges on the
64
64
  * current schema the next time it is saved.
65
65
  *
66
- * Currently: drops the dead `role` field from each node policy's providerRoles
67
- * entries. providerRoles is retained for its `maxParallel` per-(node, provider)
68
- * cap, but the routing `role` was removed (routing is governed solely by
69
- * required_tags). A meshes.json written before the removal still carries
70
- * `role: "validator"` etc.; this drops it on load so mesh_status / mesh_list_nodes
71
- * never surface the dead field and the next saveMeshConfig() persists it gone.
66
+ * Currently: migrates the removed `providerRoles` per-(node, provider) cap onto
67
+ * `slots[].maxParallel`. A meshes.json written before the removal carries
68
+ * `providerRoles: [{ providerType, maxParallel }]` (possibly alongside a dead
69
+ * `role` field). On load we fold each cap into the node's slots — into an existing
70
+ * matching-provider slot that has no cap, else by deriving slots from the legacy
71
+ * providerPriority/providerRoles when the node had no explicit slots then delete
72
+ * `providerRoles` so mesh_status / mesh_list_nodes never surface the removed field
73
+ * and the next saveMeshConfig() persists it gone.
72
74
  *
73
75
  * Returns true when the config was mutated (caller may persist eagerly).
74
76
  */
@@ -77,31 +79,76 @@ function migrateLoadedMeshConfig(config: LocalMeshConfig): boolean {
77
79
  for (const mesh of config.meshes) {
78
80
  if (!mesh || !Array.isArray(mesh.nodes)) continue;
79
81
  for (const node of mesh.nodes) {
80
- if (stripDeadRoleFromProviderRoles(node?.policy)) changed = true;
82
+ if (migrateProviderRolesToSlots(node?.policy)) changed = true;
81
83
  }
82
84
  }
83
85
  return changed;
84
86
  }
85
87
 
86
88
  /**
87
- * Drop the legacy `role` field from each providerRoles entry of a node policy,
88
- * in place. Keeps providerType + maxParallel (the still-meaningful per-(node,
89
- * provider) cap). Defensive against malformed entries non-object items are
90
- * left untouched. Returns true when at least one `role` field was removed.
89
+ * Migrate a node policy's legacy `providerRoles` cap onto `slots[].maxParallel`,
90
+ * in place, then delete the `providerRoles` field. Defensive against malformed
91
+ * entries. Returns true when the policy was mutated.
92
+ *
93
+ * Behavior-preserving: the resulting slots carry the same per-(node, provider)
94
+ * cap the queue previously enforced from providerRoles. When the node had no
95
+ * explicit slots, slots are derived from the legacy providerPriority (folding the
96
+ * caps in via deriveSlotsFromLegacy-equivalent logic); when it did, each cap is
97
+ * merged into the first matching-provider slot lacking a maxParallel.
91
98
  */
92
- function stripDeadRoleFromProviderRoles(policy: unknown): boolean {
93
- if (!policy || typeof policy !== 'object') return false;
94
- const roles = (policy as { providerRoles?: unknown }).providerRoles;
95
- if (!Array.isArray(roles)) return false;
96
- let changed = false;
97
- for (const entry of roles) {
98
- if (entry && typeof entry === 'object' && !Array.isArray(entry)
99
- && Object.prototype.hasOwnProperty.call(entry, 'role')) {
100
- delete (entry as Record<string, unknown>).role;
101
- changed = true;
99
+ export function migrateProviderRolesToSlots(policy: unknown): boolean {
100
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return false;
101
+ const p = policy as Record<string, unknown>;
102
+ const rawRoles = p.providerRoles;
103
+ if (!Array.isArray(rawRoles)) return false;
104
+
105
+ // Extract provider cap from the legacy roles (case-insensitive key, last wins).
106
+ const roleCap = new Map<string, { provider: string; cap: number }>();
107
+ for (const entry of rawRoles) {
108
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
109
+ const rec = entry as Record<string, unknown>;
110
+ const provider = typeof rec.providerType === 'string' ? rec.providerType.trim() : '';
111
+ if (!provider) continue;
112
+ const cap = Number(rec.maxParallel);
113
+ if (!Number.isFinite(cap) || cap < 0) continue;
114
+ roleCap.set(provider.toLowerCase(), { provider, cap: Math.floor(cap) });
115
+ }
116
+
117
+ const explicitSlots = Array.isArray(p.slots)
118
+ ? normalizeNodeCapabilitySlots(p.slots)
119
+ : [];
120
+
121
+ if (explicitSlots.length) {
122
+ // Merge each cap into the first matching-provider slot that has no cap yet.
123
+ for (const { provider, cap } of roleCap.values()) {
124
+ const target = explicitSlots.find(s =>
125
+ s.provider.trim().toLowerCase() === provider.toLowerCase()
126
+ && s.maxParallel === undefined);
127
+ if (target) target.maxParallel = cap;
102
128
  }
129
+ p.slots = explicitSlots;
130
+ } else if (roleCap.size) {
131
+ // No explicit slots: derive from legacy providerPriority, then fold caps in.
132
+ // Falls back to a provider-per-role slot list when providerPriority is empty
133
+ // so the cap is never silently dropped.
134
+ let difficultyBrains: DifficultyBrainMap | undefined;
135
+ try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
136
+ const priority = Array.isArray(p.providerPriority)
137
+ ? (p.providerPriority as unknown[]).map(t => typeof t === 'string' ? t.trim() : '').filter(Boolean)
138
+ : [];
139
+ const derived = deriveSlotsFromLegacy({ providerPriority: priority, difficultyBrains });
140
+ const slots: NodeCapabilitySlot[] = derived.length
141
+ ? derived
142
+ : [...roleCap.values()].map(r => ({ provider: r.provider }));
143
+ for (const slot of slots) {
144
+ const match = roleCap.get(slot.provider.trim().toLowerCase());
145
+ if (match && slot.maxParallel === undefined) slot.maxParallel = match.cap;
146
+ }
147
+ p.slots = slots;
103
148
  }
104
- return changed;
149
+
150
+ delete p.providerRoles;
151
+ return true;
105
152
  }
106
153
 
107
154
  export function normalizeCapabilityTags(value: unknown): string[] | undefined {
@@ -31,8 +31,9 @@ import type {
31
31
  RepoMeshStatus,
32
32
  RepoMeshNodeStatus,
33
33
  } from '../repo-mesh-types.js';
34
- import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
34
+ import { mergeAndNormalizePolicy, resolveProviderMaxParallel } from '../repo-mesh-types.js';
35
35
  import { getDifficultyBrains } from '../config/mesh-config.js';
36
+ import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
36
37
  import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
37
38
  import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
38
39
 
@@ -459,19 +460,23 @@ function buildNodeConfigSection(mesh: LocalMeshEntry): string {
459
460
  const explicitMachineLabel = typeof (n as any).machineLabel === 'string' ? (n as any).machineLabel : '';
460
461
  const explicitLabel = explicitMachineLabel ? ` label: **${explicitMachineLabel}** |` : '';
461
462
  const providerPriority = n.policy?.providerPriority?.length ? ` | providers: ${n.policy.providerPriority.join(', ')}` : '';
462
- // Per-(node, provider) maxParallel cap. Only maxParallel is enforced by the
463
- // queue; routing is governed by required_tags, not provider roles.
464
- const providerRoles = Array.isArray(n.policy?.providerRoles)
465
- ? (n.policy!.providerRoles as Array<{ providerType?: unknown; maxParallel?: unknown }>)
466
- .map(r => {
467
- const type = typeof r?.providerType === 'string' ? r.providerType.trim() : '';
468
- if (!type) return '';
469
- const cap = Number.isFinite(Number(r?.maxParallel)) ? ` (max ${Math.floor(Number(r.maxParallel))})` : '';
470
- return `${type}${cap}`;
471
- })
472
- .filter(Boolean)
473
- : [];
474
- const providerRolesSuffix = providerRoles.length ? ` | caps: ${providerRoles.join(', ')}` : '';
463
+ // Per-(node, provider) maxParallel cap, derived from the node's slots (the
464
+ // cap summed across a provider's slots). Only maxParallel is enforced by the
465
+ // queue; routing is governed by required_tags, not slot order.
466
+ const nodeSlots = resolveNodeCapabilitySlots(n);
467
+ const seenCapProvider = new Set<string>();
468
+ const providerCaps: string[] = [];
469
+ for (const slot of nodeSlots) {
470
+ const type = typeof slot?.provider === 'string' ? slot.provider.trim() : '';
471
+ if (!type) continue;
472
+ const key = type.toLowerCase();
473
+ if (seenCapProvider.has(key)) continue;
474
+ seenCapProvider.add(key);
475
+ const cap = resolveProviderMaxParallel(nodeSlots, type);
476
+ if (cap === undefined) continue;
477
+ providerCaps.push(`${type} (max ${cap})`);
478
+ }
479
+ const providerRolesSuffix = providerCaps.length ? ` | caps: ${providerCaps.join(', ')}` : '';
475
480
  lines.push(`- ${explicitLabel} nodeId: \`${n.id}\` | workspace: \`${n.workspace}\`${n.daemonId ? ` | daemon: \`${n.daemonId}\`` : ''}${providerPriority}${providerRolesSuffix}${suffix}`);
476
481
  // Routing tags: what this node advertises for mesh_enqueue_task required_tags.
477
482
  // Surfaced so the coordinator can route by-capability (e.g. enqueue a Windows
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Node capability-slot resolution — the single source of truth for a node's
3
+ * effective slots (ORCHESTRATION_NODE_SLOTS.md). Every layer that needs a node's
4
+ * slots (queue claim/launch caps, scheduling-runtime status projection, coordinator
5
+ * prompt) resolves them here so the "explicit policy.slots, else legacy-derived"
6
+ * rule is applied identically everywhere.
7
+ *
8
+ * Kept as a tiny standalone module (rather than living in mesh-queue-assignment)
9
+ * so importing slot resolution does not drag in the whole assignment engine and to
10
+ * avoid an import cycle between the status builder and the assignment engine.
11
+ */
12
+ import {
13
+ deriveSlotsFromLegacy,
14
+ normalizeNodeCapabilitySlots,
15
+ type NodeCapabilitySlot,
16
+ } from '@adhdev/mesh-shared';
17
+ import { getDifficultyBrains } from '../config/mesh-config.js';
18
+
19
+ /** Ordered, de-duplicated providerPriority from a node policy (defensive). */
20
+ export function normalizeProviderPriority(policy: unknown): string[] {
21
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
22
+ ? (policy as Record<string, unknown>).providerPriority
23
+ : undefined;
24
+ if (!Array.isArray(raw)) return [];
25
+ const seen = new Set<string>();
26
+ return raw
27
+ .map(type => typeof type === 'string' ? type.trim() : '')
28
+ .filter(Boolean)
29
+ .filter(type => {
30
+ if (seen.has(type)) return false;
31
+ seen.add(type);
32
+ return true;
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Resolve a node's capability slots: explicit `policy.slots` when present, else
38
+ * derived from the legacy `providerPriority` + machine-global difficultyBrains.
39
+ * (The former per-provider `providerRoles` cap has been removed; a persisted
40
+ * meshes.json is migrated to slots on load, so by the time a node reaches routing
41
+ * its cap already lives on `slots[].maxParallel`.)
42
+ */
43
+ export function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
44
+ const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
45
+ if (explicit.length) return explicit;
46
+ let difficultyBrains: any;
47
+ try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
48
+ return deriveSlotsFromLegacy({
49
+ providerPriority: normalizeProviderPriority(node?.policy),
50
+ difficultyBrains,
51
+ });
52
+ }