@adhdev/daemon-core 0.9.82-rc.430 → 0.9.82-rc.432
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/index.js +130 -35
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +130 -35
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +31 -0
- package/package.json +2 -2
- package/src/mesh/mesh-event-forwarding.ts +10 -1
- package/src/mesh/mesh-reconcile-loop.ts +171 -44
- package/src/providers/cli-provider-instance.ts +44 -0
|
@@ -231,6 +231,37 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
231
231
|
private isTransientToolConsent;
|
|
232
232
|
/** True when this session is parked on a modal awaiting a human answer. */
|
|
233
233
|
isModalParked(): boolean;
|
|
234
|
+
/**
|
|
235
|
+
* PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
|
|
236
|
+
* reconcile loop must consult — the RAW adapter turn-state, with the
|
|
237
|
+
* auto-approve "hold-idle" visual mask STRIPPED.
|
|
238
|
+
*
|
|
239
|
+
* getState().status overlays `autoApproveHoldIdle`/`autoApproveActive` to paint a
|
|
240
|
+
* genuinely-idle adapter as `generating` (a UI-flicker suppression while an
|
|
241
|
+
* auto-approve key-press settles — see getState() ~:800). That mask is correct
|
|
242
|
+
* for the dashboard, but the reconcile loop trusts it as "the coordinator is
|
|
243
|
+
* busy" and therefore HOLDS a worker's completion under
|
|
244
|
+
* `generating_no_idle_coordinator` even though the coordinator's PTY is at a real
|
|
245
|
+
* turn end and would accept the inject as a turn — the completion is stranded.
|
|
246
|
+
*
|
|
247
|
+
* This accessor reports the drain truth instead:
|
|
248
|
+
* - 'modal_parked' — a GENUINE human-await modal (AskUserQuestion / a non-
|
|
249
|
+
* transient tool-consent). Still excluded from drain (a force-inject here
|
|
250
|
+
* writes raw keystrokes the modal eats → data corruption). Mirrors
|
|
251
|
+
* isModalParked(), evaluated first so a parked session never reads idle.
|
|
252
|
+
* - 'idle' — the RAW adapter is at a turn end (adapter.getStatus(allowParse:false)
|
|
253
|
+
* === 'idle') and the session is not modal-parked. Drain-eligible REGARDLESS
|
|
254
|
+
* of the auto-approve mask. This is the case the mask used to hide.
|
|
255
|
+
* - 'generating' — the raw adapter is genuinely mid-turn. Held (a raw PTY write
|
|
256
|
+
* into a generating claude-cli is not consumed as a turn → data loss). The
|
|
257
|
+
* intentional removal of force-inject-into-generating is preserved.
|
|
258
|
+
* - 'other' — any other raw status (error / starting / waiting_choice handled by
|
|
259
|
+
* modal-park above). Not a drain target.
|
|
260
|
+
*
|
|
261
|
+
* Uses allowParse:false (engine.activeModal only, side-effect-free) so it never
|
|
262
|
+
* mutates the very auto-approve mask state the diagnostics read.
|
|
263
|
+
*/
|
|
264
|
+
getDrainStatus(): 'idle' | 'generating' | 'modal_parked' | 'other';
|
|
234
265
|
onEvent(event: string, data?: any): void;
|
|
235
266
|
recordAcknowledgedUserInput(input: InputEnvelope | string): void;
|
|
236
267
|
/** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
|
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.432",
|
|
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.432",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -1717,7 +1717,16 @@ export function flushPendingForMeshIdleCoordinators(components: DaemonComponents
|
|
|
1717
1717
|
const modalParked = typeof (inst as any).isModalParked === 'function'
|
|
1718
1718
|
? (inst as any).isModalParked() === true
|
|
1719
1719
|
: (status === 'waiting_choice' || status === 'waiting_approval');
|
|
1720
|
-
|
|
1720
|
+
// PTY-OVERTRUST-DRAIN (Defect B): decide idle on the RAW adapter turn-state
|
|
1721
|
+
// (getDrainStatus, mask-stripped) to match the reconcile loop — getState().status
|
|
1722
|
+
// overlays the auto-approve hold-idle mask that paints a genuinely-idle coordinator
|
|
1723
|
+
// `generating`, which would make this opportunistic flush skip a real drain target.
|
|
1724
|
+
// Fall back to the masked literal for any instance without getDrainStatus().
|
|
1725
|
+
const drainStatus: string | null = typeof (inst as any).getDrainStatus === 'function'
|
|
1726
|
+
? (inst as any).getDrainStatus()
|
|
1727
|
+
: null;
|
|
1728
|
+
const idle = drainStatus !== null ? drainStatus === 'idle' : (status === 'idle');
|
|
1729
|
+
if (idle && !modalParked) {
|
|
1721
1730
|
idleCoordinators.push({ instance: inst, sessionId: readNonEmptyString(state.instanceId) });
|
|
1722
1731
|
}
|
|
1723
1732
|
}
|
|
@@ -88,6 +88,26 @@ function resolveAutoPruneMinAgeMs(): number {
|
|
|
88
88
|
return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// PTY-OVERTRUST-DRAIN (Defect B, fix B). Age-based escape for the
|
|
92
|
+
// `generating_no_idle_coordinator` hold. Fix A makes the drain predicate read the RAW
|
|
93
|
+
// adapter (mask-stripped), so the common mask-driven false-busy is gone. But a hold can
|
|
94
|
+
// still arise from a genuine status-source desync that fix A does not reach (e.g. the
|
|
95
|
+
// adapter raw itself momentarily reads generating while the coordinator is actually at a
|
|
96
|
+
// turn end). This is a TIME-BASED BACKSTOP: when a mesh's pending terminal events have
|
|
97
|
+
// been held this long, re-confirm the coordinator's RAW adapter idle on the tick and, if
|
|
98
|
+
// it is genuinely idle, drain ONCE. It NEVER injects into a genuinely-generating PTY —
|
|
99
|
+
// the re-confirmation gates on raw adapter idle, so the intentional removal of
|
|
100
|
+
// force-inject-into-generating (data-loss) is preserved. Default 12s = 3 reconcile ticks
|
|
101
|
+
// at the 4s cadence: long enough that a normal mid-turn settle is not pre-empted, short
|
|
102
|
+
// enough that a desync-stranded completion is not held for minutes. Env-tunable.
|
|
103
|
+
const DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS = 12_000;
|
|
104
|
+
|
|
105
|
+
function resolvePendingHeldDrainEscalateMs(): number {
|
|
106
|
+
// Floor 4s (one tick) so a mis-set env cannot make the escape race a normal settle;
|
|
107
|
+
// ceiling 5min so it cannot be disabled into a permanent strand.
|
|
108
|
+
return resolveTunedReconcileMs('MESH_PENDING_HELD_DRAIN_ESCALATE_MS', DEFAULT_PENDING_HELD_DRAIN_ESCALATE_MS, 4_000, 5 * 60_000);
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
function resolveReconcileIntervalMs(): number {
|
|
92
112
|
const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
|
|
93
113
|
if (raw) {
|
|
@@ -187,6 +207,13 @@ interface LiveCoordinator {
|
|
|
187
207
|
// routes back to the exact originating coordinator session, not a sibling on the same
|
|
188
208
|
// daemon (the multi-coordinator misroute).
|
|
189
209
|
sessionId: string;
|
|
210
|
+
// PTY-OVERTRUST-DRAIN (Defect B): drain-eligibility, decided on the RAW adapter
|
|
211
|
+
// turn-state (mask-stripped) — NOT on getState().status, which overlays the
|
|
212
|
+
// auto-approve "hold-idle" visual mask that paints a genuinely-idle coordinator
|
|
213
|
+
// `generating` and so used to strand its worker's completion. True only when the
|
|
214
|
+
// raw adapter is at a real turn end AND the session is not modal-parked. When the
|
|
215
|
+
// instance does not expose getDrainStatus() (non-CLI / older), this falls back to
|
|
216
|
+
// the masked `status === 'idle'` (the pre-fix behaviour) so nothing regresses.
|
|
190
217
|
idle: boolean;
|
|
191
218
|
// True when the coordinator session is parked on a harness modal awaiting a
|
|
192
219
|
// human answer — claude-cli AskUserQuestion (waiting_choice) or a tool-consent
|
|
@@ -318,6 +345,18 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
|
318
345
|
const modalParked = typeof (inst as any).isModalParked === 'function'
|
|
319
346
|
? (inst as any).isModalParked() === true
|
|
320
347
|
: (status === 'waiting_choice' || status === 'waiting_approval');
|
|
348
|
+
// PTY-OVERTRUST-DRAIN (Defect B, fix A): drain-eligible idle is decided on the
|
|
349
|
+
// RAW adapter turn-state, not getState().status. getState() overlays the
|
|
350
|
+
// auto-approve hold-idle mask that paints a genuinely-idle coordinator
|
|
351
|
+
// `generating` (a UI-flicker suppressant), and the reconcile loop used to trust
|
|
352
|
+
// that mask and HOLD the worker's completion (generating_no_idle_coordinator)
|
|
353
|
+
// even though the PTY was at a real turn end. getDrainStatus() strips the mask
|
|
354
|
+
// (raw adapter idle, modal-park preserved). Fall back to the masked literal for
|
|
355
|
+
// any instance that does not expose it (non-CLI / older) — regression-0.
|
|
356
|
+
const drainStatus: string | null = typeof (inst as any).getDrainStatus === 'function'
|
|
357
|
+
? (inst as any).getDrainStatus()
|
|
358
|
+
: null;
|
|
359
|
+
const idle = drainStatus !== null ? drainStatus === 'idle' : (status === 'idle');
|
|
321
360
|
const sessionId = readNonEmptyString(state.instanceId);
|
|
322
361
|
// ── NOTIF (B) desync diagnostic (read-only, no behavior change) ───────────
|
|
323
362
|
// The confirmed (B) defect: a coordinator whose FSM is idle (status above ===
|
|
@@ -349,7 +388,10 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
|
349
388
|
const lastStatus = readNonEmptyString((inst as any).lastStatus) || '?';
|
|
350
389
|
const autoApproveBusy = (inst as any).autoApproveBusy;
|
|
351
390
|
const maskSince = (inst as any).autoApproveMaskSince;
|
|
352
|
-
|
|
391
|
+
// PTY-OVERTRUST-DRAIN: include the mask-stripped drainStatus next to the three
|
|
392
|
+
// legacy sources so the divergence (getState=generating while adapterRaw=idle =
|
|
393
|
+
// the mask) is directly readable, and confirm drain now follows adapterRaw.
|
|
394
|
+
LOG.debug('MeshReconcile', `coordDiag sess=${sessionId || '?'} mesh=${meshId} getState=${status || '?'} drainStatus=${drainStatus || 'n/a'} lastStatus=${lastStatus} adapterRaw=${adapterRaw} autoApproveBusy=${autoApproveBusy === true} maskSince=${maskSince || 0}`);
|
|
353
395
|
}
|
|
354
396
|
// Modal-park transition observability: a coordinator entering modal-park is what
|
|
355
397
|
// begins holding completion events under `modal_parked`; one leaving it is what
|
|
@@ -365,7 +407,7 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
|
365
407
|
LOG.info('MeshReconcile', `Coordinator ${sessionId || '?'} (mesh ${meshId}) left modal-park (status=${status}) — held events will drain on this/next tick`);
|
|
366
408
|
}
|
|
367
409
|
}
|
|
368
|
-
out.push({ meshId, instance: inst, sessionId, idle
|
|
410
|
+
out.push({ meshId, instance: inst, sessionId, idle, modalParked });
|
|
369
411
|
}
|
|
370
412
|
return out;
|
|
371
413
|
}
|
|
@@ -566,6 +608,107 @@ function recordHeldTerminalEventsToLedger(
|
|
|
566
608
|
}
|
|
567
609
|
}
|
|
568
610
|
|
|
611
|
+
// PTY-OVERTRUST-DRAIN (Defect B, fix B). Age of the OLDEST queued terminal/force-inject
|
|
612
|
+
// event for a mesh, in ms — the signal the generating-hold age-escape gates on. Returns 0
|
|
613
|
+
// when there is no held terminal event (no escape needed). Best-effort: a peek failure
|
|
614
|
+
// returns 0 (no escape this tick), never throws into the tick.
|
|
615
|
+
function oldestHeldTerminalEventAgeMs(meshId: string, drainDaemonIds: string[]): number {
|
|
616
|
+
let pending: readonly PendingMeshCoordinatorEvent[];
|
|
617
|
+
try {
|
|
618
|
+
pending = getPendingMeshCoordinatorEvents(meshId, drainDaemonIds.length > 0 ? drainDaemonIds : undefined);
|
|
619
|
+
} catch {
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
const now = Date.now();
|
|
623
|
+
let maxAge = 0;
|
|
624
|
+
for (const event of pending) {
|
|
625
|
+
if (!shouldForceInjectMeshEvent(event.event)) continue; // only terminal events matter
|
|
626
|
+
const queuedAt = typeof event.queuedAt === 'number' ? event.queuedAt : now;
|
|
627
|
+
const age = now - queuedAt;
|
|
628
|
+
if (age > maxAge) maxAge = age;
|
|
629
|
+
}
|
|
630
|
+
return maxAge;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// PTY-OVERTRUST-DRAIN (Defect B, fix B). Re-confirm, on the RAW adapter (mask-stripped),
|
|
634
|
+
// which of the held-as-generating coordinators is GENUINELY idle right now. A coordinator
|
|
635
|
+
// whose getDrainStatus() reads 'idle' is a real drain target the time-based escape may
|
|
636
|
+
// deliver into. One that still reads 'generating'/'modal_parked'/'other' stays held — the
|
|
637
|
+
// escape NEVER injects into a genuinely-busy PTY (that is the data-loss force-inject path
|
|
638
|
+
// intentionally removed; re-confirmation is what keeps this safe). Falls back to the
|
|
639
|
+
// coordinator's already-computed `idle` flag when the instance does not expose
|
|
640
|
+
// getDrainStatus() (non-CLI / older) — that flag is itself raw-adapter-derived post-fix-A.
|
|
641
|
+
function reconfirmGenuinelyIdleCoordinators(generating: LiveCoordinator[]): LiveCoordinator[] {
|
|
642
|
+
const out: LiveCoordinator[] = [];
|
|
643
|
+
for (const c of generating) {
|
|
644
|
+
const inst = c.instance as any;
|
|
645
|
+
const drainStatus: string | null = typeof inst?.getDrainStatus === 'function'
|
|
646
|
+
? inst.getDrainStatus()
|
|
647
|
+
: null;
|
|
648
|
+
const genuinelyIdle = drainStatus !== null ? drainStatus === 'idle' : c.idle;
|
|
649
|
+
if (genuinelyIdle) out.push({ ...c, idle: true });
|
|
650
|
+
}
|
|
651
|
+
return out;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Full-drain the local pending queue for a mesh and inject every event into the given
|
|
655
|
+
// IDLE target coordinators, honouring strict session routing. Shared by the normal idle
|
|
656
|
+
// delivery path and the Defect-B age-escape so both deliver identically (one drain, one
|
|
657
|
+
// inject-per-event, strict-route hold for an unmatched session). Returns the number of
|
|
658
|
+
// events drained (0 when the queue was empty / drain failed). Callers must have already
|
|
659
|
+
// confirmed the targets are genuinely idle.
|
|
660
|
+
function drainAndInjectIntoTargets(
|
|
661
|
+
meshId: string,
|
|
662
|
+
drainDaemonIds: string[],
|
|
663
|
+
localDaemonId: string | undefined,
|
|
664
|
+
targetCoordinators: LiveCoordinator[],
|
|
665
|
+
logLabel: string,
|
|
666
|
+
): number {
|
|
667
|
+
let pendingEvents: PendingMeshCoordinatorEvent[] = [];
|
|
668
|
+
try {
|
|
669
|
+
pendingEvents = drainPendingMeshCoordinatorEvents(
|
|
670
|
+
meshId,
|
|
671
|
+
drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
|
|
672
|
+
);
|
|
673
|
+
} catch (e: any) {
|
|
674
|
+
LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
if (pendingEvents.length === 0) return 0;
|
|
678
|
+
|
|
679
|
+
LOG.info('MeshReconcile', `Reconcile inject → ${logLabel}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
|
|
680
|
+
for (const pending of pendingEvents) {
|
|
681
|
+
// Strict session routing (multi-coordinator): when the event names an
|
|
682
|
+
// originating coordinator session, deliver ONLY to the live coordinator whose
|
|
683
|
+
// session id matches — a sibling coordinator on the same daemon must NOT receive
|
|
684
|
+
// another coordinator's completion. When the event carries no session id (legacy /
|
|
685
|
+
// version-skewed / single-coordinator), fall back to the daemon-level set
|
|
686
|
+
// (unchanged behaviour — regression-0 for the common case).
|
|
687
|
+
const wantSession = readNonEmptyString(pending.targetCoordinatorSessionId);
|
|
688
|
+
if (wantSession) {
|
|
689
|
+
// SESSION-ID IS SINGLE-FORM: a coordinator session id is one canonical
|
|
690
|
+
// UUID (crypto.randomUUID), carried verbatim end-to-end — no node/daemon-id
|
|
691
|
+
// style serialization variants. Exact `===` is the correct match; unlike
|
|
692
|
+
// the daemon-level set below it needs no equivalence helper.
|
|
693
|
+
const matched = targetCoordinators.filter(c => c.sessionId === wantSession);
|
|
694
|
+
if (matched.length === 0) {
|
|
695
|
+
// The originating coordinator session is not deliverable on this daemon
|
|
696
|
+
// right now (gone, or modal-parked and excluded from targets). Strict mode
|
|
697
|
+
// does NOT broadcast to siblings — hold the event for a later tick, and
|
|
698
|
+
// ledger-expire it past a TTL so it can never wedge forever.
|
|
699
|
+
holdOrExpireStrictUnmatchedEvent(pending, wantSession, meshId);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
for (const c of matched) injectPendingIntoCoordinator(c.instance, pending);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
for (const c of targetCoordinators) {
|
|
706
|
+
injectPendingIntoCoordinator(c.instance, pending);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return pendingEvents.length;
|
|
710
|
+
}
|
|
711
|
+
|
|
569
712
|
// One reconcile tick. Two independent phases:
|
|
570
713
|
//
|
|
571
714
|
// PHASE 1 — Remote queue pull (the fix for remote worktree completions never
|
|
@@ -974,6 +1117,31 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
974
1117
|
try { hasPending = store.pendingEventCount(meshId) > 0; } catch { /* peek below */ }
|
|
975
1118
|
}
|
|
976
1119
|
if (hasPending) {
|
|
1120
|
+
// ── PTY-OVERTRUST-DRAIN (Defect B, fix B): age-based escape ───────────
|
|
1121
|
+
// Fix A already routes the common mask-driven false-busy to the idle path,
|
|
1122
|
+
// so reaching here means the coordinator's RAW adapter reads generating.
|
|
1123
|
+
// That is almost always genuine — but a status-source desync fix A does not
|
|
1124
|
+
// reach can momentarily make the raw adapter read generating while the PTY
|
|
1125
|
+
// is actually at a turn end, stranding the completion across many ticks. As a
|
|
1126
|
+
// TIME-BASED BACKSTOP, once the oldest held terminal event has aged past the
|
|
1127
|
+
// escalate threshold, RE-CONFIRM each held coordinator's raw adapter idle and,
|
|
1128
|
+
// if genuinely idle, drain ONCE into it. The re-confirmation gate is what makes
|
|
1129
|
+
// this safe: it NEVER injects into a genuinely-generating PTY (that is the
|
|
1130
|
+
// data-loss force-inject path intentionally removed). A coordinator still
|
|
1131
|
+
// genuinely generating stays held.
|
|
1132
|
+
const escalateMs = resolvePendingHeldDrainEscalateMs();
|
|
1133
|
+
const heldAgeMs = oldestHeldTerminalEventAgeMs(
|
|
1134
|
+
meshId,
|
|
1135
|
+
drainDaemonIds.length > 0 ? drainDaemonIds : (localDaemonId ? [localDaemonId] : []),
|
|
1136
|
+
);
|
|
1137
|
+
if (heldAgeMs >= escalateMs) {
|
|
1138
|
+
const escapeTargets = reconfirmGenuinelyIdleCoordinators(generatingCoordinators);
|
|
1139
|
+
if (escapeTargets.length > 0) {
|
|
1140
|
+
LOG.info('MeshReconcile', `Reconcile age-escape → generating-hold: held terminal event(s) for mesh ${meshId} aged ${Math.round(heldAgeMs / 1000)}s (≥ ${Math.round(escalateMs / 1000)}s) and ${escapeTargets.length} coordinator(s) re-confirmed genuinely idle on the raw adapter — draining once`);
|
|
1141
|
+
const drained = drainAndInjectIntoTargets(meshId, drainDaemonIds, localDaemonId, escapeTargets, 'age-escape');
|
|
1142
|
+
if (drained > 0) continue; // delivered → no hold this tick
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
977
1145
|
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)`);
|
|
978
1146
|
// NOTIF (B) diagnostic: this is the hold that strands the completion. Name
|
|
979
1147
|
// the sessionId(s) the loop just classified non-idle/non-modal so the
|
|
@@ -1005,48 +1173,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
|
|
|
1005
1173
|
// queued event and deliver it to the idle input box as a real turn. The no-idle case
|
|
1006
1174
|
// (generating/modal-only) was already held above and never reaches here, so there is
|
|
1007
1175
|
// no force-drain-into-generating path left — the single delivery is the idle drain.
|
|
1008
|
-
|
|
1009
|
-
try {
|
|
1010
|
-
pendingEvents = drainPendingMeshCoordinatorEvents(
|
|
1011
|
-
meshId,
|
|
1012
|
-
drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
|
|
1013
|
-
);
|
|
1014
|
-
} catch (e: any) {
|
|
1015
|
-
LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
|
|
1016
|
-
continue;
|
|
1017
|
-
}
|
|
1018
|
-
if (pendingEvents.length === 0) continue;
|
|
1019
|
-
|
|
1020
|
-
LOG.info('MeshReconcile', `Reconcile inject → idle: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
|
|
1021
|
-
for (const pending of pendingEvents) {
|
|
1022
|
-
// Strict session routing (multi-coordinator): when the event names an
|
|
1023
|
-
// originating coordinator session, deliver ONLY to the live coordinator whose
|
|
1024
|
-
// session id matches — a sibling coordinator on the same daemon must NOT receive
|
|
1025
|
-
// another coordinator's completion. When the event carries no session id (legacy /
|
|
1026
|
-
// version-skewed / single-coordinator), fall back to the daemon-level set
|
|
1027
|
-
// (unchanged behaviour — regression-0 for the common case).
|
|
1028
|
-
const wantSession = readNonEmptyString(pending.targetCoordinatorSessionId);
|
|
1029
|
-
if (wantSession) {
|
|
1030
|
-
// SESSION-ID IS SINGLE-FORM: a coordinator session id is one canonical
|
|
1031
|
-
// UUID (crypto.randomUUID), carried verbatim end-to-end — no node/daemon-id
|
|
1032
|
-
// style serialization variants. Exact `===` is the correct match; unlike
|
|
1033
|
-
// the daemon-level set below it needs no equivalence helper.
|
|
1034
|
-
const matched = targetCoordinators.filter(c => c.sessionId === wantSession);
|
|
1035
|
-
if (matched.length === 0) {
|
|
1036
|
-
// The originating coordinator session is not deliverable on this daemon
|
|
1037
|
-
// right now (gone, or modal-parked and excluded from targets). Strict mode
|
|
1038
|
-
// does NOT broadcast to siblings — hold the event for a later tick, and
|
|
1039
|
-
// ledger-expire it past a TTL so it can never wedge forever.
|
|
1040
|
-
holdOrExpireStrictUnmatchedEvent(pending, wantSession, meshId);
|
|
1041
|
-
continue;
|
|
1042
|
-
}
|
|
1043
|
-
for (const c of matched) injectPendingIntoCoordinator(c.instance, pending);
|
|
1044
|
-
continue;
|
|
1045
|
-
}
|
|
1046
|
-
for (const c of targetCoordinators) {
|
|
1047
|
-
injectPendingIntoCoordinator(c.instance, pending);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1176
|
+
drainAndInjectIntoTargets(meshId, drainDaemonIds, localDaemonId, targetCoordinators, 'idle');
|
|
1050
1177
|
}
|
|
1051
1178
|
}
|
|
1052
1179
|
|
|
@@ -1199,6 +1199,50 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1199
1199
|
return this.resolveModalParkStatus() !== null;
|
|
1200
1200
|
}
|
|
1201
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
|
|
1204
|
+
* reconcile loop must consult — the RAW adapter turn-state, with the
|
|
1205
|
+
* auto-approve "hold-idle" visual mask STRIPPED.
|
|
1206
|
+
*
|
|
1207
|
+
* getState().status overlays `autoApproveHoldIdle`/`autoApproveActive` to paint a
|
|
1208
|
+
* genuinely-idle adapter as `generating` (a UI-flicker suppression while an
|
|
1209
|
+
* auto-approve key-press settles — see getState() ~:800). That mask is correct
|
|
1210
|
+
* for the dashboard, but the reconcile loop trusts it as "the coordinator is
|
|
1211
|
+
* busy" and therefore HOLDS a worker's completion under
|
|
1212
|
+
* `generating_no_idle_coordinator` even though the coordinator's PTY is at a real
|
|
1213
|
+
* turn end and would accept the inject as a turn — the completion is stranded.
|
|
1214
|
+
*
|
|
1215
|
+
* This accessor reports the drain truth instead:
|
|
1216
|
+
* - 'modal_parked' — a GENUINE human-await modal (AskUserQuestion / a non-
|
|
1217
|
+
* transient tool-consent). Still excluded from drain (a force-inject here
|
|
1218
|
+
* writes raw keystrokes the modal eats → data corruption). Mirrors
|
|
1219
|
+
* isModalParked(), evaluated first so a parked session never reads idle.
|
|
1220
|
+
* - 'idle' — the RAW adapter is at a turn end (adapter.getStatus(allowParse:false)
|
|
1221
|
+
* === 'idle') and the session is not modal-parked. Drain-eligible REGARDLESS
|
|
1222
|
+
* of the auto-approve mask. This is the case the mask used to hide.
|
|
1223
|
+
* - 'generating' — the raw adapter is genuinely mid-turn. Held (a raw PTY write
|
|
1224
|
+
* into a generating claude-cli is not consumed as a turn → data loss). The
|
|
1225
|
+
* intentional removal of force-inject-into-generating is preserved.
|
|
1226
|
+
* - 'other' — any other raw status (error / starting / waiting_choice handled by
|
|
1227
|
+
* modal-park above). Not a drain target.
|
|
1228
|
+
*
|
|
1229
|
+
* Uses allowParse:false (engine.activeModal only, side-effect-free) so it never
|
|
1230
|
+
* mutates the very auto-approve mask state the diagnostics read.
|
|
1231
|
+
*/
|
|
1232
|
+
getDrainStatus(): 'idle' | 'generating' | 'modal_parked' | 'other' {
|
|
1233
|
+
if (this.isModalParked()) return 'modal_parked';
|
|
1234
|
+
let rawStatus: string;
|
|
1235
|
+
try {
|
|
1236
|
+
const raw = this.adapter.getStatus({ allowParse: false })?.status;
|
|
1237
|
+
rawStatus = typeof raw === 'string' ? raw.trim() : '';
|
|
1238
|
+
} catch {
|
|
1239
|
+
return 'other';
|
|
1240
|
+
}
|
|
1241
|
+
if (rawStatus === 'idle') return 'idle';
|
|
1242
|
+
if (isCliGeneratingLikeStatus(rawStatus)) return 'generating';
|
|
1243
|
+
return 'other';
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1202
1246
|
onEvent(event: string, data?: any): void {
|
|
1203
1247
|
if (event === 'send_message') {
|
|
1204
1248
|
const input = normalizeInputEnvelope(data);
|