@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.
- package/dist/config/mesh-config.d.ts +12 -0
- package/dist/index.js +246 -80
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +246 -80
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-node-slots.d.ts +22 -0
- package/dist/mesh/mesh-runtime-store.d.ts +2 -2
- package/dist/mesh/mesh-scheduling-runtime.d.ts +5 -1
- package/dist/mesh/mesh-work-queue.d.ts +3 -3
- package/dist/providers/cli-provider-instance-types.d.ts +1 -0
- package/dist/providers/cli-provider-instance.d.ts +54 -0
- package/dist/repo-mesh-types.d.ts +30 -29
- package/package.json +3 -3
- package/src/commands/med-family/mesh-crud.ts +24 -14
- package/src/config/mesh-config.ts +71 -24
- package/src/mesh/coordinator-prompt.ts +19 -14
- package/src/mesh/mesh-node-slots.ts +52 -0
- package/src/mesh/mesh-queue-assignment.ts +23 -23
- package/src/mesh/mesh-runtime-store.ts +3 -3
- package/src/mesh/mesh-scheduling-runtime.ts +24 -9
- package/src/mesh/mesh-work-queue.ts +3 -3
- package/src/providers/cli-provider-instance-types.ts +15 -0
- package/src/providers/cli-provider-instance.ts +174 -2
- package/src/repo-mesh-types.ts +39 -38
|
@@ -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 (
|
|
94
|
-
*
|
|
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
|
-
/**
|
|
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 (
|
|
151
|
-
*
|
|
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
|
|
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
|
-
*
|
|
296
|
-
*
|
|
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/
|
|
337
|
-
*
|
|
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
|
|
452
|
-
*
|
|
453
|
-
* claim path as a stricter-wins constraint layered on top of the global
|
|
454
|
-
* Case-insensitive, trimmed match on
|
|
455
|
-
*
|
|
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(
|
|
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.
|
|
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.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
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
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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 (
|
|
82
|
+
if (migrateProviderRolesToSlots(node?.policy)) changed = true;
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
return changed;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
/**
|
|
87
|
-
*
|
|
88
|
-
* in place
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
93
|
-
if (!policy || typeof policy !== 'object') return false;
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
}
|