@adhdev/daemon-core 0.9.82-rc.315 → 0.9.82-rc.316

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,6 +108,28 @@ 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[];
111
133
  /**
112
134
  * M1: detect dependency cycles before enqueue. Walks the dependency graph of
113
135
  * existing queue entries plus the new task's edges. Fail-closed: a cycle
@@ -119,6 +119,62 @@ 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[];
122
178
  /**
123
179
  * Resolve whether the load-balancing scheduler should auto-inject a
124
180
  * `converge=refine` required tag onto code_change tasks so they hard-filter onto
@@ -168,6 +224,18 @@ export interface RepoMeshPolicy {
168
224
  * Defaults to false: code_change routing is unchanged unless opted in.
169
225
  */
170
226
  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;
171
239
  /**
172
240
  * Whether sessions spawned by mesh/coordinator policy should auto-open as visible
173
241
  * dashboard tabs or start hidden. Defaults to 'visible' to preserve existing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.315",
3
+ "version": "0.9.82-rc.316",
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.315",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.316",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -38,6 +38,29 @@ export interface AcpAdapterHandle {
38
38
  resolvePermission?(approved: boolean): Promise<void>;
39
39
  }
40
40
 
41
+ /**
42
+ * Launch metadata for a CLI session, surfaced by the dashboard Session info panel.
43
+ * Derived from the live adapter's spawn plan — the resolved binary, the full
44
+ * argument vector (provider base args + per-launch extra args), the cwd, and the
45
+ * set of per-launch extra-env KEYS (values are intentionally omitted so secrets in
46
+ * extraEnv are never sent to the dashboard). providerSessionId is the upstream
47
+ * agent's own session id once the CLI reports it.
48
+ */
49
+ export interface CliLaunchInfo {
50
+ /** Resolved executable path the PTY actually spawns. */
51
+ command?: string;
52
+ /** Full argument vector (provider spawn.args + extraArgs, {{workingDir}} expanded). */
53
+ args: string[];
54
+ /** Per-launch extra args only (subset of args), for attribution. */
55
+ extraArgs: string[];
56
+ /** Working directory the session was spawned in. */
57
+ cwd: string;
58
+ /** KEYS of per-launch extra env (values omitted — may contain secrets). */
59
+ extraEnvKeys: string[];
60
+ /** Upstream agent session id, once the CLI reports one. */
61
+ providerSessionId?: string;
62
+ }
63
+
41
64
  export interface CliAdapter {
42
65
  cliType: string;
43
66
  cliName: string;
@@ -70,6 +93,8 @@ export interface CliAdapter {
70
93
  setOnPtyData?(callback: (data: string) => void): void;
71
94
  writeRaw?(data: string): void;
72
95
  resize?(cols: number, rows: number): void;
96
+ // ── Launch metadata for the dashboard Session info panel (args/cwd/env keys) ──
97
+ getLaunchInfo?(): CliLaunchInfo;
73
98
  // ── Runtime metadata used by CliProviderInstance for session tracking ──
74
99
  getRuntimeMetadata?(): unknown;
75
100
  updateRuntimeMeta?(meta: Record<string, unknown>): void;
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import * as os from 'os';
18
- import type { CliAdapter } from '../cli-adapter-types.js';
18
+ import type { CliAdapter, CliLaunchInfo } from '../cli-adapter-types.js';
19
19
  import type { InteractivePromptResponse } from '../providers/types/interactive-prompt.js';
20
20
  import { LOG } from '../logging/logger.js';
21
21
  import { getDebugRuntimeConfig } from '../logging/debug-config.js';
@@ -1595,6 +1595,41 @@ export class ProviderCliAdapter implements CliAdapter {
1595
1595
  return this.ptyProcess.getMetadata();
1596
1596
  }
1597
1597
 
1598
+ /**
1599
+ * Launch metadata for the dashboard Session info panel. Re-derives the spawn plan
1600
+ * (pure — same inputs the live PTY was spawned with) so the dashboard sees the
1601
+ * resolved binary, full arg vector, and cwd without us having to persist it.
1602
+ * extraEnv values are intentionally dropped (keys only) so secrets passed at
1603
+ * launch time never reach the dashboard.
1604
+ */
1605
+ getLaunchInfo(): CliLaunchInfo {
1606
+ let command: string | undefined;
1607
+ let args: string[] = [...this.extraArgs];
1608
+ try {
1609
+ const plan = resolveCliSpawnPlan({
1610
+ provider: this.provider,
1611
+ runtimeSettings: this.runtimeSettings,
1612
+ workingDir: this.workingDir,
1613
+ extraArgs: this.extraArgs,
1614
+ extraEnv: this.extraEnv,
1615
+ });
1616
+ command = plan.binaryPath;
1617
+ args = plan.allArgs;
1618
+ } catch {
1619
+ // Spawn plan resolution can throw before the binary is resolvable
1620
+ // (e.g. CLI not installed). Fall back to the raw extra args so the
1621
+ // panel still shows what it can rather than failing the whole call.
1622
+ }
1623
+ return {
1624
+ command,
1625
+ args,
1626
+ extraArgs: [...this.extraArgs],
1627
+ cwd: this.workingDir,
1628
+ extraEnvKeys: Object.keys(this.extraEnv || {}),
1629
+ providerSessionId: this.providerSessionId || undefined,
1630
+ };
1631
+ }
1632
+
1598
1633
  updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
1599
1634
  const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
1600
1635
  ? meta.providerSessionId.trim()
@@ -534,9 +534,26 @@ export function findBinary(name: string): string {
534
534
  }
535
535
  const isWin = os.platform() === 'win32';
536
536
  const paths = (process.env.PATH || '').split(path.delimiter);
537
+ // Also search well-known global-bin directories that are frequently NOT on
538
+ // the daemon's inherited PATH. A daemon running under one Node install (e.g.
539
+ // nvm) never sees another npm prefix's bin dir — notably npm's Windows
540
+ // default prefix at %APPDATA%\npm, where `npm i -g @openai/codex` lands. We
541
+ // append these after PATH (so explicit PATH entries still win) so that an
542
+ // npm-global CLI resolves to an absolute path; the spawn layer's .cmd-shim
543
+ // handling then launches it correctly regardless of PATH.
544
+ const extraDirs: string[] = [];
545
+ if (isWin) {
546
+ if (process.env.APPDATA) extraDirs.push(path.join(process.env.APPDATA, 'npm'));
547
+ try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
548
+ } else {
549
+ extraDirs.push(path.join(os.homedir(), '.npm-global', 'bin'));
550
+ extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
551
+ try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
552
+ }
553
+ const searchDirs = [...paths, ...extraDirs];
537
554
  const exes = isWin ? ['.exe', '.cmd', '.bat', ''] : [''];
538
-
539
- for (const p of paths) {
555
+
556
+ for (const p of searchDirs) {
540
557
  if (!p) continue;
541
558
  for (const ext of exes) {
542
559
  const fullPath = path.join(p, trimmed + ext);
@@ -6345,6 +6345,12 @@ export class DaemonCommandRouter {
6345
6345
  const runtimeMeta = (adapter && typeof (adapter as any).getRuntimeMetadata === 'function')
6346
6346
  ? (adapter as any).getRuntimeMetadata()
6347
6347
  : undefined;
6348
+ // Launch metadata (args / cwd / extra-env keys / providerSessionId) is
6349
+ // derived from the live adapter's spawn plan; only available while the
6350
+ // adapter is alive (resumed-from-history sessions report nothing here).
6351
+ const launchInfo = (adapter && typeof (adapter as any).getLaunchInfo === 'function')
6352
+ ? (adapter as any).getLaunchInfo()
6353
+ : undefined;
6348
6354
  const providerType = target?.providerType || coord?.cliType || '';
6349
6355
  const providerMetaForSession = providerType
6350
6356
  ? this.deps.providerLoader.resolve?.(providerType) || this.deps.providerLoader.getMeta(providerType)
@@ -6358,8 +6364,11 @@ export class DaemonCommandRouter {
6358
6364
  transport: target?.transport,
6359
6365
  workspace: (target as any)?.workspace || coord?.workspace,
6360
6366
  spawnedAtMs: (target as any)?.spawnedAtMs || coord?.startedAt,
6361
- providerSessionId: (target as any)?.providerSessionId,
6367
+ // providerSessionId now comes from the live adapter's launch info
6368
+ // (the registry target never carried it — it was always undefined).
6369
+ providerSessionId: launchInfo?.providerSessionId || (target as any)?.providerSessionId,
6362
6370
  runtimeMetadata: runtimeMeta,
6371
+ launch: launchInfo,
6363
6372
  },
6364
6373
  coordinator: coord ? {
6365
6374
  meshId: coord.meshId,
@@ -20,8 +20,9 @@ import type {
20
20
  RepoMeshCoordinatorConfig,
21
21
  RepoMeshHostMetadata,
22
22
  RepoMeshDaemonRole,
23
+ RepoMeshTaskAffinityPolicy,
23
24
  } from '../repo-mesh-types.js';
24
- import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
25
+ import { DEFAULT_MESH_POLICY, normalizeMeshSchedulingStrategy, STANDARD_MESH_ROLES } from '../repo-mesh-types.js';
25
26
  import { createDefaultMeshHostMetadata } from '../mesh/mesh-host-ownership.js';
26
27
 
27
28
  // ─── Persistence ────────────────────────────────
@@ -134,9 +135,79 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
134
135
  } else {
135
136
  delete policy.autoConvergeCodeChange;
136
137
  }
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
+ }
137
147
  return policy;
138
148
  }
139
149
 
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
+
140
211
  function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
141
212
  const record = value && typeof value === 'object' && !Array.isArray(value)
142
213
  ? value as Record<string, unknown>
package/src/index.ts CHANGED
@@ -135,6 +135,8 @@ export type {
135
135
  RepoMeshLedgerStatus,
136
136
  MeshAsyncJobLifecycle,
137
137
  RepoMeshSchedulingStrategy,
138
+ RepoMeshTaskAffinityPolicy,
139
+ StandardMeshRole,
138
140
  } from './repo-mesh-types.js';
139
141
  export {
140
142
  DEFAULT_MESH_POLICY,
@@ -146,6 +148,10 @@ export {
146
148
  MESH_CONVERGE_REFINE_TAG,
147
149
  MESH_CONVERGE_FAST_FORWARD_TAG,
148
150
  resolveAutoConvergeCodeChange,
151
+ STANDARD_MESH_ROLES,
152
+ DEFAULT_TASKMODE_ROLE_MAP,
153
+ resolveTaskAffinityRole,
154
+ resolveMeshRoleOptions,
149
155
  } from './repo-mesh-types.js';
150
156
 
151
157
  // ── Git Surface ──
@@ -231,7 +237,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
231
237
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
232
238
 
233
239
  // ── Mesh Work Queue (GUPP) ──
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';
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';
235
241
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
236
242
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
237
243
  export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
- import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
- import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
3
+ import type { RepoMeshDaemonRole, LocalMeshNodeEntry } from '../repo-mesh-types.js';
4
+ import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange, resolveTaskAffinityRole } 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
 
@@ -328,6 +328,67 @@ export function resolveConvergeRequiredTags(
328
328
  return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
329
329
  }
330
330
 
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
+
331
392
  function withQueueLock<T>(_meshId: string, fn: () => T): T {
332
393
  return MeshRuntimeStore.getInstance().transaction(fn);
333
394
  }
@@ -413,6 +474,31 @@ export function enqueueTask(
413
474
  throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
414
475
  }
415
476
  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
+ const callerTags = normalizeMeshCapabilityTags(opts?.requiredTags);
481
+ const callerSpecifiedRequiredTags = callerTags.length > 0;
482
+ // Convergence routing (opt-in): auto-inject converge=refine for code_change
483
+ // tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
484
+ // the mesh opts in; explicit target_node_id / required_tags are preserved.
485
+ let resolvedRequiredTags = resolveConvergeRequiredTags(
486
+ meshId,
487
+ modeValidation.taskMode,
488
+ callerTags,
489
+ { targetNodeId: opts?.targetNodeId },
490
+ );
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
+ );
416
502
  const entry: MeshWorkQueueEntry = {
417
503
  id,
418
504
  meshId,
@@ -421,15 +507,7 @@ export function enqueueTask(
421
507
  taskMode: modeValidation.taskMode,
422
508
  targetNodeId: opts?.targetNodeId,
423
509
  targetSessionId: opts?.targetSessionId,
424
- // Convergence routing (opt-in): auto-inject converge=refine for code_change
425
- // tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
426
- // the mesh opts in; explicit target_node_id / required_tags are preserved.
427
- requiredTags: resolveConvergeRequiredTags(
428
- meshId,
429
- modeValidation.taskMode,
430
- normalizeMeshCapabilityTags(opts?.requiredTags),
431
- { targetNodeId: opts?.targetNodeId },
432
- ),
510
+ requiredTags: resolvedRequiredTags,
433
511
  ...(dependsOn.length > 0 ? { dependsOn } : {}),
434
512
  ...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
435
513
  createdAt: new Date().toISOString(),
@@ -165,6 +165,106 @@ export function resolveNodeSchedulingPriority(
165
165
  export const MESH_CONVERGE_REFINE_TAG = 'converge=refine';
166
166
  export const MESH_CONVERGE_FAST_FORWARD_TAG = 'converge=fast_forward';
167
167
 
168
+ /**
169
+ * Standard mesh resource-pool roles. These are the dashboard-provided defaults for
170
+ * the role=<x> routing tag (advertised by node policy.providerRoles and matched
171
+ * through nodeSatisfiesRequiredTags). A mesh is free to declare additional custom
172
+ * role strings in policy.taskAffinity; the dashboard role dropdown exposes the union
173
+ * of these four standards plus any config-declared roles. Roles are lowercased
174
+ * everywhere (the role=<x> tag is lowercased in roleCapabilityTags).
175
+ */
176
+ export const STANDARD_MESH_ROLES = ['investigator', 'coder', 'validator', 'converger'] as const;
177
+ export type StandardMeshRole = typeof STANDARD_MESH_ROLES[number];
178
+
179
+ /**
180
+ * Default taskMode → role mapping (all five task modes). A task enqueued with a
181
+ * given taskMode auto-routes to a node advertising the mapped role=<x> tag, unless
182
+ * the mesh overrides the mapping in policy.taskAffinity.byTaskMode. Keyed by the
183
+ * MeshTaskMode string literals (the canonical union lives in mesh-work-queue.ts;
184
+ * this map is keyed by string to avoid a types ↔ queue import cycle). Kept in sync
185
+ * with MeshTaskMode by the exhaustive lookup in resolveTaskAffinityRole.
186
+ */
187
+ export const DEFAULT_TASKMODE_ROLE_MAP: Readonly<Record<string, StandardMeshRole>> = {
188
+ live_debug_readonly: 'investigator',
189
+ code_change: 'coder',
190
+ launch_app: 'coder',
191
+ validation: 'validator',
192
+ convergence: 'converger',
193
+ };
194
+
195
+ /**
196
+ * Declarative task_mode → role affinity policy. When set, enqueueTask auto-injects a
197
+ * `role=<role>` required tag (see resolveTaskAffinityRequiredTags) so the task
198
+ * hard-filters onto nodes advertising that role — but only when the caller did NOT
199
+ * already pin the task with an explicit target_node_id or its own required_tags.
200
+ *
201
+ * - `enabled`: master switch. Defaults to true so the built-in DEFAULT_TASKMODE_ROLE_MAP
202
+ * takes effect even when a mesh declares no taskAffinity block at all. Set false to
203
+ * fully disable affinity routing (today's pre-affinity behavior).
204
+ * - `byTaskMode`: per-taskMode override of the default mapping. A role string here may
205
+ * be any of STANDARD_MESH_ROLES or a custom role declared by the operator. An empty
206
+ * string disables affinity for that one task mode.
207
+ * - `customRoles`: extra (non-standard) role strings to surface in the dashboard role
208
+ * dropdown. Routing itself does not require a role to be pre-declared here — any role
209
+ * referenced in byTaskMode is injected regardless; customRoles only widens the UI list.
210
+ */
211
+ export interface RepoMeshTaskAffinityPolicy {
212
+ enabled?: boolean;
213
+ byTaskMode?: Record<string, string>;
214
+ customRoles?: string[];
215
+ }
216
+
217
+ /**
218
+ * Resolve the affinity role for a task mode against a mesh's taskAffinity policy.
219
+ * Returns the lowercased role string to inject as `role=<role>`, or null when no
220
+ * affinity applies (affinity disabled, an explicit empty override, or an unknown mode).
221
+ *
222
+ * Precedence: policy.byTaskMode[mode] (operator override) → DEFAULT_TASKMODE_ROLE_MAP[mode].
223
+ * A blank override string explicitly opts that mode out of affinity routing.
224
+ */
225
+ export function resolveTaskAffinityRole(
226
+ taskMode: string | undefined,
227
+ policy: RepoMeshTaskAffinityPolicy | null | undefined,
228
+ ): string | null {
229
+ if (!taskMode) return null;
230
+ if (policy?.enabled === false) return null;
231
+ const override = policy?.byTaskMode && Object.prototype.hasOwnProperty.call(policy.byTaskMode, taskMode)
232
+ ? policy.byTaskMode[taskMode]
233
+ : undefined;
234
+ if (typeof override === 'string') {
235
+ const trimmed = override.trim().toLowerCase();
236
+ // An explicit blank override opts this task mode out of affinity routing.
237
+ return trimmed ? trimmed : null;
238
+ }
239
+ const fallback = DEFAULT_TASKMODE_ROLE_MAP[taskMode];
240
+ return fallback ?? null;
241
+ }
242
+
243
+ /**
244
+ * Union of the standard roles plus any operator-declared roles (byTaskMode values +
245
+ * customRoles), lowercased and deduped, preserving standard-first ordering. Used by
246
+ * the dashboard role dropdown so operators can pick a standard or a config-declared
247
+ * custom role. Pure/UI-facing — does not gate routing.
248
+ */
249
+ export function resolveMeshRoleOptions(policy: RepoMeshTaskAffinityPolicy | null | undefined): string[] {
250
+ const out: string[] = [...STANDARD_MESH_ROLES];
251
+ const seen = new Set<string>(out);
252
+ const add = (raw: unknown) => {
253
+ if (typeof raw !== 'string') return;
254
+ const role = raw.trim().toLowerCase();
255
+ if (!role || seen.has(role)) return;
256
+ seen.add(role);
257
+ out.push(role);
258
+ };
259
+ if (policy?.byTaskMode) {
260
+ for (const value of Object.values(policy.byTaskMode)) add(value);
261
+ }
262
+ if (Array.isArray(policy?.customRoles)) {
263
+ for (const value of policy.customRoles) add(value);
264
+ }
265
+ return out;
266
+ }
267
+
168
268
  /**
169
269
  * Resolve whether the load-balancing scheduler should auto-inject a
170
270
  * `converge=refine` required tag onto code_change tasks so they hard-filter onto
@@ -220,6 +320,18 @@ export interface RepoMeshPolicy {
220
320
  * Defaults to false: code_change routing is unchanged unless opted in.
221
321
  */
222
322
  autoConvergeCodeChange?: boolean;
323
+ /**
324
+ * Declarative task_mode → role affinity routing. When present (and not disabled),
325
+ * enqueueTask auto-injects a `role=<role>` required tag for the task's taskMode so
326
+ * the work hard-filters onto nodes advertising that role. Explicit target_node_id
327
+ * routing and any caller-supplied required_tags are preserved (auto-injection only
328
+ * applies when the caller pinned neither). When the mapped role has no advertising
329
+ * node in the mesh, the task is NOT blocked — the role tag is skipped so the work
330
+ * falls back to ordinary least_loaded eligibility (soft affinity). Defaults to the
331
+ * built-in DEFAULT_TASKMODE_ROLE_MAP even when this block is omitted; set
332
+ * `{ enabled: false }` to fully restore pre-affinity routing.
333
+ */
334
+ taskAffinity?: RepoMeshTaskAffinityPolicy;
223
335
  /**
224
336
  * Whether sessions spawned by mesh/coordinator policy should auto-open as visible
225
337
  * dashboard tabs or start hidden. Defaults to 'visible' to preserve existing