@adhdev/daemon-core 0.9.82-rc.320 → 0.9.82-rc.321

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.
@@ -108,28 +108,6 @@ export declare function nodeSatisfiesRequiredTags(requiredTags: unknown, capabil
108
108
  export declare function resolveConvergeRequiredTags(meshId: string, taskMode: MeshTaskMode | undefined, explicitRequiredTags: string[], opts?: {
109
109
  targetNodeId?: string;
110
110
  }): string[];
111
- /**
112
- * Declarative task_mode → role affinity resolution (precedent: resolveConvergeRequiredTags).
113
- *
114
- * At enqueue time, auto-inject a `role=<role>` required tag derived from the task's
115
- * taskMode via the mesh's policy.taskAffinity (falling back to DEFAULT_TASKMODE_ROLE_MAP),
116
- * so the task hard-filters onto nodes advertising that role through the ordinary
117
- * required-tags path (nodeSatisfiesRequiredTags) — no claim/scheduler change needed.
118
- *
119
- * Strict precedence / backward compatibility — the injection is skipped (returns the
120
- * explicit tags unchanged) when ANY of:
121
- * - the caller pinned an explicit targetNodeId (operator chose the node), or
122
- * - the caller supplied their own non-empty required_tags (caller's routing wins), or
123
- * - affinity resolves to no role (policy disabled, blank override, or unknown mode), or
124
- * - SOFT fallback: no node in the mesh advertises the resolved role=<role> — injecting
125
- * would block the task with zero eligible nodes, so we skip it and let least_loaded
126
- * eligibility take over (a warning is logged so the misconfiguration is visible).
127
- * Idempotent: normalizeMeshCapabilityTags dedupes.
128
- */
129
- export declare function resolveTaskAffinityRequiredTags(meshId: string, taskMode: MeshTaskMode | undefined, explicitRequiredTags: string[], opts?: {
130
- targetNodeId?: string;
131
- callerSpecifiedRequiredTags?: boolean;
132
- }): string[];
133
111
  /**
134
112
  * M1: detect dependency cycles before enqueue. Walks the dependency graph of
135
113
  * existing queue entries plus the new task's edges. Fail-closed: a cycle
@@ -119,62 +119,6 @@ export declare function resolveNodeSchedulingPriority(nodePolicy: Pick<RepoMeshN
119
119
  */
120
120
  export declare const MESH_CONVERGE_REFINE_TAG = "converge=refine";
121
121
  export declare const MESH_CONVERGE_FAST_FORWARD_TAG = "converge=fast_forward";
122
- /**
123
- * Standard mesh resource-pool roles. These are the dashboard-provided defaults for
124
- * the role=<x> routing tag (advertised by node policy.providerRoles and matched
125
- * through nodeSatisfiesRequiredTags). A mesh is free to declare additional custom
126
- * role strings in policy.taskAffinity; the dashboard role dropdown exposes the union
127
- * of these four standards plus any config-declared roles. Roles are lowercased
128
- * everywhere (the role=<x> tag is lowercased in roleCapabilityTags).
129
- */
130
- export declare const STANDARD_MESH_ROLES: readonly ["investigator", "coder", "validator", "converger"];
131
- export type StandardMeshRole = typeof STANDARD_MESH_ROLES[number];
132
- /**
133
- * Default taskMode → role mapping (all five task modes). A task enqueued with a
134
- * given taskMode auto-routes to a node advertising the mapped role=<x> tag, unless
135
- * the mesh overrides the mapping in policy.taskAffinity.byTaskMode. Keyed by the
136
- * MeshTaskMode string literals (the canonical union lives in mesh-work-queue.ts;
137
- * this map is keyed by string to avoid a types ↔ queue import cycle). Kept in sync
138
- * with MeshTaskMode by the exhaustive lookup in resolveTaskAffinityRole.
139
- */
140
- export declare const DEFAULT_TASKMODE_ROLE_MAP: Readonly<Record<string, StandardMeshRole>>;
141
- /**
142
- * Declarative task_mode → role affinity policy. When set, enqueueTask auto-injects a
143
- * `role=<role>` required tag (see resolveTaskAffinityRequiredTags) so the task
144
- * hard-filters onto nodes advertising that role — but only when the caller did NOT
145
- * already pin the task with an explicit target_node_id or its own required_tags.
146
- *
147
- * - `enabled`: master switch. Defaults to true so the built-in DEFAULT_TASKMODE_ROLE_MAP
148
- * takes effect even when a mesh declares no taskAffinity block at all. Set false to
149
- * fully disable affinity routing (today's pre-affinity behavior).
150
- * - `byTaskMode`: per-taskMode override of the default mapping. A role string here may
151
- * be any of STANDARD_MESH_ROLES or a custom role declared by the operator. An empty
152
- * string disables affinity for that one task mode.
153
- * - `customRoles`: extra (non-standard) role strings to surface in the dashboard role
154
- * dropdown. Routing itself does not require a role to be pre-declared here — any role
155
- * referenced in byTaskMode is injected regardless; customRoles only widens the UI list.
156
- */
157
- export interface RepoMeshTaskAffinityPolicy {
158
- enabled?: boolean;
159
- byTaskMode?: Record<string, string>;
160
- customRoles?: string[];
161
- }
162
- /**
163
- * Resolve the affinity role for a task mode against a mesh's taskAffinity policy.
164
- * Returns the lowercased role string to inject as `role=<role>`, or null when no
165
- * affinity applies (affinity disabled, an explicit empty override, or an unknown mode).
166
- *
167
- * Precedence: policy.byTaskMode[mode] (operator override) → DEFAULT_TASKMODE_ROLE_MAP[mode].
168
- * A blank override string explicitly opts that mode out of affinity routing.
169
- */
170
- export declare function resolveTaskAffinityRole(taskMode: string | undefined, policy: RepoMeshTaskAffinityPolicy | null | undefined): string | null;
171
- /**
172
- * Union of the standard roles plus any operator-declared roles (byTaskMode values +
173
- * customRoles), lowercased and deduped, preserving standard-first ordering. Used by
174
- * the dashboard role dropdown so operators can pick a standard or a config-declared
175
- * custom role. Pure/UI-facing — does not gate routing.
176
- */
177
- export declare function resolveMeshRoleOptions(policy: RepoMeshTaskAffinityPolicy | null | undefined): string[];
178
122
  /**
179
123
  * Resolve whether the load-balancing scheduler should auto-inject a
180
124
  * `converge=refine` required tag onto code_change tasks so they hard-filter onto
@@ -224,18 +168,6 @@ export interface RepoMeshPolicy {
224
168
  * Defaults to false: code_change routing is unchanged unless opted in.
225
169
  */
226
170
  autoConvergeCodeChange?: boolean;
227
- /**
228
- * Declarative task_mode → role affinity routing. When present (and not disabled),
229
- * enqueueTask auto-injects a `role=<role>` required tag for the task's taskMode so
230
- * the work hard-filters onto nodes advertising that role. Explicit target_node_id
231
- * routing and any caller-supplied required_tags are preserved (auto-injection only
232
- * applies when the caller pinned neither). When the mapped role has no advertising
233
- * node in the mesh, the task is NOT blocked — the role tag is skipped so the work
234
- * falls back to ordinary least_loaded eligibility (soft affinity). Defaults to the
235
- * built-in DEFAULT_TASKMODE_ROLE_MAP even when this block is omitted; set
236
- * `{ enabled: false }` to fully restore pre-affinity routing.
237
- */
238
- taskAffinity?: RepoMeshTaskAffinityPolicy;
239
171
  /**
240
172
  * Whether sessions spawned by mesh/coordinator policy should auto-open as visible
241
173
  * dashboard tabs or start hidden. Defaults to 'visible' to preserve existing
@@ -278,21 +210,7 @@ export interface RepoMeshRelatedRepo {
278
210
  workspace: string;
279
211
  }
280
212
  /**
281
- * Per-(node, provider) role + parallelism declaration.
282
- *
283
- * `role` is a free-form resource-pool label (recommended values:
284
- * 'investigation' | 'coding' | 'orchestration') describing what this
285
- * (node, provider) combination is *for*. As of the load-balancing scheduler it
286
- * is ALSO routable: each declared role is advertised as a synthetic `role=<x>`
287
- * capability tag (see buildMeshNodeCapabilityTags), so a task enqueued with
288
- * requiredTags: ["role=validation"] is hard-filtered to nodes/providers that
289
- * declare that role — through the same nodeSatisfiesRequiredTags path as any
290
- * other tag. There is intentionally no separate "advertisedRoles" field: the
291
- * label and the routing tag are one mechanism. A task that does not require a
292
- * `role=` tag ignores roles entirely (opt-in, fully backward compatible).
293
- *
294
- * role is intentionally orthogonal to taskMode: taskMode classifies the *work*
295
- * (code_change vs live_debug_readonly), role classifies the *resource pool*.
213
+ * Per-(node, provider) parallelism declaration.
296
214
  *
297
215
  * `maxParallel` is the only enforced field: the queue will not assign a task
298
216
  * to this (node, provider) once it already has `maxParallel` active
@@ -300,12 +218,14 @@ export interface RepoMeshRelatedRepo {
300
218
  * provider) cap disagree, the stricter (lower effective) limit wins — a claim
301
219
  * must satisfy both. Omitting `maxParallel` means this provider is bounded only
302
220
  * by the global/taskMode caps (full backward compatibility).
221
+ *
222
+ * Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags);
223
+ * this entry carries no routing role. To route work to a specific node, advertise an
224
+ * ordinary capability tag on the node and require it on the task.
303
225
  */
304
226
  export interface RepoMeshProviderRole {
305
227
  /** Provider type this entry governs (e.g. 'claude-cli', 'codex-cli'). */
306
228
  providerType: string;
307
- /** Free-form role label; recommended: 'investigation' | 'coding' | 'orchestration'. */
308
- role?: string;
309
229
  /** Max concurrent active tasks for this (node, provider). Omit = no per-provider cap. */
310
230
  maxParallel?: number;
311
231
  }
@@ -324,13 +244,11 @@ export interface RepoMeshNodePolicy {
324
244
  /** Ordered provider preference used when mesh_launch_session omits an explicit type. */
325
245
  providerPriority?: string[];
326
246
  /**
327
- * Per-(node, provider) role + parallelism declarations. Each entry binds a
328
- * providerType on THIS node to an optional free-form role label and an
329
- * optional maxParallel cap. maxParallel is enforced (as an additional,
330
- * stricter-wins constraint on top of the global maxParallelTasks/taskMode
331
- * caps); role is advertised as a routable `role=<x>` capability tag so tasks
332
- * can hard-filter by required role. Missing/empty: the node behaves exactly
333
- * as before (global caps only, no role tags).
247
+ * Per-(node, provider) parallelism declarations. Each entry binds a
248
+ * providerType on THIS node to an optional maxParallel cap. maxParallel is
249
+ * enforced as an additional, stricter-wins constraint on top of the global
250
+ * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
251
+ * before (global caps only). Routing is governed solely by required_tags.
334
252
  */
335
253
  providerRoles?: RepoMeshProviderRole[];
336
254
  /**
@@ -369,18 +287,12 @@ export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
369
287
  * launch path merges the envelope as a settingsOverride on top of the provider defaults.
370
288
  */
371
289
  export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null): boolean;
372
- /**
373
- * Resolve the per-(node, provider) role declaration from a node policy.
374
- * Case-insensitive, trimmed match on providerType. Returns undefined when the
375
- * node has no providerRoles entry for this provider (caller then applies only
376
- * the global caps). Defensive against malformed config — non-object entries and
377
- * blank providerTypes are skipped rather than throwing.
378
- */
379
- export declare function resolveProviderRole(nodePolicy: Pick<RepoMeshNodePolicy, 'providerRoles'> | null | undefined, providerType: string | null | undefined): RepoMeshProviderRole | undefined;
380
290
  /**
381
291
  * Resolve the enforced per-(node, provider) maxParallel cap, or undefined when
382
292
  * no finite, non-negative cap is declared for this provider. Used by the queue
383
293
  * claim path as a stricter-wins constraint layered on top of the global caps.
294
+ * Case-insensitive, trimmed match on providerType. Defensive against malformed
295
+ * config — non-object entries and blank providerTypes are skipped rather than throwing.
384
296
  */
385
297
  export declare function resolveProviderMaxParallel(nodePolicy: Pick<RepoMeshNodePolicy, 'providerRoles'> | null | undefined, providerType: string | null | undefined): number | undefined;
386
298
  export interface RepoMeshNodeCapabilities {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.320",
3
+ "version": "0.9.82-rc.321",
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.320",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.321",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -128,22 +128,21 @@ function readProviderPriorityFromPolicy(policy: unknown): string[] {
128
128
 
129
129
  /**
130
130
  * Normalize a providerRoles array (RepoMeshNodePolicy.providerRoles) from raw
131
- * tool args. Each entry binds a providerType to an optional free-form `role`
132
- * label and an optional `maxParallel` cap. Entries without a usable providerType
133
- * are dropped; the last entry wins on duplicate providerType. Returns [] when no
134
- * valid entries — callers then omit the field entirely (full backward compat).
131
+ * tool args. Each entry binds a providerType to an optional `maxParallel` cap.
132
+ * Entries without a usable providerType are dropped; the last entry wins on
133
+ * duplicate providerType. Returns [] when no valid entries — callers then omit
134
+ * the field entirely (full backward compat). Routing is governed by required_tags;
135
+ * any legacy `role` field on the input is ignored.
135
136
  */
136
- function normalizeProviderRoles(value: unknown): Array<{ providerType: string; role?: string; maxParallel?: number }> {
137
+ function normalizeProviderRoles(value: unknown): Array<{ providerType: string; maxParallel?: number }> {
137
138
  if (!Array.isArray(value)) return [];
138
- const byType = new Map<string, { providerType: string; role?: string; maxParallel?: number }>();
139
+ const byType = new Map<string, { providerType: string; maxParallel?: number }>();
139
140
  for (const raw of value) {
140
141
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
141
142
  const rec = raw as Record<string, unknown>;
142
143
  const providerType = typeof rec.providerType === 'string' ? rec.providerType.trim() : '';
143
144
  if (!providerType) continue;
144
- const entry: { providerType: string; role?: string; maxParallel?: number } = { providerType };
145
- const role = typeof rec.role === 'string' ? rec.role.trim() : '';
146
- if (role) entry.role = role;
145
+ const entry: { providerType: string; maxParallel?: number } = { providerType };
147
146
  const maxParallel = Number(rec.maxParallel);
148
147
  if (Number.isFinite(maxParallel) && maxParallel >= 0) entry.maxParallel = Math.floor(maxParallel);
149
148
  byType.set(providerType.toLowerCase(), entry);
@@ -20,9 +20,8 @@ import type {
20
20
  RepoMeshCoordinatorConfig,
21
21
  RepoMeshHostMetadata,
22
22
  RepoMeshDaemonRole,
23
- RepoMeshTaskAffinityPolicy,
24
23
  } from '../repo-mesh-types.js';
25
- import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy, STANDARD_MESH_ROLES } from '../repo-mesh-types.js';
24
+ import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
26
25
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
27
26
 
28
27
  // ─── Persistence ────────────────────────────────
@@ -135,79 +134,9 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
135
134
  } else {
136
135
  delete policy.autoConvergeCodeChange;
137
136
  }
138
- // Task-mode → role affinity: format-only validation (drop blanks). Unknown/custom
139
- // roles are NOT blocked — only a warning is logged. Only persist when the block
140
- // carries meaningful content so existing meshes.json stays untouched.
141
- const normalizedAffinity = normalizeTaskAffinityPolicy(policy.taskAffinity);
142
- if (normalizedAffinity) {
143
- policy.taskAffinity = normalizedAffinity;
144
- } else {
145
- delete policy.taskAffinity;
146
- }
147
137
  return policy;
148
138
  }
149
139
 
150
- /**
151
- * Format-only normalization of a task-affinity policy. Trims/lowercases role strings,
152
- * drops blank custom roles and blank byTaskMode keys, and warns (never blocks) when a
153
- * declared role is not one of the standard four. Returns undefined when the result is
154
- * empty/no-op so the field stays absent from persisted config.
155
- */
156
- function normalizeTaskAffinityPolicy(
157
- value: unknown,
158
- ): RepoMeshTaskAffinityPolicy | undefined {
159
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
160
- const record = value as Record<string, unknown>;
161
- const out: RepoMeshTaskAffinityPolicy = {};
162
- let hasContent = false;
163
-
164
- if (typeof record.enabled === 'boolean') {
165
- out.enabled = record.enabled;
166
- // `enabled:true` is the default — only persist the explicit `false`.
167
- if (record.enabled === false) hasContent = true;
168
- else delete out.enabled;
169
- }
170
-
171
- if (record.byTaskMode && typeof record.byTaskMode === 'object' && !Array.isArray(record.byTaskMode)) {
172
- const byTaskMode: Record<string, string> = {};
173
- for (const [mode, raw] of Object.entries(record.byTaskMode as Record<string, unknown>)) {
174
- const modeKey = mode.trim();
175
- if (!modeKey) continue;
176
- if (typeof raw !== 'string') continue;
177
- // A blank value is a deliberate opt-out for that mode — preserve it as ''.
178
- const role = raw.trim().toLowerCase();
179
- byTaskMode[modeKey] = role;
180
- if (role && !STANDARD_MESH_ROLES.includes(role as any)) {
181
- try {
182
- console.warn(`[mesh] task_affinity.byTaskMode.${modeKey}: custom role "${role}" is not a standard mesh role (${STANDARD_MESH_ROLES.join('/')}); routing still applies`);
183
- } catch { /* best-effort */ }
184
- }
185
- }
186
- if (Object.keys(byTaskMode).length > 0) {
187
- out.byTaskMode = byTaskMode;
188
- hasContent = true;
189
- }
190
- }
191
-
192
- if (Array.isArray(record.customRoles)) {
193
- const seen = new Set<string>();
194
- const customRoles: string[] = [];
195
- for (const raw of record.customRoles) {
196
- if (typeof raw !== 'string') continue;
197
- const role = raw.trim().toLowerCase();
198
- if (!role || seen.has(role)) continue;
199
- seen.add(role);
200
- customRoles.push(role);
201
- }
202
- if (customRoles.length > 0) {
203
- out.customRoles = customRoles;
204
- hasContent = true;
205
- }
206
- }
207
-
208
- return hasContent ? out : undefined;
209
- }
210
-
211
140
  function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
212
141
  const record = value && typeof value === 'object' && !Array.isArray(value)
213
142
  ? value as Record<string, unknown>
package/src/index.ts CHANGED
@@ -135,8 +135,6 @@ export type {
135
135
  RepoMeshLedgerStatus,
136
136
  MeshAsyncJobLifecycle,
137
137
  RepoMeshSchedulingStrategy,
138
- RepoMeshTaskAffinityPolicy,
139
- StandardMeshRole,
140
138
  } from './repo-mesh-types.js';
141
139
  export {
142
140
  DEFAULT_MESH_POLICY,
@@ -148,10 +146,6 @@ export {
148
146
  MESH_CONVERGE_REFINE_TAG,
149
147
  MESH_CONVERGE_FAST_FORWARD_TAG,
150
148
  resolveAutoConvergeCodeChange,
151
- STANDARD_MESH_ROLES,
152
- DEFAULT_TASKMODE_ROLE_MAP,
153
- resolveTaskAffinityRole,
154
- resolveMeshRoleOptions,
155
149
  } from './repo-mesh-types.js';
156
150
 
157
151
  // ── Git Surface ──
@@ -237,7 +231,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
237
231
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
238
232
 
239
233
  // ── Mesh Work Queue (GUPP) ──
240
- export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, resolveTaskAffinityRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
234
+ export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
241
235
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
242
236
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
243
237
  export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
@@ -269,20 +269,19 @@ function buildNodeConfigSection(mesh: LocalMeshEntry): string {
269
269
  const explicitMachineLabel = typeof (n as any).machineLabel === 'string' ? (n as any).machineLabel : '';
270
270
  const explicitLabel = explicitMachineLabel ? ` label: **${explicitMachineLabel}** |` : '';
271
271
  const providerPriority = n.policy?.providerPriority?.length ? ` | providers: ${n.policy.providerPriority.join(', ')}` : '';
272
- // Per-(node, provider) role label + maxParallel cap. role is a hint surfaced
273
- // here for delegation context; only maxParallel is enforced by the queue.
272
+ // Per-(node, provider) maxParallel cap. Only maxParallel is enforced by the
273
+ // queue; routing is governed by required_tags, not provider roles.
274
274
  const providerRoles = Array.isArray(n.policy?.providerRoles)
275
- ? (n.policy!.providerRoles as Array<{ providerType?: unknown; role?: unknown; maxParallel?: unknown }>)
275
+ ? (n.policy!.providerRoles as Array<{ providerType?: unknown; maxParallel?: unknown }>)
276
276
  .map(r => {
277
277
  const type = typeof r?.providerType === 'string' ? r.providerType.trim() : '';
278
278
  if (!type) return '';
279
- const role = typeof r?.role === 'string' && r.role.trim() ? `=${r.role.trim()}` : '';
280
279
  const cap = Number.isFinite(Number(r?.maxParallel)) ? ` (max ${Math.floor(Number(r.maxParallel))})` : '';
281
- return `${type}${role}${cap}`;
280
+ return `${type}${cap}`;
282
281
  })
283
282
  .filter(Boolean)
284
283
  : [];
285
- const providerRolesSuffix = providerRoles.length ? ` | roles: ${providerRoles.join(', ')}` : '';
284
+ const providerRolesSuffix = providerRoles.length ? ` | caps: ${providerRoles.join(', ')}` : '';
286
285
  lines.push(`- ${explicitLabel} nodeId: \`${n.id}\` | workspace: \`${n.workspace}\`${n.daemonId ? ` | daemon: \`${n.daemonId}\`` : ''}${providerPriority}${providerRolesSuffix}${suffix}`);
287
286
  const nodePrompt = typeof (n as any).systemPrompt === 'string' ? (n as any).systemPrompt.trim() : '';
288
287
  if (nodePrompt) {
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
- import type { RepoMeshDaemonRole, LocalMeshNodeEntry } from '../repo-mesh-types.js';
4
- import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange, resolveTaskAffinityRole } from '../repo-mesh-types.js';
3
+ import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
+ import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
5
5
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
6
6
  import { getMesh } from '../config/mesh-config.js';
7
7
 
@@ -204,45 +204,6 @@ function firstProviderPriority(policy: unknown): string | undefined {
204
204
  return raw.find(type => typeof type === 'string' && type.trim())?.trim();
205
205
  }
206
206
 
207
- /**
208
- * Synthetic `role=<x>` capability tags advertised by a node's policy.providerRoles.
209
- *
210
- * When a specific `providerType` is being evaluated, only that provider's declared
211
- * role is emitted — so role-based routing (requiredTags: ["role=validation"]) gates
212
- * the *selected* provider through the ordinary capability-tag filter. When no
213
- * provider is selected (node-level eligibility scan), every declared role is emitted
214
- * so the node passes the filter if ANY of its providers could satisfy the role; the
215
- * per-provider tag set then narrows it during provider selection.
216
- *
217
- * Roles are lowercased and deduped. Missing/empty providerRoles emits nothing, so a
218
- * node that never declares roles advertises no `role=` tags and is therefore only
219
- * matched by role-unconstrained tasks (full backward compatibility).
220
- */
221
- function roleCapabilityTags(policy: unknown, providerType: string | undefined): string[] {
222
- const roles = policy && typeof policy === 'object' && !Array.isArray(policy)
223
- ? (policy as Record<string, unknown>).providerRoles
224
- : undefined;
225
- if (!Array.isArray(roles)) return [];
226
- const wantedProvider = typeof providerType === 'string' && providerType.trim()
227
- ? providerType.trim().toLowerCase()
228
- : '';
229
- const out: string[] = [];
230
- for (const entry of roles) {
231
- if (!entry || typeof entry !== 'object') continue;
232
- const type = typeof (entry as any).providerType === 'string'
233
- ? (entry as any).providerType.trim().toLowerCase()
234
- : '';
235
- const role = typeof (entry as any).role === 'string'
236
- ? (entry as any).role.trim().toLowerCase()
237
- : '';
238
- if (!role) continue;
239
- // When narrowing to a selected provider, only emit that provider's role.
240
- if (wantedProvider && type && type !== wantedProvider) continue;
241
- out.push(`role=${role}`);
242
- }
243
- return out;
244
- }
245
-
246
207
  export function buildMeshNodeCapabilityTags(
247
208
  node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown } | undefined,
248
209
  providerType?: string,
@@ -273,13 +234,6 @@ export function buildMeshNodeCapabilityTags(
273
234
  // the load-balancing scheduler auto-injects converge=refine for code_change
274
235
  // so such work is hard-filtered onto refine-capable nodes.
275
236
  ...(node?.isLocalWorktree === true ? ['converge=refine'] : ['converge=fast_forward']),
276
- // Role-based routing: advertise role=<x> for each (node, provider) role
277
- // declared in policy.providerRoles. Narrowed to the selected provider when
278
- // one is given so the chosen provider must match a task's required role;
279
- // when no provider is selected, all declared roles are advertised for the
280
- // node-level eligibility scan. Reuses the ordinary required-tags filter —
281
- // no separate role field/gate.
282
- ...roleCapabilityTags(node?.policy, providerType),
283
237
  ]);
284
238
  }
285
239
 
@@ -328,67 +282,6 @@ export function resolveConvergeRequiredTags(
328
282
  return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
329
283
  }
330
284
 
331
- /**
332
- * Whether any node in the mesh advertises the given `role=<role>` capability tag.
333
- * Reuses buildMeshNodeCapabilityTags (node-level scan: every declared providerRole is
334
- * advertised) so the membership test matches the exact tag the claim/eligibility filter
335
- * would see. Used to keep task-affinity SOFT — if no node can satisfy the role, the
336
- * injection is skipped so the task falls back to ordinary least_loaded eligibility
337
- * rather than being blocked.
338
- */
339
- function meshHasNodeAdvertisingRole(nodes: LocalMeshNodeEntry[] | undefined, role: string): boolean {
340
- if (!Array.isArray(nodes) || nodes.length === 0) return false;
341
- const wanted = `role=${role}`;
342
- return nodes.some(node => buildMeshNodeCapabilityTags(node as any).includes(wanted));
343
- }
344
-
345
- /**
346
- * Declarative task_mode → role affinity resolution (precedent: resolveConvergeRequiredTags).
347
- *
348
- * At enqueue time, auto-inject a `role=<role>` required tag derived from the task's
349
- * taskMode via the mesh's policy.taskAffinity (falling back to DEFAULT_TASKMODE_ROLE_MAP),
350
- * so the task hard-filters onto nodes advertising that role through the ordinary
351
- * required-tags path (nodeSatisfiesRequiredTags) — no claim/scheduler change needed.
352
- *
353
- * Strict precedence / backward compatibility — the injection is skipped (returns the
354
- * explicit tags unchanged) when ANY of:
355
- * - the caller pinned an explicit targetNodeId (operator chose the node), or
356
- * - the caller supplied their own non-empty required_tags (caller's routing wins), or
357
- * - affinity resolves to no role (policy disabled, blank override, or unknown mode), or
358
- * - SOFT fallback: no node in the mesh advertises the resolved role=<role> — injecting
359
- * would block the task with zero eligible nodes, so we skip it and let least_loaded
360
- * eligibility take over (a warning is logged so the misconfiguration is visible).
361
- * Idempotent: normalizeMeshCapabilityTags dedupes.
362
- */
363
- export function resolveTaskAffinityRequiredTags(
364
- meshId: string,
365
- taskMode: MeshTaskMode | undefined,
366
- explicitRequiredTags: string[],
367
- opts?: { targetNodeId?: string; callerSpecifiedRequiredTags?: boolean },
368
- ): string[] {
369
- // Caller pinned the node, or brought their own required_tags → respect it verbatim.
370
- if (typeof opts?.targetNodeId === 'string' && opts.targetNodeId.trim()) return explicitRequiredTags;
371
- if (opts?.callerSpecifiedRequiredTags === true) return explicitRequiredTags;
372
-
373
- let mesh;
374
- try {
375
- mesh = getMesh(meshId);
376
- } catch {
377
- return explicitRequiredTags;
378
- }
379
- const role = resolveTaskAffinityRole(taskMode, mesh?.policy?.taskAffinity);
380
- if (!role) return explicitRequiredTags;
381
-
382
- // SOFT affinity: only hard-filter when the role actually exists in the mesh.
383
- if (!meshHasNodeAdvertisingRole(mesh?.nodes, role)) {
384
- try {
385
- console.warn(`[mesh] task_affinity: no node advertises role=${role} for taskMode=${taskMode} in mesh ${meshId}; skipping injection (least_loaded fallback)`);
386
- } catch { /* logging is best-effort */ }
387
- return explicitRequiredTags;
388
- }
389
- return normalizeMeshCapabilityTags([...explicitRequiredTags, `role=${role}`]);
390
- }
391
-
392
285
  function withQueueLock<T>(_meshId: string, fn: () => T): T {
393
286
  return MeshRuntimeStore.getInstance().transaction(fn);
394
287
  }
@@ -474,31 +367,18 @@ export function enqueueTask(
474
367
  throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
475
368
  }
476
369
  assertNoDependencyCycle(meshId, id, dependsOn);
477
- // Did the caller bring their own routing tags? Affinity auto-injection is
478
- // suppressed when so (caller's required_tags win), matching the spec — measured
479
- // against the ORIGINAL caller tags, before convergence augmentation below.
480
370
  const callerTags = normalizeMeshCapabilityTags(opts?.requiredTags);
481
- const callerSpecifiedRequiredTags = callerTags.length > 0;
482
371
  // Convergence routing (opt-in): auto-inject converge=refine for code_change
483
372
  // tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
484
373
  // the mesh opts in; explicit target_node_id / required_tags are preserved.
485
- let resolvedRequiredTags = resolveConvergeRequiredTags(
374
+ // Routing is otherwise governed solely by the caller's required_tags (hard
375
+ // filter through nodeSatisfiesRequiredTags) — no role/taskMode auto-routing.
376
+ const resolvedRequiredTags = resolveConvergeRequiredTags(
486
377
  meshId,
487
378
  modeValidation.taskMode,
488
379
  callerTags,
489
380
  { targetNodeId: opts?.targetNodeId },
490
381
  );
491
- // Declarative task_mode → role affinity (precedent: convergence routing above):
492
- // inject role=<role> for the task's taskMode unless the caller pinned a node or
493
- // supplied their own required_tags. SOFT — skipped when no node advertises the
494
- // role (least_loaded fallback). Built-in default mapping applies even without a
495
- // taskAffinity config block; { enabled:false } disables it.
496
- resolvedRequiredTags = resolveTaskAffinityRequiredTags(
497
- meshId,
498
- modeValidation.taskMode,
499
- resolvedRequiredTags,
500
- { targetNodeId: opts?.targetNodeId, callerSpecifiedRequiredTags },
501
- );
502
382
  const entry: MeshWorkQueueEntry = {
503
383
  id,
504
384
  meshId,