@adhdev/daemon-core 0.9.82-rc.382 → 0.9.82-rc.383
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/router.d.ts +11 -1
- package/dist/index.js +58 -19
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +58 -19
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/commands/router.ts +50 -17
- package/src/mesh/mesh-reconcile-loop.ts +74 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.383",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.383",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
package/src/commands/router.ts
CHANGED
|
@@ -491,25 +491,51 @@ export class DaemonCommandRouter {
|
|
|
491
491
|
* the wider plural-shape scan so a controlbar/modal command targeting a non-primary remote
|
|
492
492
|
* session still resolves its owner — the singular readCachedInlineMeshActiveSessions semantics
|
|
493
493
|
* other consumers depend on stay untouched.
|
|
494
|
+
*
|
|
495
|
+
* CANCEL-STOP-RELAY: the session-id cache scan above only matches when the coordinator's
|
|
496
|
+
* cached status snapshot already lists the worker's session id in a recognized active-sessions
|
|
497
|
+
* shape. A worktree-clone worker session whose id form/timing differs from the cached snapshot
|
|
498
|
+
* (or is simply not yet reflected) misses the scan, so a stop that carries the authoritative
|
|
499
|
+
* owning nodeId (mesh_queue_cancel knows assignedNodeId) used to silently fail to forward.
|
|
500
|
+
* `ownerNodeIdHint` adds a deterministic fallback: when the session-id scan misses, resolve the
|
|
501
|
+
* owner daemonId by matching the node by id (meshNodeIdMatches — same form-tolerant compare the
|
|
502
|
+
* rest of the router uses, no new raw compare). The same self-loopback guard applies to both
|
|
503
|
+
* paths, so a coordinator-hosted node is still never force-forwarded to a remote form of itself.
|
|
494
504
|
*/
|
|
495
|
-
public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string): string | undefined {
|
|
505
|
+
public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string, ownerNodeIdHint?: string): string | undefined {
|
|
496
506
|
const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
497
|
-
|
|
507
|
+
const nodeHint = typeof ownerNodeIdHint === 'string' ? ownerNodeIdHint.trim() : '';
|
|
508
|
+
if (!trimmed && !nodeHint) return undefined;
|
|
498
509
|
const selfDaemonId = this.deps.statusInstanceId;
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
510
|
+
const candidates = this.collectMeshSessionOwnerCandidateNodes();
|
|
511
|
+
if (trimmed) {
|
|
512
|
+
for (const node of candidates) {
|
|
513
|
+
if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
|
|
514
|
+
const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
|
|
515
|
+
// A matching node with no readable daemonId can't be attributed — keep scanning
|
|
516
|
+
// the remaining candidates (e.g. the same session on an aggregate node that does
|
|
517
|
+
// carry the daemonId) rather than bailing on the whole resolution.
|
|
518
|
+
if (!nodeDaemonId) continue;
|
|
519
|
+
// Only forward to a genuinely remote daemon. When the owning node is this
|
|
520
|
+
// coordinator itself (locally hosted worker), fall through to local handling.
|
|
521
|
+
// id-form robust: the node daemonId and selfDaemonId may be stored in different
|
|
522
|
+
// forms of the same machine — a strict `===` would miss the self-match and forward
|
|
523
|
+
// a local session to a remote form of THIS daemon (loopback).
|
|
524
|
+
if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
|
|
525
|
+
return nodeDaemonId;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// Deterministic fallback: the session-id scan missed (cache lag / id-form mismatch on a
|
|
529
|
+
// worktree-clone worker), but the caller knows the authoritative owning nodeId. Resolve the
|
|
530
|
+
// owner daemonId straight off that node — never the fuzzy session cache.
|
|
531
|
+
if (nodeHint) {
|
|
532
|
+
for (const node of candidates) {
|
|
533
|
+
if (!meshNodeIdMatches(node, nodeHint)) continue;
|
|
534
|
+
const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
|
|
535
|
+
if (!nodeDaemonId) continue;
|
|
536
|
+
if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
|
|
537
|
+
return nodeDaemonId;
|
|
538
|
+
}
|
|
513
539
|
}
|
|
514
540
|
return undefined;
|
|
515
541
|
}
|
|
@@ -3092,7 +3118,14 @@ export class DaemonCommandRouter {
|
|
|
3092
3118
|
const localInstance = this.deps.instanceManager?.getInstance(targetSessionId);
|
|
3093
3119
|
const localRegistry = this.deps.sessionRegistry?.get?.(targetSessionId);
|
|
3094
3120
|
if (!localInstance && !localRegistry) {
|
|
3095
|
-
|
|
3121
|
+
// CANCEL-STOP-RELAY: pass the authoritative owning nodeId (when the caller
|
|
3122
|
+
// shipped one in meshContext, e.g. mesh_queue_cancel's assignedNodeId) as the
|
|
3123
|
+
// deterministic owner-resolution fallback. The session-id cache scan stays the
|
|
3124
|
+
// primary path; the hint only kicks in when that scan misses (worktree-clone
|
|
3125
|
+
// worker session not yet in / form-mismatched against the cached snapshot).
|
|
3126
|
+
const meshContext = readObjectRecord(args?.meshContext);
|
|
3127
|
+
const ownerNodeIdHint = readStringValue(meshContext.nodeId);
|
|
3128
|
+
const ownerDaemonId = this.resolveRemoteMeshSessionOwnerDaemonId(targetSessionId, ownerNodeIdHint);
|
|
3096
3129
|
if (ownerDaemonId) {
|
|
3097
3130
|
LOG.info('Mesh', `[Mesh] Forwarding session-scoped '${cmd}' for remote worker session ${targetSessionId.split('_')[0]} → daemon ${ownerDaemonId.slice(0, 12)}`);
|
|
3098
3131
|
const forwarded = await this.deps.dispatchMeshCommand(ownerDaemonId, cmd, {
|
|
@@ -49,7 +49,7 @@ import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, bui
|
|
|
49
49
|
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
50
50
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
51
51
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
52
|
-
import { handleMeshForwardEvent, shouldForceInjectMeshEvent,
|
|
52
|
+
import { handleMeshForwardEvent, shouldForceInjectMeshEvent, triggerMeshQueue, resolveForwardEventMeshId } from './mesh-events-coordinator.js';
|
|
53
53
|
import {
|
|
54
54
|
peekUnresolvedDelegateForwards,
|
|
55
55
|
ackUnresolvedDelegateForward,
|
|
@@ -589,35 +589,53 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
589
589
|
for (const [meshId, meshCoordinators] of byMesh) {
|
|
590
590
|
// Drain the local queue scoped to this coordinator daemon and inject.
|
|
591
591
|
// - If an idle coordinator exists, FULL-drain and deliver every event to it
|
|
592
|
-
// (
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
592
|
+
// (the idle input box accepts the prompt as a real next turn). The drain
|
|
593
|
+
// marks consumed rows drained=1 atomically, so the pull path can't re-deliver.
|
|
594
|
+
// - If only GENERATING coordinators exist (no idle target), we HOLD: leave the
|
|
595
|
+
// events queued (drained=0) for the coordinator's next idle/turn-end tick.
|
|
596
|
+
//
|
|
597
|
+
// NOTIF-SURFACE-LOCAL (false-idle hold): we used to force-inject terminal events
|
|
598
|
+
// (completion/approval/stop/refine·bootstrap) straight into a *generating*
|
|
599
|
+
// coordinator's PTY (forceSendMessage → atomic content+\r write), on the theory it
|
|
600
|
+
// bypassed the busy send-guard and broke the await-result deadlock. But a raw PTY
|
|
601
|
+
// write into a claude-cli that is mid-generation is NOT consumed as a new turn — the
|
|
602
|
+
// bytes land in the terminal input buffer and the LLM never reads them on its next
|
|
603
|
+
// turn. The `surfaced/force-inject` trace fired, the row was marked drained=1, and the
|
|
604
|
+
// genuine completion was lost forever (the exact same-daemon local-worktree miss: the
|
|
605
|
+
// coordinator's OWN session is generating at the moment its worker completes). The
|
|
606
|
+
// deadlock the force path guarded against does not actually require force: a
|
|
607
|
+
// coordinator that dispatched a task via mesh_send_task returns to idle when that
|
|
608
|
+
// tool call resolves (dispatch is fire-and-forget; the worker runs for minutes while
|
|
609
|
+
// the coordinator is idle/between turns), so the completion lands on the very next
|
|
610
|
+
// idle tick (≤ one reconcile interval). Holding the event undrained for that idle
|
|
611
|
+
// tick is therefore the single, reliable delivery — and it is the SAME skip-and-hold
|
|
612
|
+
// the modal-park branch below already uses. This also makes double-injection
|
|
613
|
+
// structurally impossible: there is exactly one delivery path (the idle full-drain),
|
|
614
|
+
// so we never need a surface-time fingerprint to dedup a force-write against a re-drain.
|
|
599
615
|
const idleCoordinators = meshCoordinators.filter(c => c.idle);
|
|
600
|
-
// A coordinator parked on a harness modal (waiting_choice / waiting_approval)
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
// targets are the non-idle, non-modal-parked coordinators.
|
|
616
|
+
// A coordinator parked on a harness modal (waiting_choice / waiting_approval) is
|
|
617
|
+
// non-idle; it is held under the modal-park branch (a force-inject into a modal would
|
|
618
|
+
// write raw keystrokes the modal key handler eats, silently selecting a choice the
|
|
619
|
+
// user never made). A plainly-generating coordinator (non-idle, non-modal-parked) is
|
|
620
|
+
// ALSO held now — for the false-idle reason above — but separately, so the C1 ledger
|
|
621
|
+
// audit and the operator-facing skip log can name the right hold reason.
|
|
607
622
|
const generatingCoordinators = meshCoordinators.filter(c => !c.idle && !c.modalParked);
|
|
608
623
|
const modalParkedCoordinators = meshCoordinators.filter(c => !c.idle && c.modalParked);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
624
|
+
// Only an IDLE coordinator is a deliverable target. A generating coordinator's PTY
|
|
625
|
+
// does not consume an injected prompt as a turn, so it is held (not a target).
|
|
626
|
+
const targetCoordinators = idleCoordinators;
|
|
627
|
+
|
|
628
|
+
// ── no-idle-target short-circuit (MUST precede the drain) ─────────────────
|
|
629
|
+
// When there is no IDLE coordinator for this mesh — only generating and/or
|
|
630
|
+
// modal-parked ones — there is nowhere a queued event can land as a real turn.
|
|
631
|
+
// We skip-and-hold: by NOT draining we leave the events at drained=0 in the queue,
|
|
632
|
+
// so a later tick (once a coordinator returns to idle) delivers them. This
|
|
633
|
+
// short-circuit MUST run BEFORE drainPendingMeshCoordinatorEvents — the drain marks
|
|
634
|
+
// rows drained=1 atomically, which would lose the events for a coordinator that is
|
|
635
|
+
// only transiently busy (the false-idle local-worktree miss). Both the generating
|
|
636
|
+
// hold and the modal-park hold record a C1 ledger audit copy so a held completion's
|
|
637
|
+
// worker summary is recoverable even if the coordinator never returns or the pending
|
|
638
|
+
// file is later trimmed.
|
|
621
639
|
if (targetCoordinators.length === 0) {
|
|
622
640
|
if (modalParkedCoordinators.length > 0) {
|
|
623
641
|
// ── orphan escape (MUST precede the blanket modal-park hold) ──────────
|
|
@@ -705,6 +723,30 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
705
723
|
modalParkedCoordinators.length,
|
|
706
724
|
);
|
|
707
725
|
}
|
|
726
|
+
} else if (generatingCoordinators.length > 0) {
|
|
727
|
+
// ── generating hold (NOTIF-SURFACE-LOCAL false-idle fix) ─────────────
|
|
728
|
+
// The only coordinator(s) for this mesh are plainly generating (no idle, no
|
|
729
|
+
// modal). A raw force-write into a generating claude-cli PTY is not consumed
|
|
730
|
+
// as a turn, so we do NOT inject and do NOT drain — the events stay queued
|
|
731
|
+
// (drained=0) and the next tick that finds the coordinator idle full-drains
|
|
732
|
+
// them as real turns (the coordinator returns to idle when its current
|
|
733
|
+
// tool-call/turn resolves; a dispatched worker runs for minutes while the
|
|
734
|
+
// coordinator is idle, so this lands within one reconcile interval). C1: mirror
|
|
735
|
+
// any held terminal events into the ledger so a completion's worker summary is
|
|
736
|
+
// recoverable even before that idle tick. Idempotent per process; O(1)-gated.
|
|
737
|
+
let hasPending = true;
|
|
738
|
+
if (store) {
|
|
739
|
+
try { hasPending = store.pendingEventCount(meshId) > 0; } catch { /* peek below */ }
|
|
740
|
+
}
|
|
741
|
+
if (hasPending) {
|
|
742
|
+
LOG.info('MeshReconcile', `Reconcile skip → generating: holding pending event(s) for mesh ${meshId} (${generatingCoordinators.length} coordinator(s) busy; events left queued for the next idle tick)`);
|
|
743
|
+
recordHeldTerminalEventsToLedger(
|
|
744
|
+
meshId,
|
|
745
|
+
drainDaemonIds.length > 0 ? drainDaemonIds : (localDaemonId ? [localDaemonId] : []),
|
|
746
|
+
'generating_no_idle_coordinator',
|
|
747
|
+
generatingCoordinators.length,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
708
750
|
}
|
|
709
751
|
continue;
|
|
710
752
|
}
|
|
@@ -716,12 +758,15 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
716
758
|
} catch { /* fall through to drain */ }
|
|
717
759
|
}
|
|
718
760
|
|
|
761
|
+
// An idle coordinator is present (targetCoordinators.length > 0): FULL-drain every
|
|
762
|
+
// queued event and deliver it to the idle input box as a real turn. The no-idle case
|
|
763
|
+
// (generating/modal-only) was already held above and never reaches here, so there is
|
|
764
|
+
// no force-drain-into-generating path left — the single delivery is the idle drain.
|
|
719
765
|
let pendingEvents: PendingMeshCoordinatorEvent[] = [];
|
|
720
766
|
try {
|
|
721
767
|
pendingEvents = drainPendingMeshCoordinatorEvents(
|
|
722
768
|
meshId,
|
|
723
769
|
drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
|
|
724
|
-
forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
|
|
725
770
|
);
|
|
726
771
|
} catch (e: any) {
|
|
727
772
|
LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
|
|
@@ -729,8 +774,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
729
774
|
}
|
|
730
775
|
if (pendingEvents.length === 0) continue;
|
|
731
776
|
|
|
732
|
-
|
|
733
|
-
LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
|
|
777
|
+
LOG.info('MeshReconcile', `Reconcile inject → idle: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
|
|
734
778
|
for (const pending of pendingEvents) {
|
|
735
779
|
// Strict session routing (multi-coordinator): when the event names an
|
|
736
780
|
// originating coordinator session, deliver ONLY to the live coordinator whose
|