@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.
- package/dist/config/mesh-config.d.ts +12 -0
- package/dist/index.js +285 -83
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +285 -83
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-node-slots.d.ts +22 -0
- package/dist/mesh/mesh-runtime-store.d.ts +2 -2
- package/dist/mesh/mesh-scheduling-runtime.d.ts +5 -1
- package/dist/mesh/mesh-work-queue.d.ts +3 -3
- package/dist/mesh/model-provider-compat.d.ts +44 -0
- package/dist/providers/cli-provider-instance-types.d.ts +1 -0
- package/dist/providers/cli-provider-instance.d.ts +54 -0
- package/dist/repo-mesh-types.d.ts +30 -29
- package/package.json +3 -3
- package/src/commands/med-family/mesh-crud.ts +24 -14
- package/src/config/mesh-config.ts +71 -24
- package/src/mesh/coordinator-prompt.ts +26 -16
- package/src/mesh/mesh-node-slots.ts +52 -0
- package/src/mesh/mesh-queue-assignment.ts +43 -24
- package/src/mesh/mesh-runtime-store.ts +3 -3
- package/src/mesh/mesh-scheduling-runtime.ts +24 -9
- package/src/mesh/mesh-work-queue.ts +3 -3
- package/src/mesh/model-provider-compat.ts +73 -0
- package/src/providers/cli-provider-instance-types.ts +15 -0
- package/src/providers/cli-provider-instance.ts +174 -2
- package/src/repo-mesh-types.ts +39 -38
|
@@ -31,8 +31,9 @@ import type {
|
|
|
31
31
|
RepoMeshStatus,
|
|
32
32
|
RepoMeshNodeStatus,
|
|
33
33
|
} from '../repo-mesh-types.js';
|
|
34
|
-
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
34
|
+
import { mergeAndNormalizePolicy, resolveProviderMaxParallel } from '../repo-mesh-types.js';
|
|
35
35
|
import { getDifficultyBrains } from '../config/mesh-config.js';
|
|
36
|
+
import { resolveNodeCapabilitySlots } from './mesh-node-slots.js';
|
|
36
37
|
import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
|
|
37
38
|
import type { MagiKindPanelMap, MagiSlot, MagiTaskKind } from '@adhdev/mesh-shared';
|
|
38
39
|
|
|
@@ -459,19 +460,23 @@ function buildNodeConfigSection(mesh: LocalMeshEntry): string {
|
|
|
459
460
|
const explicitMachineLabel = typeof (n as any).machineLabel === 'string' ? (n as any).machineLabel : '';
|
|
460
461
|
const explicitLabel = explicitMachineLabel ? ` label: **${explicitMachineLabel}** |` : '';
|
|
461
462
|
const providerPriority = n.policy?.providerPriority?.length ? ` | providers: ${n.policy.providerPriority.join(', ')}` : '';
|
|
462
|
-
// Per-(node, provider) maxParallel cap
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
463
|
+
// Per-(node, provider) maxParallel cap, derived from the node's slots (the
|
|
464
|
+
// cap summed across a provider's slots). Only maxParallel is enforced by the
|
|
465
|
+
// queue; routing is governed by required_tags, not slot order.
|
|
466
|
+
const nodeSlots = resolveNodeCapabilitySlots(n);
|
|
467
|
+
const seenCapProvider = new Set<string>();
|
|
468
|
+
const providerCaps: string[] = [];
|
|
469
|
+
for (const slot of nodeSlots) {
|
|
470
|
+
const type = typeof slot?.provider === 'string' ? slot.provider.trim() : '';
|
|
471
|
+
if (!type) continue;
|
|
472
|
+
const key = type.toLowerCase();
|
|
473
|
+
if (seenCapProvider.has(key)) continue;
|
|
474
|
+
seenCapProvider.add(key);
|
|
475
|
+
const cap = resolveProviderMaxParallel(nodeSlots, type);
|
|
476
|
+
if (cap === undefined) continue;
|
|
477
|
+
providerCaps.push(`${type} (max ${cap})`);
|
|
478
|
+
}
|
|
479
|
+
const providerRolesSuffix = providerCaps.length ? ` | caps: ${providerCaps.join(', ')}` : '';
|
|
475
480
|
lines.push(`- ${explicitLabel} nodeId: \`${n.id}\` | workspace: \`${n.workspace}\`${n.daemonId ? ` | daemon: \`${n.daemonId}\`` : ''}${providerPriority}${providerRolesSuffix}${suffix}`);
|
|
476
481
|
// Routing tags: what this node advertises for mesh_enqueue_task required_tags.
|
|
477
482
|
// Surfaced so the coordinator can route by-capability (e.g. enqueue a Windows
|
|
@@ -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
|
|
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
|
|
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,
|
|
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 (
|
|
508
|
-
// on top of the global/taskMode caps — stricter wins. Resolved
|
|
509
|
-
// claiming session's providerType + node policy are both known,
|
|
510
|
-
// inside the atomic claim transaction so concurrent claims can't
|
|
511
|
-
|
|
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
|
|
1283
|
-
//
|
|
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
|
|
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
|
|
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 (
|
|
871
|
-
*
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
|
153
|
-
|
|
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
|
|
157
|
-
|
|
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 (
|
|
616
|
-
*
|
|
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
|
|
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
|