@adhdev/daemon-core 0.9.82-rc.507 → 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.
@@ -31,8 +31,9 @@ import type {
31
31
  RepoMeshStatus,
32
32
  RepoMeshNodeStatus,
33
33
  } from '../repo-mesh-types.js';
34
- import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
34
+ import { mergeAndNormalizePolicy, resolveProviderMaxParallel } from '../repo-mesh-types.js';
35
35
  import { getDifficultyBrains } from '../config/mesh-config.js';
36
+ import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
36
37
  import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
37
38
  import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
38
39
 
@@ -459,19 +460,23 @@ function buildNodeConfigSection(mesh: LocalMeshEntry): string {
459
460
  const explicitMachineLabel = typeof (n as any).machineLabel === 'string' ? (n as any).machineLabel : '';
460
461
  const explicitLabel = explicitMachineLabel ? ` label: **${explicitMachineLabel}** |` : '';
461
462
  const providerPriority = n.policy?.providerPriority?.length ? ` | providers: ${n.policy.providerPriority.join(', ')}` : '';
462
- // Per-(node, provider) maxParallel cap. Only maxParallel is enforced by the
463
- // queue; routing is governed by required_tags, not provider roles.
464
- const providerRoles = Array.isArray(n.policy?.providerRoles)
465
- ? (n.policy!.providerRoles as Array<{ providerType?: unknown; maxParallel?: unknown }>)
466
- .map(r => {
467
- const type = typeof r?.providerType === 'string' ? r.providerType.trim() : '';
468
- if (!type) return '';
469
- const cap = Number.isFinite(Number(r?.maxParallel)) ? ` (max ${Math.floor(Number(r.maxParallel))})` : '';
470
- return `${type}${cap}`;
471
- })
472
- .filter(Boolean)
473
- : [];
474
- const providerRolesSuffix = providerRoles.length ? ` | caps: ${providerRoles.join(', ')}` : '';
463
+ // Per-(node, provider) maxParallel cap, derived from the node's slots (the
464
+ // cap summed across a provider's slots). Only maxParallel is enforced by the
465
+ // queue; routing is governed by required_tags, not slot order.
466
+ const nodeSlots = resolveNodeCapabilitySlots(n);
467
+ const seenCapProvider = new Set<string>();
468
+ const providerCaps: string[] = [];
469
+ for (const slot of nodeSlots) {
470
+ const type = typeof slot?.provider === 'string' ? slot.provider.trim() : '';
471
+ if (!type) continue;
472
+ const key = type.toLowerCase();
473
+ if (seenCapProvider.has(key)) continue;
474
+ seenCapProvider.add(key);
475
+ const cap = resolveProviderMaxParallel(nodeSlots, type);
476
+ if (cap === undefined) continue;
477
+ providerCaps.push(`${type} (max ${cap})`);
478
+ }
479
+ const providerRolesSuffix = providerCaps.length ? ` | caps: ${providerCaps.join(', ')}` : '';
475
480
  lines.push(`- ${explicitLabel} nodeId: \`${n.id}\` | workspace: \`${n.workspace}\`${n.daemonId ? ` | daemon: \`${n.daemonId}\`` : ''}${providerPriority}${providerRolesSuffix}${suffix}`);
476
481
  // Routing tags: what this node advertises for mesh_enqueue_task required_tags.
477
482
  // Surfaced so the coordinator can route by-capability (e.g. enqueue a Windows
@@ -804,7 +809,9 @@ Follow these recovery rules:
804
809
  1. **If "Retry recommended"**: Check \`mesh_view_queue\` first — the daemon may have auto-requeued. If not, re-launch the session on the same node (\`mesh_launch_session\`), then resend the original task (\`mesh_send_task\`). The system message includes the original task text.
805
810
  2. **If "Max retries exceeded"**: Do NOT retry on the same node. Either reassign the task to a different node, or inform the user that the task requires manual intervention.
806
811
  3. **If no recovery context**: The stop may be intentional (normal completion). Use \`mesh_read_chat\` once to verify, then move on.
807
- 4. **Always record what happened**: After handling a failure, briefly note the outcome in your report to the user.`;
812
+ 4. **Always record what happened**: After handling a failure, briefly note the outcome in your report to the user.
813
+ 5. **Stuck-but-done vs actually-stuck**: If a delegated session appears stuck but has already produced a verified final summary or diff, stop polling noisy tool/terminal transcript bubbles. Verify with \`mesh_git_status\` or a checkpoint and proceed to landing.
814
+ 6. **Refinery falsely blocks a verified-clean branch — manual fast-forward convergence**: When \`mesh_refine_node\` falsely blocks a verified-clean branch (stale preflight, or the submodule-gitlink trivial-fast-forward misjudgment), bypass the refine tool and converge by strict fast-forward — (1) rebase the submodule commit onto the submodule \`origin/main\`, (2) push the submodule ff-only (verify \`git merge-base --is-ancestor\` first), (3) rebase the root branch and re-bump the submodule pointer so the root diff stays non-empty, (4) push the root ff-only. NEVER force-push or reset; abort and report on any non-fast-forward.`;
808
815
 
809
816
  const ONBOARDING_SECTION = `## Onboarding / Reinit
810
817
 
@@ -837,7 +844,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
837
844
 
838
845
  - **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinator — keep context lean.
839
846
  - **Front-load task messages.** Include everything the agent needs (files, problem, expected fix) in \`mesh_enqueue_task\` / \`mesh_send_task\`. Append a structured result request at the end: ask the worker to conclude with a JSON block containing \`status\`, \`changedFiles\`, \`gitStatus\`, \`validationResults\`, \`errors\`, \`nextAction\`. The daemon parses this automatically; you can read it from \`mesh_task_history\`.
840
- - **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start fresh only for independent work, provider mismatch, transcript contamination, or required worktree isolation.
847
+ - **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start a fresh session only when: (a) branch/worktree isolation is required, (b) the existing session had a dispatch failure or provider mismatch, (c) the transcript/runtime is contaminated or interrupted, or (d) the user explicitly asks for a different provider/session. Continuation of the same issue in an already-idle session is allowed and preferred — this rule blocks concurrent unrelated work interleaved into a live (still-generating) session, not sequential same-issue follow-ups.
841
848
  - **Worktree affinity.** A worktree is a durable per-branch workspace; keep all of a branch's code_change/fix/review work on its worktree node by targeting \`required_tags: ["worktree=<branch>"]\` or \`target_node_id\`. Get the id/branch from the \`mesh_clone_node\` result or a live \`mesh_status\` — the Configured Nodes snapshot won't list a worktree cloned after launch. Untargeted same-branch follow-ups drift to the base node. Only \`convergence\` (merge/push) runs on the base, never pinned to the worktree.
842
849
  - **Classify task difficulty to save tokens.** For each task you enqueue, judge its execution difficulty and pass \`difficulty\`: \`easy\` (extraction, renames, doc tweaks, trivial fixes), \`medium\` (ordinary feature/bugfix work), \`difficult\` (architecture, tricky debugging, multi-file refactors, subtle reasoning), or \`freeform\`. The mesh's per-difficulty brain preset then runs easy tasks on a cheaper model at low reasoning effort and hard tasks on a stronger model at high effort — real token savings on simple work. The current presets are shown in the "Brain presets" section below. You may still pass an explicit \`model\`/\`thinkingLevel\` to override the preset for one task.
843
850
  - **Retune node profiles when routing is a poor fit — but only with approval.** A node's capability slots (its provider/model/thinking + difficulty range + capability tags, seen via \`mesh_node_slots_list\`) are what task→node fitness routing matches against. If you notice a persistent mismatch — e.g. every \`difficult\` task lands on a node whose only slot is a cheap model, or a capability a node clearly has isn't declared — you MAY propose a slot change with \`mesh_node_slots_set\` (write=false). That returns current-vs-proposed; present that diff to the user with a one-line reason and apply (write=true) ONLY after they approve. It is a WHOLESALE replacement of the node's slots, so include the slots you want to keep. Never rewrite a node's profile silently or without a clear routing reason.
@@ -845,12 +852,15 @@ function buildRulesSection(coordinatorCliType?: string): string {
845
852
  - **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
846
853
  - **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).
847
854
  - **Check history first.** Call \`mesh_task_history\` at session start to avoid duplicate work and inform recovery. On failure, read task history before retrying.
855
+ - **Don't reopen already-done work after a resume.** Before reopening a reported issue after context compaction or session resume, check current git state and recent session context. If another session has already completed the work, continue from the existing diff/commit instead of starting a duplicate investigation.
848
856
  - **Sequence shared-base-moving merges.** Parallel dispatch is encouraged, but merging one worktree can advance another in-flight worktree's base — especially a shared submodule pointer — turning a clean fast-forward into a diverged rebase (patch-equivalence correctly blocks this). Before merging an in-flight worktree while siblings are also in flight, land in an intentional order, re-clone long-running worktrees from the advanced base, or expect to manually rebase + ff-only the laggards; merging an independent fix mid-flight can strand siblings into a rebase.
849
857
  - **Converge branches.** After worktree tasks: refine/fast-forward, or classify as \`pushed_feature_branch_needs_merge\` / \`blocked_review\` / \`cleanup_candidate\` / \`not_mergeable\`. Clean up with \`mesh_remove_node\`.
850
858
  - **Refinery is config-driven.** \`mesh_refine_node\` must run validation from \`.adhdev/refine.{json,yaml,yml}\` or \`repo-mesh.refine.*\`. Heuristics are scaffolding only.
851
859
  - **Submodule reachability = publish-needed.** \`submodule_reachability_failed\` → classify as \`blocked_review\`, request user approval to push to submodule main, then rerun \`mesh_refine_node\`.
852
860
  - **Honor per-node instructions.** When a node carries a 📌 Node instruction in the nodes section, include the relevant parts of that instruction in the task message you send to that node. Don't paraphrase the instruction into your own words — quote it verbatim so the worker agent sees exactly what the user wrote.
853
861
  - **Mission status does not update itself.** When a mission's tasks are all done or the work is abandoned, explicitly call \`mesh_mission_upsert\` to set status \`completed\` or \`abandoned\`. Never leave a finished mission in \`active\`. All-cancelled tasks with no further work → \`abandoned\`.
862
+ - **Don't spawn a nested coordinator for simple inspection.** Do not spawn a nested coordinator-like agent for simple inspection tasks. If delegation is required, use explicit provider selection and a fully self-contained, bounded task instruction.
863
+ - **Keep internal traffic out of the transcript.** Internal tool calls, status events, control messages, and debug output must not appear as ordinary user-visible chat transcript content unless explicitly marked user-facing by the producing agent.
854
864
  - **Never fabricate tool results.** Always call the actual tool.
855
865
  - **Keep the user informed.** One or two sentences after each delegation round.${coordinatorNote}
856
866
 
@@ -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
+ }
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
2
2
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
3
  import { MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
4
4
  import { loadConfig } from '../config/config.js';
5
- import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
5
+ import { getMesh } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
@@ -15,7 +15,8 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
16
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, deriveSlotsFromLegacy, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, withStatusProbeMarker, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
+ import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
19
20
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
21
  import { readNonEmptyString } from './mesh-events-utils.js';
21
22
  import { readMeshNodeDaemonId } from './mesh-node-identity.js';
@@ -23,6 +24,7 @@ import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent }
23
24
  import { isWorktreeBootstrapStaleRunning, shouldDeferDispatchForBootstrap } from './worktree-bootstrap-config.js';
24
25
  import { isWithinCloneBootstrapGrace } from './mesh-clone-grace.js';
25
26
  import { beginTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
27
+ import { isModelCompatibleWithProvider } from './model-provider-compat.js';
26
28
 
27
29
  /**
28
30
  * CANON: the single canonical coordinator-daemon id this daemon stamps onto every
@@ -504,11 +506,12 @@ export function tryAssignQueueTask(
504
506
  }
505
507
 
506
508
  const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
507
- // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy.providerRoles) layers
508
- // on top of the global/taskMode caps — stricter wins. Resolved here where the
509
- // claiming session's providerType + node policy are both known, then enforced
510
- // inside the atomic claim transaction so concurrent claims can't overshoot it.
511
- const providerMaxParallel = resolveProviderMaxParallel(node?.policy, providerType);
509
+ // Per-(node, provider) maxParallel cap (summed across the node's slots for this
510
+ // provider) layers on top of the global/taskMode caps — stricter wins. Resolved
511
+ // here where the claiming session's providerType + node policy are both known,
512
+ // then enforced inside the atomic claim transaction so concurrent claims can't
513
+ // overshoot it.
514
+ const providerMaxParallel = resolveProviderMaxParallel(resolveNodeCapabilitySlots(node), providerType);
512
515
  // WTDISPATCH-FANOUT: tell the atomic claim whether the claiming node is a worktree
513
516
  // clone so a `convergence` task (base-only: merge → push → cleanup) is refused for
514
517
  // worktree sessions. Without it, every sibling worktree session on this daemon could
@@ -1279,8 +1282,8 @@ export function __buildSchedulingPoolForTests(
1279
1282
  //
1280
1283
  // A node's capability slots are the single source of truth for routing. When a
1281
1284
  // node has explicit `policy.slots` we use them; otherwise we derive slots from the
1282
- // legacy providerPriority/providerRoles + the machine-global difficultyBrains so
1283
- // existing nodes keep working (back-compat). The fitness scorer ranks a node for a
1285
+ // legacy providerPriority + the machine-global difficultyBrains so existing nodes
1286
+ // keep working (back-compat). The fitness scorer ranks a node for a
1284
1287
  // specific task by how well its best slot matches the task's difficulty and
1285
1288
  // required tags — with graceful fallback so a task is never blocked by a missing
1286
1289
  // exact match.
@@ -1292,19 +1295,6 @@ interface FitnessTask {
1292
1295
  requiredTags?: string[];
1293
1296
  }
1294
1297
 
1295
- /** Resolve a node's capability slots: explicit policy.slots, else derived from legacy. */
1296
- function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
1297
- const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
1298
- if (explicit.length) return explicit;
1299
- let difficultyBrains: any;
1300
- try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
1301
- return deriveSlotsFromLegacy({
1302
- providerPriority: normalizeProviderPriority(node?.policy),
1303
- providerRoles: Array.isArray(node?.policy?.providerRoles) ? node.policy.providerRoles : undefined,
1304
- difficultyBrains,
1305
- });
1306
- }
1307
-
1308
1298
  /**
1309
1299
  * Score how well one slot fits a task. Higher = better. A slot whose difficulty
1310
1300
  * range contains the task's difficulty scores highest; a general-purpose slot
@@ -1555,6 +1545,17 @@ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: st
1555
1545
  const state = inst.getState();
1556
1546
  const settings = state.settings as Record<string, unknown> || {};
1557
1547
  if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
1548
+ // DISPATCH-DEADLOCK-COORD-SESSION-SLOT: a coordinator session for THIS mesh
1549
+ // (meshCoordinatorFor === meshId) is never a pending-claim worker — the idle→claim
1550
+ // drain (drainMeshQueue, isIdleSessionState + worker role) never picks it up, because
1551
+ // a coordinator is generating/non-idle and is the dispatcher, not a claimer. The claim
1552
+ // path excludes it structurally; the skip gate must apply the SAME exclusion. Without
1553
+ // this, a node whose only live mesh session is the coordinator makes this gate return
1554
+ // true, so no worker auto-launches and no session ever claims → the task pends forever
1555
+ // with no error/requeue (silent deadlock). The busy-set / non-idle guards below don't
1556
+ // help because the coordinator holds no *assigned* queue task, so it is neither busy
1557
+ // nor terminal here.
1558
+ if (readNonEmptyString(settings.meshCoordinatorFor) === meshId) return false;
1558
1559
  const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1559
1560
  // Canonical-form match (see nodeHasActiveMeshWork / liveSessionCountForNode): a
1560
1561
  // daemon-id form skew must not make a present session look absent and reopen the
@@ -2056,13 +2057,31 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
2056
2057
  // Slot-derived model/thinking: an explicit task.model/thinkingLevel
2057
2058
  // (resolved from the enqueue-time brain) still wins; the matched
2058
2059
  // slot fills only what the task left blank (ORCHESTRATION_NODE_SLOTS.md).
2059
- const effectiveModel = (typeof task.model === 'string' && task.model.trim()) ? task.model.trim() : resolved.model;
2060
+ const rawEffectiveModel = (typeof task.model === 'string' && task.model.trim()) ? task.model.trim() : resolved.model;
2060
2061
  const effectiveThinkingLevel = (typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim()) ? task.thinkingLevel.trim() : resolved.thinkingLevel;
2061
2062
 
2063
+ // CODEX-400 GUARD: the difficulty→brain presets (and MAGI slots) carry
2064
+ // provider-agnostic Anthropic model aliases (opus/sonnet/haiku). Now that
2065
+ // resolved.providerType is definitively known, drop the model if it is a
2066
+ // Claude model but the provider is NOT Anthropic-backed (codex-cli /
2067
+ // antigravity-cli / hermes-cli): forwarding `claude-*` as an initialModel
2068
+ // makes those providers convert it to `-c model='claude-...'`, and a
2069
+ // ChatGPT-account codex then rejects the launch with a 400. Stripping it
2070
+ // lets the provider fall back to its own default model; the provider-neutral
2071
+ // thinkingLevel axis is preserved. This is the single authoritative point
2072
+ // that enforces the invariant across every model source (preset, slot,
2073
+ // explicit) because both remote and local launch consume effectiveModel below.
2074
+ const effectiveModel = isModelCompatibleWithProvider(rawEffectiveModel, resolved.providerType)
2075
+ ? rawEffectiveModel
2076
+ : undefined;
2077
+ if (rawEffectiveModel && effectiveModel === undefined) {
2078
+ LOG.info('MeshQueue', `CODEX-400 GUARD: dropped incompatible launch model '${rawEffectiveModel}' for non-Anthropic provider '${resolved.providerType}' on node ${nodeId} (task ${task.id}); provider will use its own default model`);
2079
+ }
2080
+
2062
2081
  // Don't spawn a session for a (node, provider) already at its declared
2063
2082
  // maxParallel cap — it would launch only to fail the claim. The claim
2064
2083
  // transaction enforces the cap regardless; this just avoids a doomed launch.
2065
- const providerCap = resolveProviderMaxParallel(node?.policy, resolved.providerType);
2084
+ const providerCap = resolveProviderMaxParallel(resolveNodeCapabilitySlots(node), resolved.providerType);
2066
2085
  if (
2067
2086
  providerCap !== undefined
2068
2087
  && activeProviderAssignedCount(meshId, nodeId, resolved.providerType) >= providerCap
@@ -867,8 +867,8 @@ export class MeshRuntimeStore {
867
867
  /**
868
868
  * Count active (status='assigned') tasks on a (node, provider) combination,
869
869
  * matched by the assignedProviderType stamped on the payload at claim time.
870
- * Drives the per-(node, provider) maxParallel cap (RepoMeshNodePolicy
871
- * providerRoles). The active-assignment set for a single node is tiny, so
870
+ * Drives the per-(node, provider) maxParallel cap (summed across a provider's
871
+ * slots[].maxParallel). The active-assignment set for a single node is tiny, so
872
872
  * parsing payloads here is cheap and avoids a schema migration. Pre-cap legacy
873
873
  * rows (no provider stamp) and other providers on the same node do not consume
874
874
  * this provider's budget, so the cap is fully backward compatible.
@@ -906,7 +906,7 @@ export class MeshRuntimeStore {
906
906
  if (this.hasActiveSessionAssignment(meshId, sessionId)) return null;
907
907
  const nodeBusy = this.hasActiveNodeAssignment(meshId, nodeId);
908
908
 
909
- // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy providerRoles).
909
+ // Per-(node, provider) maxParallel cap (summed slots[].maxParallel).
910
910
  // Orthogonal to taskMode: this bounds the (node, provider) resource pool
911
911
  // regardless of read-only vs write. When the cap is already met, this
912
912
  // session cannot claim any candidate here — return null. This composes
@@ -21,6 +21,7 @@ import {
21
21
  resolveProviderMaxParallel,
22
22
  } from '../repo-mesh-types.js';
23
23
  import { normalizeMeshNodeId } from '@adhdev/mesh-shared';
24
+ import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
24
25
  import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
25
26
  import { isTaskReadonly } from './mesh-work-queue.js';
26
27
 
@@ -44,7 +45,11 @@ export interface MeshNodeSchedulingRuntime {
44
45
  schedulingPriority: number;
45
46
  /** Per-node concurrent-session cap, when configured. */
46
47
  maxConcurrentSessions?: number;
47
- /** Per-(node, provider) caps + consumption, when providerRoles declares any. */
48
+ /**
49
+ * Per-(node, provider) caps + consumption, when the node's slots declare a
50
+ * maxParallel for any provider. (Field name kept for dashboard back-compat;
51
+ * the cap source is now slots[].maxParallel, not the removed providerRoles.)
52
+ */
48
53
  providerRoles?: MeshNodeProviderSchedulingRuntime[];
49
54
  /**
50
55
  * True when the node currently cannot claim a NEW write (non-readonly) task —
@@ -147,17 +152,27 @@ export function buildMeshSchedulingRuntime(
147
152
  // Write isolation: a node already holding an assigned write task can't take another.
148
153
  if ((writeAssignedByNode.get(nodeId) ?? 0) > 0) capReasons.push('node_has_active_assignment');
149
154
 
150
- // Per-(node, provider) caps, with live consumption.
155
+ // Per-(node, provider) caps, with live consumption. Derived from the node's
156
+ // resolved capability slots (explicit policy.slots, else legacy-derived): the
157
+ // distinct providers named by slots that declare a maxParallel cap, each cap
158
+ // summed across that provider's slots by resolveProviderMaxParallel.
151
159
  let providerRoles: MeshNodeProviderSchedulingRuntime[] | undefined;
152
- const declaredRoles = Array.isArray(policy?.providerRoles) ? policy!.providerRoles! : [];
153
- if (declaredRoles.length) {
160
+ const slots = resolveNodeCapabilitySlots(rawNode);
161
+ const cappedProviders: string[] = [];
162
+ const seenProvider = new Set<string>();
163
+ for (const slot of slots) {
164
+ const providerType = typeof slot?.provider === 'string' ? slot.provider.trim() : '';
165
+ if (!providerType) continue;
166
+ const key = providerType.toLowerCase();
167
+ if (seenProvider.has(key)) continue;
168
+ seenProvider.add(key);
169
+ if (resolveProviderMaxParallel(slots, providerType) !== undefined) cappedProviders.push(providerType);
170
+ }
171
+ if (cappedProviders.length) {
154
172
  const byProvider = providerCountByNode.get(nodeId);
155
173
  providerRoles = [];
156
- for (const role of declaredRoles) {
157
- if (!role || typeof role !== 'object') continue;
158
- const providerType = typeof role.providerType === 'string' ? role.providerType.trim() : '';
159
- if (!providerType) continue;
160
- const maxParallel = resolveProviderMaxParallel(policy, providerType);
174
+ for (const providerType of cappedProviders) {
175
+ const maxParallel = resolveProviderMaxParallel(slots, providerType);
161
176
  const activeAssigned = byProvider?.get(providerType) ?? 0;
162
177
  const capReached = maxParallel !== undefined && activeAssigned >= maxParallel;
163
178
  providerRoles.push({
@@ -612,8 +612,8 @@ export interface MeshWorkQueueEntry {
612
612
  assignedSessionId?: string;
613
613
  /**
614
614
  * Provider type of the session that claimed the task. Recorded so the queue
615
- * can enforce per-(node, provider) maxParallel caps (RepoMeshNodePolicy
616
- * providerRoles) by counting active assignments grouped by node + provider.
615
+ * can enforce per-(node, provider) maxParallel caps (summed slots[].maxParallel)
616
+ * by counting active assignments grouped by node + provider.
617
617
  */
618
618
  assignedProviderType?: string;
619
619
  /** Human/operator reason for terminal cancellation. */
@@ -1095,7 +1095,7 @@ export function getMeshQueueRevision(meshId: string): string {
1095
1095
  *
1096
1096
  * `opts.providerType` is stamped onto the claimed entry (assignedProviderType) so
1097
1097
  * per-(node, provider) caps can be counted. `opts.providerMaxParallel`, when set,
1098
- * is the enforced per-(node, provider) cap from RepoMeshNodePolicy.providerRoles:
1098
+ * is the enforced per-(node, provider) cap (summed slots[].maxParallel):
1099
1099
  * a task is not assigned to this (node, provider) once it already has that many
1100
1100
  * active assignments. This composes with the global/taskMode caps (stricter wins).
1101
1101
  */
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Provider ↔ model compatibility guard for brain/slot-derived launch models.
3
+ *
4
+ * WHY THIS EXISTS: the difficulty→brain presets (and MAGI slots) carry
5
+ * provider-agnostic model strings like `opus`/`sonnet`/`haiku`, which are
6
+ * Anthropic (Claude) model names. When a task lands on a node whose provider is
7
+ * NOT Anthropic-backed — `codex-cli` (ChatGPT), `antigravity-cli`, `hermes-cli`
8
+ * — forwarding a `claude-*` model as the launch arg makes that provider convert
9
+ * it to e.g. `-c model='claude-...'`, and a ChatGPT-account codex then rejects
10
+ * the launch with a 400. The model string was never meant to be forced onto a
11
+ * provider that can't honor it (presets are "best-effort at launch").
12
+ *
13
+ * The invariant this enforces: **an Anthropic (`claude-*`) model is never passed
14
+ * as a launch argument to a non-Anthropic provider.** When a preset/slot model is
15
+ * incompatible with the resolved provider, drop the model (the provider then uses
16
+ * its own default) and keep only the provider-neutral axis (thinkingLevel).
17
+ *
18
+ * This is deliberately conservative: it only strips a model that is *known* to be
19
+ * Anthropic when the provider is *known* to be non-Anthropic. Unknown models and
20
+ * unknown providers pass through unchanged so a legitimately provider-specific
21
+ * model (e.g. an operator who configured `gpt-5-codex` on a codex slot) is not
22
+ * clobbered.
23
+ */
24
+
25
+ /**
26
+ * Provider types that are Anthropic-backed (accept `claude-*` / opus/sonnet/haiku
27
+ * model names). Everything else is treated as non-Anthropic for the guard.
28
+ */
29
+ const ANTHROPIC_PROVIDER_TYPES: ReadonlySet<string> = new Set([
30
+ 'claude-cli',
31
+ ]);
32
+
33
+ /**
34
+ * True when `providerType` is an Anthropic-backed provider (Claude). Matching is
35
+ * case-insensitive and tolerant of surrounding whitespace. An empty/undefined
36
+ * provider is treated as non-Anthropic (unknown → don't assume Claude).
37
+ */
38
+ export function isAnthropicProvider(providerType: string | undefined | null): boolean {
39
+ const p = typeof providerType === 'string' ? providerType.trim().toLowerCase() : '';
40
+ return p.length > 0 && ANTHROPIC_PROVIDER_TYPES.has(p);
41
+ }
42
+
43
+ /**
44
+ * True when `model` names an Anthropic (Claude) model — the provider-agnostic
45
+ * brain-preset aliases (`opus`/`sonnet`/`haiku`) or any explicit `claude-*` id.
46
+ * Case-insensitive. Non-Anthropic and unknown models return false so they pass
47
+ * the compatibility check unchanged.
48
+ */
49
+ export function isAnthropicModel(model: string | undefined | null): boolean {
50
+ const m = typeof model === 'string' ? model.trim().toLowerCase() : '';
51
+ if (!m) return false;
52
+ if (m.startsWith('claude') || m.startsWith('anthropic')) return true;
53
+ // Provider-agnostic brain-preset aliases (DEFAULT_DIFFICULTY_BRAINS) are Claude
54
+ // model families; a bare `opus`/`sonnet`/`haiku` (optionally with a suffix like
55
+ // `opus-4` or `sonnet-4-5`) means an Anthropic model.
56
+ return /^(opus|sonnet|haiku)(\b|[-_])/.test(m);
57
+ }
58
+
59
+ /**
60
+ * Compatibility check for a brain/slot-derived launch model against the provider
61
+ * it would launch on. Returns false ONLY for the concrete failure mode we guard:
62
+ * an Anthropic model routed to a non-Anthropic provider. Everything else
63
+ * (no model, non-Anthropic model, Anthropic provider, unknown provider) is
64
+ * compatible so nothing legitimate is stripped.
65
+ */
66
+ export function isModelCompatibleWithProvider(
67
+ model: string | undefined | null,
68
+ providerType: string | undefined | null,
69
+ ): boolean {
70
+ if (!isAnthropicModel(model)) return true; // non-Claude model → no constraint
71
+ if (isAnthropicProvider(providerType)) return true; // Claude model on Claude provider → fine
72
+ return false; // Claude model on non-Claude provider → block
73
+ }
@@ -105,6 +105,21 @@ export const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
105
105
  // hold only covers the approval-resolved valley; widening this settle window to 4000ms
106
106
  // covers that race AND the ~3s waiting_approval valley within the settle bound.
107
107
  export const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
108
+ // (FALSE-IDLE-MIDTURN codex/PTY) Minimum quiet dwell required after the LAST raw PTY
109
+ // output before a PTY-PARSED (non-native-history) provider's on-screen "final" assistant
110
+ // bubble may be trusted as a turn-complete reply. codex parses its assistant text from the
111
+ // terminal screen, so a completion-gate poll that lands MID-STREAM — while the screen still
112
+ // shows a partial sentence fragment ("...하겠습니다") and the FSM momentarily reads idle —
113
+ // satisfies completionHasFinalAssistantMessage (present=true) and would clean-emit an early
114
+ // completion. The busyEpoch/lastOutputAt continuity guard in the flush only CANCELS when new
115
+ // output ARRIVES during the settle; it cannot catch a turn that fell quiet just before the
116
+ // arm and is still mid-turn. Require instead that the screen has been QUIET for at least this
117
+ // long since the last output: a genuinely finished turn's tail is stable well past this bound,
118
+ // while a mid-stream fragment is by definition still receiving output (or just did). Bounded,
119
+ // non-terminal HOLD — a real completion re-passes the gate one retry later once the dwell is
120
+ // met. Scoped (at the call site) to autonomous mesh sessions, so interactive sessions are
121
+ // untouched.
122
+ export const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
108
123
  // (FALSE-IDLE-BACKGROUND-CMD) Hard cap on how long a pending completion may be HELD
109
124
  // solely because the claude-cli transcript still shows an unresolved run_in_background
110
125
  // bash job (backgroundTaskActive). The hold is the correct behaviour while the job is