@adhdev/daemon-core 0.9.82-rc.482 → 0.9.82-rc.484
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/commands/cli-manager.d.ts +20 -0
- package/dist/config/mesh-config.d.ts +19 -1
- package/dist/index.js +398 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +398 -19
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/contracts.d.ts +27 -4
- package/dist/mesh/mesh-events-pending.d.ts +12 -0
- package/dist/mesh/mesh-runtime-store.d.ts +14 -0
- package/dist/mesh/mesh-work-queue.d.ts +17 -0
- package/dist/providers/cli-provider-instance.d.ts +14 -0
- package/dist/providers/contracts.d.ts +47 -0
- package/dist/repo-mesh-types.d.ts +11 -2
- package/dist/shared-types.d.ts +4 -0
- package/package.json +3 -3
- package/src/commands/cli-manager.ts +64 -3
- package/src/commands/low-family/coordinator-prompt.ts +53 -0
- package/src/commands/med-family/mesh-crud.ts +35 -0
- package/src/config/mesh-config.ts +42 -1
- package/src/mesh/contracts.ts +42 -7
- package/src/mesh/coordinator-prompt.ts +55 -0
- package/src/mesh/mesh-events-pending.ts +54 -0
- package/src/mesh/mesh-queue-assignment.ts +46 -4
- package/src/mesh/mesh-reconcile-loop.ts +69 -1
- package/src/mesh/mesh-runtime-store.ts +22 -0
- package/src/mesh/mesh-work-queue.ts +36 -3
- package/src/providers/cli-provider-instance.ts +46 -0
- package/src/providers/contracts.ts +47 -0
- package/src/providers/provider-schema.ts +6 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +29 -0
- package/src/repo-mesh-types.ts +11 -2
- package/src/shared-types.ts +4 -0
- package/src/status/snapshot.ts +4 -0
package/src/mesh/contracts.ts
CHANGED
|
@@ -332,7 +332,12 @@ export function assertPendingMeshCoordinatorEventV2(raw: unknown, path = '$'): P
|
|
|
332
332
|
* Decide whether a v2 pending event should be delivered to the given drainer.
|
|
333
333
|
* Centralised so every drain implementation uses the same rule.
|
|
334
334
|
*
|
|
335
|
-
* - 'broadcast': always delivered.
|
|
335
|
+
* - 'broadcast': always delivered. NOTE: a terminal task event that reached the
|
|
336
|
+
* queue as broadcast is an ownership leak (it belongs to its dispatching
|
|
337
|
+
* coordinator). This pure helper does not have the drain-window
|
|
338
|
+
* daemon-form/session matching semantics, so the terminal+broadcast
|
|
339
|
+
* dispatchedBy filter is applied one layer up in the drainer (see
|
|
340
|
+
* mesh-events-pending routeV2EventsForDrainer) where those semantics live.
|
|
336
341
|
* - 'system': never delivered to coordinators (system handler only).
|
|
337
342
|
* - 'unicast': delivered iff intendedFor matches drainer identity.
|
|
338
343
|
*
|
|
@@ -367,6 +372,18 @@ const TERMINAL_TASK_EVENTS: ReadonlySet<string> = new Set([
|
|
|
367
372
|
'refine:accepted',
|
|
368
373
|
]);
|
|
369
374
|
|
|
375
|
+
/**
|
|
376
|
+
* True for a terminal task event (completion / stop / refine outcome). A terminal
|
|
377
|
+
* event belongs to exactly the coordinator that dispatched the task, so it must
|
|
378
|
+
* never fan out to sibling coordinators that did not dispatch it. Used by the
|
|
379
|
+
* emit-side stamp (to avoid downgrading an unaddressed terminal event to full
|
|
380
|
+
* broadcast) and by the drain-side filter (defense-in-depth for any terminal
|
|
381
|
+
* event that already reached the queue as broadcast).
|
|
382
|
+
*/
|
|
383
|
+
export function isTerminalTaskEvent(eventName: string): boolean {
|
|
384
|
+
return TERMINAL_TASK_EVENTS.has(eventName);
|
|
385
|
+
}
|
|
386
|
+
|
|
370
387
|
/**
|
|
371
388
|
* Coordinator-addressed dispatch-plane alerts. `mesh:dispatch_blocked` is the
|
|
372
389
|
* Fix (1) actionable dispatch-skip notification: it exists precisely to page the
|
|
@@ -445,9 +462,18 @@ export function coordinatorIdentityFromEmitFields(fields: {
|
|
|
445
462
|
* returns undefined: the event stays a v1 (unstamped) event and is broadcast-
|
|
446
463
|
* treated during rollout, exactly as before — no regression, no fabricated
|
|
447
464
|
* identity. When the resolved scope is 'unicast' but no `intendedFor` is
|
|
448
|
-
* available, the
|
|
449
|
-
*
|
|
450
|
-
*
|
|
465
|
+
* available, the fallback depends on the event class:
|
|
466
|
+
*
|
|
467
|
+
* - Terminal task events (completion / stop / refine outcome) MUST NOT be
|
|
468
|
+
* broadcast to every coordinator — a completion belongs to the coordinator
|
|
469
|
+
* that dispatched the task, and broadcasting it makes non-owner coordinators
|
|
470
|
+
* (e.g. sibling MAGI coordinators that never dispatched this replica's task)
|
|
471
|
+
* act on a completion that is not theirs (MAGI-REPLICA-COMPLETION-EVENT-LEAK).
|
|
472
|
+
* For these we address the event to `dispatchedBy` (the dispatching
|
|
473
|
+
* coordinator) and KEEP it unicast, so the stamp stays contract-valid and the
|
|
474
|
+
* event reaches only its originating coordinator.
|
|
475
|
+
* - Any other unicast event with no addressable target falls back to broadcast
|
|
476
|
+
* (contract-valid, still delivered, never dropped) — unchanged.
|
|
451
477
|
*/
|
|
452
478
|
export function buildPendingEventEmitStamp(opts: {
|
|
453
479
|
eventName: string;
|
|
@@ -460,9 +486,18 @@ export function buildPendingEventEmitStamp(opts: {
|
|
|
460
486
|
let scope: MeshEventScope = opts.scope ?? defaultScopeForEvent(opts.eventName);
|
|
461
487
|
let intendedFor = opts.intendedFor;
|
|
462
488
|
if (scope === 'unicast' && !intendedFor) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
489
|
+
if (isTerminalTaskEvent(opts.eventName)) {
|
|
490
|
+
// Terminal event with no explicit target: address it to the dispatching
|
|
491
|
+
// coordinator rather than broadcasting to every coordinator. dispatchedBy
|
|
492
|
+
// is the coordinator that owns the task, so this is the correct — and
|
|
493
|
+
// contract-valid (unicast requires intendedFor) — narrowing.
|
|
494
|
+
intendedFor = opts.dispatchedBy;
|
|
495
|
+
} else {
|
|
496
|
+
// No addressable target for a non-terminal unicast event — fall back to
|
|
497
|
+
// broadcast so the stamp is contract-valid and the event is still
|
|
498
|
+
// delivered (never dropped).
|
|
499
|
+
scope = 'broadcast';
|
|
500
|
+
}
|
|
466
501
|
}
|
|
467
502
|
if (scope !== 'unicast') intendedFor = undefined;
|
|
468
503
|
return {
|
|
@@ -32,6 +32,8 @@ import type {
|
|
|
32
32
|
RepoMeshNodeStatus,
|
|
33
33
|
} from '../repo-mesh-types.js';
|
|
34
34
|
import { mergeAndNormalizePolicy } from '../repo-mesh-types.js';
|
|
35
|
+
import { getDifficultyBrains } from '../config/mesh-config.js';
|
|
36
|
+
import { MESH_TASK_DIFFICULTIES } from '@adhdev/mesh-shared';
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Cheap, locally-derived "what just happened" snapshot for the coordinator
|
|
@@ -279,6 +281,9 @@ Repository: \`${mesh.repoIdentity}\`${mesh.defaultBranch ? `\nDefault branch: \`
|
|
|
279
281
|
// ── Policy ──
|
|
280
282
|
sections.push(buildPolicySection(mergeAndNormalizePolicy(undefined, mesh.policy)));
|
|
281
283
|
|
|
284
|
+
// ── Brain presets (difficulty → model/thinking) ──
|
|
285
|
+
sections.push(buildBrainPresetsSection());
|
|
286
|
+
|
|
282
287
|
// ── Tools ──
|
|
283
288
|
sections.push(TOOLS_SECTION);
|
|
284
289
|
|
|
@@ -454,6 +459,23 @@ function buildNodeConfigSection(mesh: LocalMeshEntry): string {
|
|
|
454
459
|
: [];
|
|
455
460
|
const providerRolesSuffix = providerRoles.length ? ` | caps: ${providerRoles.join(', ')}` : '';
|
|
456
461
|
lines.push(`- ${explicitLabel} nodeId: \`${n.id}\` | workspace: \`${n.workspace}\`${n.daemonId ? ` | daemon: \`${n.daemonId}\`` : ''}${providerPriority}${providerRolesSuffix}${suffix}`);
|
|
462
|
+
// Routing tags: what this node advertises for mesh_enqueue_task required_tags.
|
|
463
|
+
// Surfaced so the coordinator can route by-capability (e.g. enqueue a Windows
|
|
464
|
+
// build with required_tags:["os=win32"], or a custom "test-runner" node).
|
|
465
|
+
// os=/arch= use the same userOverrides → reported precedence as the matcher;
|
|
466
|
+
// the internal converge= tag is omitted (it is not something to target by hand).
|
|
467
|
+
const routingTags: string[] = [];
|
|
468
|
+
const custom = Array.isArray((n as any).capabilities) ? (n as any).capabilities : [];
|
|
469
|
+
for (const t of custom) { const s = typeof t === 'string' ? t.trim() : ''; if (s) routingTags.push(s); }
|
|
470
|
+
const tagOs = ((n as any).userOverrides?.platform || (n as any).reportedPlatform || '').toString().trim();
|
|
471
|
+
const tagArch = ((n as any).userOverrides?.arch || (n as any).reportedArch || '').toString().trim();
|
|
472
|
+
if (tagOs) routingTags.push(`os=${tagOs}`);
|
|
473
|
+
if (tagArch) routingTags.push(`arch=${tagArch}`);
|
|
474
|
+
const wtBranch = typeof (n as any).worktreeBranch === 'string' ? (n as any).worktreeBranch.trim() : '';
|
|
475
|
+
if (n.isLocalWorktree && wtBranch) routingTags.push(`worktree=${wtBranch}`);
|
|
476
|
+
if (routingTags.length) {
|
|
477
|
+
lines.push(` 🏷️ routing tags: ${routingTags.map(t => `\`${t}\``).join(', ')}`);
|
|
478
|
+
}
|
|
457
479
|
const nodePrompt = typeof (n as any).systemPrompt === 'string' ? (n as any).systemPrompt.trim() : '';
|
|
458
480
|
if (nodePrompt) {
|
|
459
481
|
lines.push(` 📌 Node instruction: ${indentFollowing(nodePrompt, ' ')}`);
|
|
@@ -582,6 +604,36 @@ function truncateNote(text: string): string {
|
|
|
582
604
|
return `${text.slice(0, OPERATING_NOTE_MAX_CHARS).trimEnd()}… [truncated]`;
|
|
583
605
|
}
|
|
584
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Render the difficulty→brain presets so the coordinator knows what each
|
|
609
|
+
* `difficulty` value resolves to (which model / thinking level). Machine-local,
|
|
610
|
+
* read live at prompt-build time — seeded defaults when nothing is configured.
|
|
611
|
+
*/
|
|
612
|
+
function buildBrainPresetsSection(): string {
|
|
613
|
+
let brains;
|
|
614
|
+
try { brains = getDifficultyBrains(); } catch { brains = {}; }
|
|
615
|
+
const lines = [
|
|
616
|
+
'## Brain presets',
|
|
617
|
+
'',
|
|
618
|
+
'When you pass `difficulty` on `mesh_enqueue_task`, it resolves to this model / thinking level (an explicit model/thinkingLevel on the task overrides it). Pick easy for trivial work to save tokens, difficult for hard reasoning.',
|
|
619
|
+
'',
|
|
620
|
+
];
|
|
621
|
+
for (const key of MESH_TASK_DIFFICULTIES) {
|
|
622
|
+
const slot = (brains as Record<string, { provider?: string; model?: string; thinkingLevel?: string } | undefined>)[key];
|
|
623
|
+
if (!slot || (!slot.provider && !slot.model && !slot.thinkingLevel)) {
|
|
624
|
+
lines.push(`- **${key}**: (no preset — ordinary routing)`);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const parts = [
|
|
628
|
+
slot.provider ? `provider: \`${slot.provider}\`` : '',
|
|
629
|
+
slot.model ? `model: \`${slot.model}\`` : '',
|
|
630
|
+
slot.thinkingLevel ? `thinking: \`${slot.thinkingLevel}\`` : '',
|
|
631
|
+
].filter(Boolean).join(' | ');
|
|
632
|
+
lines.push(`- **${key}**: ${parts}`);
|
|
633
|
+
}
|
|
634
|
+
return lines.join('\n');
|
|
635
|
+
}
|
|
636
|
+
|
|
585
637
|
function buildPolicySection(policy: RepoMeshPolicy): string {
|
|
586
638
|
const rules: string[] = [];
|
|
587
639
|
if (policy.requirePreTaskCheckpoint) rules.push('- Create a git checkpoint **before** starting each task');
|
|
@@ -663,6 +715,7 @@ const WORKFLOW_SECTION = `## Orchestration Workflow
|
|
|
663
715
|
3. **Queue / Delegate** — The Mesh uses an autonomous pull-based Work Queue:
|
|
664
716
|
a. **General Tasks**: Enqueue tasks using \`mesh_enqueue_task\`. Idle node agents will automatically pull tasks from the queue and begin working.
|
|
665
717
|
b. **Node Preparation**: Reuse an existing idle session on the correct node/provider before launching a new chat/session. Call \`mesh_launch_session\` only when no suitable session exists, when the user explicitly asks for a fresh provider/session, or when branch/worktree isolation requires it. If you need branch isolation for parallel work, call \`mesh_clone_node\` to create a worktree node first.
|
|
718
|
+
b1. **Keep a branch's work on its worktree (worktree affinity).** A worktree node is a durable per-branch workspace, not a one-task throwaway — implement, review, and fix for the same branch all belong on the SAME worktree, and it lives until its work is converged (merged/pushed) and it is cleaned up. So once you clone a worktree for a branch, route every subsequent \`code_change\`/\`validation\`/fix task for that branch back to that same node: pass \`required_tags: ["worktree=<branch>"]\` or \`target_node_id: <that worktree node's id>\`. **Where to get the node id / tag:** the \`mesh_clone_node\` result returns the new node's \`id\` and \`worktreeBranch\` directly — use them immediately. The Configured Nodes list in this prompt is a launch-time snapshot and will NOT list a worktree you cloned after this session started, so do not rely on it for freshly-cloned worktrees; take the id/branch from the \`mesh_clone_node\` result, or call \`mesh_status\` to re-list the live nodes (each worktree there advertises its \`worktree=<branch>\` tag). Do NOT leave same-branch follow-ups untargeted — an untargeted task is claimed by whichever node polls first (usually the base machine node), which strands the work off the branch's worktree. The ONE exception is a \`convergence\` task (merge/push): that is base-only and must NOT be pinned to the worktree.
|
|
666
719
|
c. **Targeted Tasks**: Use \`mesh_send_task\` only when you need to bypass the queue and force a specific node to execute a task immediately.
|
|
667
720
|
d. For the first dispatch of a new task, provide a **complete, self-contained** instruction that includes all context the agent needs (file paths, line numbers, what to change, why). Do not send partial instructions expecting future follow-up.
|
|
668
721
|
e. For a continuation of the same issue in an existing session, send a concise **delta instruction**: current verified state, the exact failed/blocked step, the newly approved action, and final reporting requirements. Do not resend the full original task or open a new chat solely to continue the same work; that wastes coordinator and worker context.
|
|
@@ -718,6 +771,8 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
718
771
|
- **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.
|
|
719
772
|
- **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\`.
|
|
720
773
|
- **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.
|
|
774
|
+
- **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.
|
|
775
|
+
- **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.
|
|
721
776
|
- **Respect explicit provider requests.** Map: Hermes → \`hermes-cli\`, Claude/Claude Code → \`claude-cli\`, Codex → \`codex-cli\`, Gemini → \`gemini-cli\`, Antigravity → \`antigravity-cli\`. Never substitute the coordinator's own runtime.
|
|
722
777
|
- **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
|
|
723
778
|
- **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).
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
coordinatorIdentityFromEmitFields,
|
|
15
15
|
coordinatorIdentityKey,
|
|
16
16
|
isMeshEventScope,
|
|
17
|
+
isTerminalTaskEvent,
|
|
17
18
|
MESH_PROTOCOL_VERSION_V2,
|
|
18
19
|
shouldDeliverPendingEventToCoordinator,
|
|
19
20
|
type CoordinatorIdentity,
|
|
@@ -74,6 +75,18 @@ export interface PendingMeshCoordinatorEvent {
|
|
|
74
75
|
dispatchedBy?: CoordinatorIdentity;
|
|
75
76
|
/** Present only for unicast scope: the coordinator this event is addressed to. */
|
|
76
77
|
intendedFor?: CoordinatorIdentity;
|
|
78
|
+
/**
|
|
79
|
+
* True when this event was stamped as a broadcast SOLELY because no owning
|
|
80
|
+
* coordinator identity was resolvable at emit time (self-fallback: dispatchedBy
|
|
81
|
+
* is THIS daemon's own machineId, not a real coordinator). Such a broadcast has
|
|
82
|
+
* no owner, so the MAGI-REPLICA-COMPLETION-EVENT-LEAK guard — which only exists
|
|
83
|
+
* to stop a NON-owner coordinator from consuming an OWNED terminal event — must
|
|
84
|
+
* not apply: an ownerless terminal broadcast is a genuine "deliver to any
|
|
85
|
+
* coordinator that drains on this machine" event and identity-matching its
|
|
86
|
+
* self-id dispatchedBy against the drainer would wrongly route it away.
|
|
87
|
+
* Absent (undefined/false) on a normally-owned event → the leak guard applies.
|
|
88
|
+
*/
|
|
89
|
+
dispatchedBySelfFallback?: boolean;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
/**
|
|
@@ -408,6 +421,39 @@ function routeV2EventsForDrainer(
|
|
|
408
421
|
// Broadcast → any coordinator; system → daemon handler only (never a
|
|
409
422
|
// coordinator). Delegates to the contract helper for those two scopes.
|
|
410
423
|
if (validated.scope !== 'unicast') {
|
|
424
|
+
// Defense-in-depth (MAGI-REPLICA-COMPLETION-EVENT-LEAK): a TERMINAL task
|
|
425
|
+
// event that reached the queue as broadcast is an ownership leak — a
|
|
426
|
+
// completion/stop belongs to the coordinator that dispatched the task, so
|
|
427
|
+
// a sibling coordinator that never dispatched it must NOT act on it. The
|
|
428
|
+
// emit-side stamp now narrows unaddressed terminal events to unicast, but a
|
|
429
|
+
// legacy/version-skewed/other-path broadcast can still arrive here; filter
|
|
430
|
+
// it by dispatchedBy vs the drainer using the SAME daemon-form/session
|
|
431
|
+
// matching semantics as unicast (identityDeliversTo), so the true owner —
|
|
432
|
+
// possibly addressed under a different daemon-id form — still receives it.
|
|
433
|
+
if (validated.scope === 'broadcast' && isTerminalTaskEvent(validated.event)) {
|
|
434
|
+
// An ownerless self-fallback broadcast (dispatchedBy is this daemon's
|
|
435
|
+
// own machineId because no coordinator identity existed at emit) has no
|
|
436
|
+
// coordinator owner — but it must still stay on ITS machine: a replica
|
|
437
|
+
// completion emitted on machine A must never fan out to a coordinator on
|
|
438
|
+
// machine B (the MAGI-REPLICA leak). So for a self-fallback event, match
|
|
439
|
+
// at the MACHINE (daemonId) level — deliver iff the drainer is on the
|
|
440
|
+
// same machine as the self-dispatcher — instead of the full
|
|
441
|
+
// identityDeliversTo (which also compares runId/session and would route
|
|
442
|
+
// the event away from a same-machine coordinator whose id form differs,
|
|
443
|
+
// the exact symptom for refine:* / agent:generating_completed reaching a
|
|
444
|
+
// stdio MCP coordinator). Non-self-fallback broadcasts keep the strict
|
|
445
|
+
// owner check.
|
|
446
|
+
const deliverSelfFallback = event.dispatchedBySelfFallback
|
|
447
|
+
&& daemonIdsEquivalent(validated.dispatchedBy.daemonId, drainer.daemonId);
|
|
448
|
+
if (deliverSelfFallback || identityDeliversTo(validated.dispatchedBy, drainer)) {
|
|
449
|
+
ctx.batchSeen.add(eventId);
|
|
450
|
+
bump('v2Delivered');
|
|
451
|
+
kept.push(event);
|
|
452
|
+
} else {
|
|
453
|
+
bump('v2RoutedAway');
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
411
457
|
if (shouldDeliverPendingEventToCoordinator(validated, drainer)) {
|
|
412
458
|
ctx.batchSeen.add(eventId);
|
|
413
459
|
bump('v2Delivered');
|
|
@@ -838,6 +884,13 @@ export function stampPendingEventV2(
|
|
|
838
884
|
});
|
|
839
885
|
if (!stamp) return event; // no coordinator identity at all (no self id) → stays a v1 event
|
|
840
886
|
|
|
887
|
+
// Mark an ownerless (self-fallback) broadcast so the drain-side leak guard can
|
|
888
|
+
// tell it apart from a genuinely owned broadcast terminal event. selfFallback is
|
|
889
|
+
// true only when no coordinator identity existed and we minted the stamp under
|
|
890
|
+
// this daemon's own machineId; a broadcast that stays broadcast for that reason
|
|
891
|
+
// has no owner to leak from and must reach whatever coordinator drains here.
|
|
892
|
+
const dispatchedBySelfFallback = selfFallback && stamp.scope === 'broadcast';
|
|
893
|
+
|
|
841
894
|
return {
|
|
842
895
|
...event,
|
|
843
896
|
protocolVersion: stamp.protocolVersion,
|
|
@@ -845,6 +898,7 @@ export function stampPendingEventV2(
|
|
|
845
898
|
scope: stamp.scope,
|
|
846
899
|
dispatchedBy: stamp.dispatchedBy,
|
|
847
900
|
...(stamp.intendedFor ? { intendedFor: stamp.intendedFor } : {}),
|
|
901
|
+
...(dispatchedBySelfFallback ? { dispatchedBySelfFallback: true } : {}),
|
|
848
902
|
};
|
|
849
903
|
}
|
|
850
904
|
|
|
@@ -76,17 +76,34 @@ export function getMeshWithCache(components: DaemonComponents, meshId: string):
|
|
|
76
76
|
*
|
|
77
77
|
* Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
|
|
78
78
|
* view matches the command (send_task) view. Base (non-worktree) nodes present in
|
|
79
|
-
* local config stay config-authoritative — their
|
|
80
|
-
* localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
|
|
79
|
+
* local config stay config-authoritative — their STATIC fields are taken verbatim
|
|
80
|
+
* from localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
|
|
81
81
|
* that exist solely in the inline cache (the cloned worktree nodes) are appended.
|
|
82
82
|
* Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
|
|
83
83
|
* identical to every other claim-path consumer — the matching logic is untouched,
|
|
84
84
|
* only which nodes are visible.
|
|
85
|
+
*
|
|
86
|
+
* BOOTSTRAP-DEFER VIEW-CONSISTENCY (this fix): for a worktree node that IS registered
|
|
87
|
+
* in local config, the union previously took the config node verbatim and discarded the
|
|
88
|
+
* inline-cache entry entirely. But the inline cache holds the FRESHER runtime bootstrap
|
|
89
|
+
* state — markWorktreeBootstrapTerminalState stamps worktreeBootstrap.status='complete'
|
|
90
|
+
* synchronously into the inline cache, while local config lags behind the detached async
|
|
91
|
+
* persist chain (and on the coordinator may never receive it at all). A config-registered
|
|
92
|
+
* worktree node therefore read a permanently stale 'running' here, so
|
|
93
|
+
* shouldDeferDispatchForBootstrap deferred its claim forever. We now MERGE the inline
|
|
94
|
+
* cache's dynamic runtime bootstrap state onto the config node (config keeps its static
|
|
95
|
+
* fields; worktreeBootstrap is preferred from the inline cache when the inline entry
|
|
96
|
+
* carries a status) so the gate view sees the terminal stamp. Regression-safe: when the
|
|
97
|
+
* inline entry has no bootstrap status the config value is kept, and when bootstrap is
|
|
98
|
+
* genuinely still 'running' (no terminal stamp yet) the gate still defers — only a node
|
|
99
|
+
* whose inline stamp has actually reached a terminal state opens the gate.
|
|
85
100
|
*/
|
|
86
101
|
function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
|
|
87
102
|
const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
|
|
88
103
|
const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
|
|
89
104
|
if (!cachedNodes.length) return localMesh;
|
|
105
|
+
// Index inline-cache nodes by identity so we can (a) append cache-only nodes and
|
|
106
|
+
// (b) prefer the inline runtime bootstrap state on config-registered nodes.
|
|
90
107
|
const cacheOnly = cachedNodes.filter((cachedNode: any) => {
|
|
91
108
|
const cachedId = readMeshNodeId(cachedNode);
|
|
92
109
|
// Unidentifiable cache entries can never be a claim/route target — skip them
|
|
@@ -94,8 +111,29 @@ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
|
|
|
94
111
|
if (!cachedId) return false;
|
|
95
112
|
return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
|
|
96
113
|
});
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
// Overlay the inline cache's fresher worktreeBootstrap state onto any config node
|
|
115
|
+
// that also exists in the inline cache. Only override when the inline entry actually
|
|
116
|
+
// carries a bootstrap status (an incomplete inline entry never masks a genuine config
|
|
117
|
+
// 'running'), mirroring the inline-first read the bootstrap gate does directly.
|
|
118
|
+
let overlaidLocalNodes: any[] = localNodes;
|
|
119
|
+
let overlaid = false;
|
|
120
|
+
for (let i = 0; i < localNodes.length; i++) {
|
|
121
|
+
const localNode = localNodes[i];
|
|
122
|
+
const localId = readMeshNodeId(localNode);
|
|
123
|
+
if (!localId) continue;
|
|
124
|
+
const inlineMatch = cachedNodes.find((cachedNode: any) => meshNodeIdMatches(cachedNode, localId));
|
|
125
|
+
const inlineBootstrapStatus = readNonEmptyString(inlineMatch?.worktreeBootstrap?.status);
|
|
126
|
+
if (!inlineMatch || !inlineBootstrapStatus) continue;
|
|
127
|
+
if (!overlaid) {
|
|
128
|
+
overlaidLocalNodes = [...localNodes];
|
|
129
|
+
overlaid = true;
|
|
130
|
+
}
|
|
131
|
+
// Keep the config node's static fields; prefer the inline cache's dynamic
|
|
132
|
+
// bootstrap runtime state (fresher terminal stamp).
|
|
133
|
+
overlaidLocalNodes[i] = { ...localNode, worktreeBootstrap: inlineMatch.worktreeBootstrap };
|
|
134
|
+
}
|
|
135
|
+
if (!cacheOnly.length && !overlaid) return localMesh;
|
|
136
|
+
return { ...localMesh, nodes: [...overlaidLocalNodes, ...cacheOnly] };
|
|
99
137
|
}
|
|
100
138
|
|
|
101
139
|
// ---------------------------------------------------------------------------
|
|
@@ -1880,6 +1918,8 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1880
1918
|
// MAGI-KIND-PANEL model axis: forward the task's model override so the
|
|
1881
1919
|
// remote worker session launches with it (initialModel). Best-effort.
|
|
1882
1920
|
...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
|
|
1921
|
+
// BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
|
|
1922
|
+
...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
|
|
1883
1923
|
});
|
|
1884
1924
|
} catch (e: any) {
|
|
1885
1925
|
markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
|
|
@@ -1912,6 +1952,8 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1912
1952
|
// MAGI-KIND-PANEL model axis: local launch forwards the task's model
|
|
1913
1953
|
// override as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
|
|
1914
1954
|
...(typeof task.model === 'string' && task.model.trim() ? { initialModel: task.model.trim() } : {}),
|
|
1955
|
+
// BRAIN-ROUTING thinking axis: forward the task's thinking level (initialThinkingLevel).
|
|
1956
|
+
...(typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim() ? { initialThinkingLevel: task.thinkingLevel.trim() } : {}),
|
|
1915
1957
|
});
|
|
1916
1958
|
if (!launchResult?.success) {
|
|
1917
1959
|
const reason = launchResult?.error || 'launch_cli_failed';
|
|
@@ -679,6 +679,19 @@ const ASSIGNED_STRANDED_DEADLINE_MS = 5 * 60_000;
|
|
|
679
679
|
// reclaimed out from under itself.
|
|
680
680
|
const DELIVERED_NO_TURN_DEADLINE_MS = 15 * 60_000;
|
|
681
681
|
|
|
682
|
+
// DELIVERED-NOT-CONSUMED (remote autoLaunch delivered≠consumed gap): how long a row may sit
|
|
683
|
+
// 'assigned' with a CONFIRMED delivery ('delivered') that was never CONSUMED ('acked' — the
|
|
684
|
+
// worker's agent:generating_started never arrived) before the watchdog re-drives it. Far shorter
|
|
685
|
+
// than DELIVERED_NO_TURN_DEADLINE_MS (15min): a remote autoLaunch marks markAutoLaunch(completed)
|
|
686
|
+
// and returns immediately, relying on agent:ready/reconcile to inject; if the launch→ready→claim
|
|
687
|
+
// window (widened on win32 by the 3–4s git spawn latency) drops the inject, the row sits 'assigned'
|
|
688
|
+
// but the delivery never flips past 'delivered' to 'acked'. The delivered-not-acked state is the
|
|
689
|
+
// cross-daemon consumption signal — positive evidence the worker never started the turn — so we can
|
|
690
|
+
// safely re-open the task after a SHORT grace (well above a normal generating_started round-trip so
|
|
691
|
+
// a merely-slow start is never torn off) instead of waiting the full 15min turn budget. Floored
|
|
692
|
+
// comfortably above the auto-launch cooldown so a legitimate late inject still has room to land.
|
|
693
|
+
const ASSIGNED_DELIVERED_UNCONSUMED_REDRIVE_MS = 25_000;
|
|
694
|
+
|
|
682
695
|
// RECLAIM-FALSEPOS: how many CONSECUTIVE UNKNOWN busy-verdict ticks (past the delivered-no-turn
|
|
683
696
|
// deadline) must accumulate before a delivered row whose worker session cannot be positively
|
|
684
697
|
// observed is reclaimed. An UNKNOWN verdict means the assigned session is not present in THIS
|
|
@@ -728,7 +741,62 @@ function recoverStrandedAssignedDispatches(components: DaemonComponents, meshId:
|
|
|
728
741
|
for (const row of assigned) {
|
|
729
742
|
const dispatchedAtMs = Date.parse(row.dispatchTimestamp ?? '');
|
|
730
743
|
if (!Number.isFinite(dispatchedAtMs)) continue; // no dispatch ts → can't age it
|
|
731
|
-
|
|
744
|
+
const ageMs = nowMs - dispatchedAtMs;
|
|
745
|
+
// DELIVERED-NOT-CONSUMED short-grace re-drive (remote autoLaunch delivered≠consumed gap).
|
|
746
|
+
// Runs BEFORE the ASSIGNED_STRANDED_DEADLINE_MS confirm-window gate below because its whole
|
|
747
|
+
// point is to recover a delivered-but-unconsumed row well inside that window. A remote
|
|
748
|
+
// autoLaunch marks the dispatch delivered (transport acked) but the worker may never emit
|
|
749
|
+
// agent:generating_started — the delivery then sits 'delivered' and never flips to 'acked',
|
|
750
|
+
// so the task is stranded 'assigned' with no live turn. This branch re-opens exactly that
|
|
751
|
+
// row after a short grace:
|
|
752
|
+
// - the delivery IS confirmed handed off (taskHasConfirmedDelivery) but was NEVER consumed
|
|
753
|
+
// (!taskDeliveryConsumed → no 'acked'/'completed' delivery) — the cross-daemon "worker
|
|
754
|
+
// never started the turn" signal, valid even for a REMOTE session whose local busy
|
|
755
|
+
// verdict is UNKNOWN;
|
|
756
|
+
// - AND the busy verdict is NOT GENERATING — a locally-present generating session IS
|
|
757
|
+
// consuming (ack lost/late), so never touch it (regression guard against tearing a live
|
|
758
|
+
// worker off its turn);
|
|
759
|
+
// - AND no terminal ledger evidence exists (the completion already landed → leave it).
|
|
760
|
+
// reclaimStrandedAssignedTask returns the row to 'pending' (bounded by MAX_STRANDED_RECLAIMS)
|
|
761
|
+
// so PHASE 3 re-dispatches it this same tick onto a fresh idle session — idempotent: it only
|
|
762
|
+
// mutates a still-'assigned' row, so a completion/ack that raced in already moved the row off
|
|
763
|
+
// 'assigned' and this is a no-op.
|
|
764
|
+
if (
|
|
765
|
+
ageMs >= ASSIGNED_DELIVERED_UNCONSUMED_REDRIVE_MS
|
|
766
|
+
&& ageMs < ASSIGNED_STRANDED_DEADLINE_MS
|
|
767
|
+
&& store.taskHasConfirmedDelivery(meshId, row.id)
|
|
768
|
+
&& !store.taskDeliveryConsumed(meshId, row.id)
|
|
769
|
+
) {
|
|
770
|
+
const terminal = findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id });
|
|
771
|
+
if (terminal) {
|
|
772
|
+
const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
|
|
773
|
+
updateTaskStatus(meshId, row.id, status);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const verdict = row.assignedSessionId
|
|
777
|
+
? resolveSessionBusyVerdict(components, row.assignedSessionId)
|
|
778
|
+
: 'IDLE_CONFIRMED'; // no session bound → nothing live generating to protect
|
|
779
|
+
if (verdict !== 'GENERATING') {
|
|
780
|
+
const redriven = reclaimStrandedAssignedTask(meshId, row.id, {
|
|
781
|
+
reason: 'delivered_not_consumed_redrive',
|
|
782
|
+
ageMs,
|
|
783
|
+
});
|
|
784
|
+
if (redriven) {
|
|
785
|
+
LOG.warn('MeshReconcile', `Re-drove delivered-but-unconsumed task ${row.id} on mesh ${meshId} `
|
|
786
|
+
+ `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, delivered but no `
|
|
787
|
+
+ `generating_started in ${Math.round(ageMs / 1000)}s, verdict ${verdict} → ${redriven.status})`);
|
|
788
|
+
traceMeshEventDrop('assigned_delivered_not_consumed_redrive', {
|
|
789
|
+
taskId: row.id,
|
|
790
|
+
sessionId: row.assignedSessionId,
|
|
791
|
+
nodeId: row.assignedNodeId,
|
|
792
|
+
meshId,
|
|
793
|
+
event: 'agent:generating_started',
|
|
794
|
+
}, `delivered_not_consumed ${Math.round(ageMs / 1000)}s → ${redriven.status}`);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (ageMs < ASSIGNED_STRANDED_DEADLINE_MS) continue; // still in confirm window
|
|
732
800
|
const terminal = findTerminalLedgerEvidenceForTask({
|
|
733
801
|
meshId,
|
|
734
802
|
taskId: row.id,
|
|
@@ -1488,6 +1488,28 @@ export class MeshRuntimeStore {
|
|
|
1488
1488
|
return !!row;
|
|
1489
1489
|
}
|
|
1490
1490
|
|
|
1491
|
+
/**
|
|
1492
|
+
* DELIVERED-NOT-CONSUMED re-drive support: true when at least one delivery record for
|
|
1493
|
+
* the task has reached a CONSUMED status ('acked' / 'completed'). Distinct from
|
|
1494
|
+
* {@link taskHasConfirmedDelivery} ('delivered' | 'acked' | 'completed'): a delivery is
|
|
1495
|
+
* flipped to 'delivered' the instant the transport hands the dispatch off, but only
|
|
1496
|
+
* flipped to 'acked' when the worker's agent:generating_started event arrives (see the
|
|
1497
|
+
* generating_started handler in mesh-event-forwarding) — i.e. when the session has
|
|
1498
|
+
* actually begun the turn. That distinction is the cross-daemon consumption signal the
|
|
1499
|
+
* short-grace re-drive uses: a row whose delivery is 'delivered' but never 'acked' was
|
|
1500
|
+
* handed to a REMOTE worker that never started generating — the remote autoLaunch
|
|
1501
|
+
* delivered≠consumed gap — even when the session's busy verdict is UNKNOWN (not locally
|
|
1502
|
+
* observable). Indexed by (mesh_id, task_id).
|
|
1503
|
+
*/
|
|
1504
|
+
taskDeliveryConsumed(meshId: string, taskId: string): boolean {
|
|
1505
|
+
const row = this.db.prepare(`
|
|
1506
|
+
SELECT 1 FROM mesh_session_delivery
|
|
1507
|
+
WHERE mesh_id = ? AND task_id = ? AND status IN ('acked','completed')
|
|
1508
|
+
LIMIT 1
|
|
1509
|
+
`).get(meshId, taskId) as { 1: number } | undefined;
|
|
1510
|
+
return !!row;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1491
1513
|
expireStaleSessionDeliveries(meshId: string): void {
|
|
1492
1514
|
const now = new Date().toISOString();
|
|
1493
1515
|
this.db.prepare(`
|
|
@@ -3,13 +3,13 @@ import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
|
3
3
|
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
4
4
|
import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
|
|
5
5
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
6
|
-
import { getMesh } from '../config/mesh-config.js';
|
|
6
|
+
import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
|
|
7
7
|
import { LOG } from '../logging/logger.js';
|
|
8
8
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
9
9
|
import type { MeshLedgerKind } from './mesh-ledger.js';
|
|
10
10
|
import { createSessionDelivery } from './mesh-delivery-policy.js';
|
|
11
11
|
import { isTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
|
|
12
|
-
import { sessionIdsEquivalent } from '@adhdev/mesh-shared';
|
|
12
|
+
import { sessionIdsEquivalent, isMeshTaskDifficulty, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
|
|
13
13
|
|
|
14
14
|
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
15
15
|
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
@@ -584,6 +584,13 @@ export interface MeshWorkQueueEntry {
|
|
|
584
584
|
* that cannot honor the model still runs the task (never a fatal launch error).
|
|
585
585
|
*/
|
|
586
586
|
model?: string;
|
|
587
|
+
/**
|
|
588
|
+
* BRAIN-ROUTING (thinking axis): standard reasoning level ('low'|'medium'|'high')
|
|
589
|
+
* for the session that executes this task. When the task auto-launches, this is
|
|
590
|
+
* passed to launch_cli as `initialThinkingLevel` (CLI → thinkingLaunchArgs; ACP →
|
|
591
|
+
* setConfigOption('thought_level')). Rides in payload JSON. Best-effort like model.
|
|
592
|
+
*/
|
|
593
|
+
thinkingLevel?: string;
|
|
587
594
|
/**
|
|
588
595
|
* M1: why this task is held back (e.g. "dependency_failed:<taskId>").
|
|
589
596
|
* Only set by the system on dependency failure under the 'block' policy;
|
|
@@ -862,6 +869,16 @@ export function enqueueTask(
|
|
|
862
869
|
consensusGroupId?: string;
|
|
863
870
|
/** MAGI-KIND-PANEL: model override forwarded to the executing session's launch (initialModel). */
|
|
864
871
|
model?: string;
|
|
872
|
+
/** BRAIN-ROUTING: standard thinking level forwarded to launch (initialThinkingLevel). */
|
|
873
|
+
thinkingLevel?: string;
|
|
874
|
+
/**
|
|
875
|
+
* BRAIN-ROUTING: task execution difficulty ('easy'|'medium'|'difficult'|
|
|
876
|
+
* 'freeform'). When set, the mesh's difficulty→brain preset fills in model /
|
|
877
|
+
* thinkingLevel that were not passed explicitly (an explicit model/thinkingLevel
|
|
878
|
+
* wins). Purely a convenience resolver — the stored task still carries the
|
|
879
|
+
* resolved model/thinkingLevel, so downstream launch is unchanged.
|
|
880
|
+
*/
|
|
881
|
+
difficulty?: string;
|
|
865
882
|
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
866
883
|
id?: string;
|
|
867
884
|
/** (3) Originating coordinator session id (for session-anchored completion routing). */
|
|
@@ -881,6 +898,21 @@ export function enqueueTask(
|
|
|
881
898
|
const maxRetries = typeof opts?.maxRetries === 'number' && Number.isFinite(opts.maxRetries) && opts.maxRetries >= 0
|
|
882
899
|
? Math.floor(opts.maxRetries)
|
|
883
900
|
: undefined;
|
|
901
|
+
// BRAIN-ROUTING: resolve the difficulty preset into effective model / thinking
|
|
902
|
+
// level. An explicit opts.model / opts.thinkingLevel always wins; the preset only
|
|
903
|
+
// fills what the caller left blank. Best-effort — a missing/invalid difficulty or
|
|
904
|
+
// an unconfigured preset just leaves the explicit values (or none) in place.
|
|
905
|
+
let effectiveModel = typeof opts?.model === 'string' && opts.model.trim() ? opts.model.trim() : undefined;
|
|
906
|
+
let effectiveThinkingLevel = typeof opts?.thinkingLevel === 'string' && opts.thinkingLevel.trim() ? opts.thinkingLevel.trim() : undefined;
|
|
907
|
+
if (isMeshTaskDifficulty(opts?.difficulty)) {
|
|
908
|
+
try {
|
|
909
|
+
const preset = getDifficultyBrains()[opts!.difficulty as MeshTaskDifficulty];
|
|
910
|
+
if (preset) {
|
|
911
|
+
if (!effectiveModel && preset.model) effectiveModel = preset.model;
|
|
912
|
+
if (!effectiveThinkingLevel && preset.thinkingLevel) effectiveThinkingLevel = preset.thinkingLevel;
|
|
913
|
+
}
|
|
914
|
+
} catch { /* preset read is best-effort — never block enqueue */ }
|
|
915
|
+
}
|
|
884
916
|
const result = withQueueLock(meshId, () => {
|
|
885
917
|
if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
|
|
886
918
|
throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
|
|
@@ -917,7 +949,8 @@ export function enqueueTask(
|
|
|
917
949
|
...(maxRetries !== undefined ? { maxRetries } : {}),
|
|
918
950
|
...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
|
|
919
951
|
...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
|
|
920
|
-
...(
|
|
952
|
+
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
953
|
+
...(effectiveThinkingLevel ? { thinkingLevel: effectiveThinkingLevel } : {}),
|
|
921
954
|
...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
|
|
922
955
|
? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
|
|
923
956
|
: {}),
|
|
@@ -312,6 +312,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
312
312
|
private presentationMode: 'terminal' | 'chat';
|
|
313
313
|
private providerSessionId?: string;
|
|
314
314
|
private launchMode: 'new' | 'resume' | 'manual';
|
|
315
|
+
private initialThinkingLevel?: string;
|
|
315
316
|
private readonly startedAt = Date.now();
|
|
316
317
|
private onProviderSessionResolved?: (info: {
|
|
317
318
|
instanceId: string;
|
|
@@ -332,6 +333,10 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
332
333
|
providerSessionId?: string;
|
|
333
334
|
launchMode?: 'new' | 'resume' | 'manual';
|
|
334
335
|
extraEnv?: Record<string, string>;
|
|
336
|
+
/** BRAIN-ROUTING: standard thinking level to apply post-launch via the
|
|
337
|
+
* provider's thinkingControlId (runtime-control providers like hermes).
|
|
338
|
+
* Providers using thinkingLaunchArgs get it at spawn instead and ignore this. */
|
|
339
|
+
initialThinkingLevel?: string;
|
|
335
340
|
onProviderSessionResolved?: (info: {
|
|
336
341
|
instanceId: string;
|
|
337
342
|
providerType: string;
|
|
@@ -347,6 +352,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
347
352
|
this.presentationMode = 'chat';
|
|
348
353
|
this.providerSessionId = options?.providerSessionId;
|
|
349
354
|
this.launchMode = options?.launchMode || 'new';
|
|
355
|
+
this.initialThinkingLevel = options?.initialThinkingLevel;
|
|
350
356
|
this.onProviderSessionResolved = options?.onProviderSessionResolved;
|
|
351
357
|
this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
|
|
352
358
|
if (this.providerSessionId) {
|
|
@@ -392,6 +398,7 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
392
398
|
// PTY spawn
|
|
393
399
|
await this.adapter.spawn();
|
|
394
400
|
await this.enforceFreshSessionLaunchIfNeeded();
|
|
401
|
+
await this.applyInitialThinkingLevelViaControl();
|
|
395
402
|
this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
|
|
396
403
|
if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
|
|
397
404
|
this.restorePersistedHistoryFromCurrentSession();
|
|
@@ -1209,6 +1216,45 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1209
1216
|
this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
|
|
1210
1217
|
}
|
|
1211
1218
|
|
|
1219
|
+
/**
|
|
1220
|
+
* BRAIN-ROUTING (runtime-control thinking axis): for a provider that selects
|
|
1221
|
+
* reasoning effort via a runtime control instead of a launch arg (e.g. hermes
|
|
1222
|
+
* `reasoning`), apply the requested initialThinkingLevel after spawn by invoking
|
|
1223
|
+
* that control's setScript. The provider names the control via thinkingControlId.
|
|
1224
|
+
* The standard level is mapped through thinkingLevelMap first (same as the
|
|
1225
|
+
* launch-arg path). Best-effort: any failure logs and never blocks launch.
|
|
1226
|
+
*/
|
|
1227
|
+
private async applyInitialThinkingLevelViaControl(): Promise<void> {
|
|
1228
|
+
const level = typeof this.initialThinkingLevel === 'string' ? this.initialThinkingLevel.trim() : '';
|
|
1229
|
+
if (!level) return;
|
|
1230
|
+
const controlId = (this.provider as any).thinkingControlId;
|
|
1231
|
+
if (!controlId) return; // provider uses thinkingLaunchArgs (or has no support)
|
|
1232
|
+
const controls: any[] = Array.isArray((this.provider as any).controls) ? (this.provider as any).controls : [];
|
|
1233
|
+
const control = controls.find(c => c && c.id === controlId);
|
|
1234
|
+
if (!control || !control.setScript) return;
|
|
1235
|
+
// Map the standard level to the provider's own vocabulary (unchanged if absent).
|
|
1236
|
+
const map = (this.provider as any).thinkingLevelMap as Record<string, string> | undefined;
|
|
1237
|
+
const mapped = (map && typeof map[level] === 'string' && map[level].trim()) ? map[level].trim() : level;
|
|
1238
|
+
try {
|
|
1239
|
+
await waitForCliAdapterReady(this.adapter);
|
|
1240
|
+
const raw = await this.adapter.invokeScript(control.setScript, { value: mapped });
|
|
1241
|
+
const parsed = parseCliScriptResult(raw);
|
|
1242
|
+
if (!parsed.success) {
|
|
1243
|
+
LOG.warn('CLI', `[${this.type}] thinking control '${controlId}' set to '${mapped}' failed: ${parsed.payload?.error || 'unknown'}`);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const cliCommand = getCliScriptCommand(parsed.payload);
|
|
1247
|
+
if (cliCommand?.type === 'send_message' && cliCommand.text) {
|
|
1248
|
+
await this.adapter.sendMessage(cliCommand.text);
|
|
1249
|
+
} else if (cliCommand?.type === 'pty_write' && cliCommand.text) {
|
|
1250
|
+
await this.adapter.writeRaw(cliCommand.text + '\r');
|
|
1251
|
+
}
|
|
1252
|
+
LOG.info('CLI', `[${this.type}] applied thinking level '${mapped}' via control '${controlId}'`);
|
|
1253
|
+
} catch (e: any) {
|
|
1254
|
+
LOG.warn('CLI', `[${this.type}] thinking control apply threw: ${e?.message || e}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1212
1258
|
private completionHasFinalAssistantMessage(messages: unknown, turnStartedAt?: number): boolean {
|
|
1213
1259
|
const visibleMessages = (Array.isArray(messages) ? messages : [])
|
|
1214
1260
|
.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
|