@adhdev/daemon-core 0.9.82-rc.374 → 0.9.82-rc.375

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.
@@ -394,6 +394,42 @@ function isWeakTerminalLedgerPayload(payload: Record<string, unknown> | undefine
394
394
  return diag?.finalAssistantPresent === false || diag?.blockReason === 'missing_final_assistant';
395
395
  }
396
396
 
397
+ // (FALSEIDLE-BGCHILD-b) A later genuine completion of the SAME task that carries a
398
+ // substantively different — and fuller — final summary than the recorded terminal is the REAL
399
+ // final that an earlier (false-idle) completion pre-empted, not a duplicate. The background-child
400
+ // false idle is the nasty case the plain isWeakTerminalLedgerPayload supersession misses: the
401
+ // early completion's screen parser DID see a prior/intermediate standard assistant, so it is
402
+ // recorded as a STRONG terminal with a non-empty (but truncated) finalSummary. Without this the
403
+ // providerSessionId/finalSummary dedup below swallows the genuine final and the coordinator is
404
+ // stuck with the truncated mid-turn text forever (the one-shot-consumption symptom). Same-task,
405
+ // new event is genuine, prior terminal summary is a strict prefix of (or otherwise shorter than)
406
+ // the new one → treat as the corrected final and let it through. Conservative: requires the new
407
+ // summary to be genuine evidence AND meaningfully longer, so an identical re-arrival or a SHORTER
408
+ // later summary is still deduped.
409
+ function supersedesTruncatedTerminalSummary(args: {
410
+ terminalPayload: Record<string, unknown>;
411
+ metadataEvent: Record<string, unknown>;
412
+ terminalTaskId: string;
413
+ eventTaskId: string;
414
+ }): boolean {
415
+ // Only applies when both name the SAME task (a distinct task is handled by distinctTaskCompletion).
416
+ if (!args.terminalTaskId || !args.eventTaskId || args.terminalTaskId !== args.eventTaskId) return false;
417
+ if (!isGenuineCompletionEvidence(args.metadataEvent)) return false;
418
+ const terminalSummary = readNonEmptyString(args.terminalPayload.finalSummary);
419
+ const eventSummary = readNonEmptyString(args.metadataEvent.finalSummary);
420
+ if (!eventSummary) return false;
421
+ // Identical text → genuine duplicate, keep deduping.
422
+ if (terminalSummary === eventSummary) return false;
423
+ // The recorded terminal was a known-weak (false-idle) one → already handled by the weak
424
+ // supersession path; nothing extra to do here.
425
+ if (isWeakTerminalLedgerPayload(args.terminalPayload)) return false;
426
+ // No prior summary at all, or the new summary strictly extends / is meaningfully longer than
427
+ // the recorded one → the recorded terminal was the truncated pre-emption; supersede it.
428
+ if (!terminalSummary) return true;
429
+ if (eventSummary.startsWith(terminalSummary)) return true;
430
+ return eventSummary.length > terminalSummary.length + 32;
431
+ }
432
+
397
433
  // The latest still-active direct-dispatch taskId for a session, resolved BEFORE the
398
434
  // completion flips the dispatch row terminal. Direct dispatches (mesh_send_task) have no
399
435
  // work-queue row, so this is the only taskId available to attribute the terminal ledger
@@ -1963,7 +1999,15 @@ function evaluateMeshEventSuppression(
1963
1999
  const terminalTaskId = readNonEmptyString(terminal.payload.taskId);
1964
2000
  const eventTaskId = readNonEmptyString(args.metadataEvent.taskId);
1965
2001
  const distinctTaskCompletion = !!eventTaskId && !!terminalTaskId && eventTaskId !== terminalTaskId;
1966
- if (!newDispatchAfterTerminal && !supersedesWeakTerminal && !distinctTaskCompletion) {
2002
+ // (FALSEIDLE-BGCHILD-b) Same-task genuine completion carrying a fuller summary than the
2003
+ // recorded (truncated, false-idle-pre-empted) terminal supersedes it — see helper.
2004
+ const supersedesTruncatedTerminal = supersedesTruncatedTerminalSummary({
2005
+ terminalPayload: terminal.payload,
2006
+ metadataEvent: args.metadataEvent,
2007
+ terminalTaskId,
2008
+ eventTaskId,
2009
+ });
2010
+ if (!newDispatchAfterTerminal && !supersedesWeakTerminal && !distinctTaskCompletion && !supersedesTruncatedTerminal) {
1967
2011
  const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1968
2012
  const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1969
2013
  const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
@@ -44,6 +44,11 @@ export type MeshLedgerKind =
44
44
  | 'direct_dispatch_pruned'
45
45
  | 'event_held'
46
46
  | 'task_reclaimed'
47
+ // Gap2-A: a coordinator-recorded operating note — a runtime-accumulated
48
+ // lesson (provider quirk, pattern to avoid, recovery lesson) persisted in
49
+ // the ledger so it survives coordinator restarts and is provider-neutral.
50
+ // payload: { text, category?, createdAt?, sourceCoordinator? }
51
+ | 'coordinator_operating_note'
47
52
  ;
48
53
 
49
54
  export interface MeshLedgerEntry {
@@ -205,6 +205,13 @@ function resolveCoordinatorSelfIds(mesh: LocalMeshEntry, drainDaemonIds: string[
205
205
  return [...ids];
206
206
  }
207
207
 
208
+ // Observability: last-seen modal-park state per coordinator session, so we LOG.info
209
+ // only on a TRANSITION (clear → parked, parked → cleared) instead of every 4s tick.
210
+ // Per-process; a restart re-logs the first observation, which is desirable — it
211
+ // re-confirms a coordinator that is still parked after the restart (the exact
212
+ // "restart does not clear it" symptom the operator needs visibility into).
213
+ const coordinatorModalParkState = new Map<string, boolean>();
214
+
208
215
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
209
216
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
210
217
  const out: LiveCoordinator[] = [];
@@ -222,6 +229,20 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
222
229
  // and waiting_choice is absent from some of them (see cli-provider-instance).
223
230
  const modalParked = status === 'waiting_choice' || status === 'waiting_approval';
224
231
  const sessionId = readNonEmptyString(state.instanceId);
232
+ // Modal-park transition observability: a coordinator entering modal-park is what
233
+ // begins holding completion events under `modal_parked`; one leaving it is what
234
+ // drains them. Both transitions were previously SILENT (the operator had no log
235
+ // to diagnose a stuck/held completion), so emit a single line per edge.
236
+ const stateKey = `${meshId}::${sessionId || '?'}`;
237
+ const prevParked = coordinatorModalParkState.get(stateKey);
238
+ if (prevParked !== modalParked) {
239
+ coordinatorModalParkState.set(stateKey, modalParked);
240
+ if (modalParked) {
241
+ LOG.info('MeshReconcile', `Coordinator ${sessionId || '?'} (mesh ${meshId}) entered modal-park (status=${status}) — terminal events for it will be held until the modal is answered`);
242
+ } else if (prevParked === true) {
243
+ LOG.info('MeshReconcile', `Coordinator ${sessionId || '?'} (mesh ${meshId}) left modal-park (status=${status}) — held events will drain on this/next tick`);
244
+ }
245
+ }
225
246
  out.push({ meshId, instance: inst, sessionId, idle: status === 'idle', modalParked });
226
247
  }
227
248
  return out;
@@ -599,7 +620,73 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
599
620
  // force-injected via generatingCoordinators — we never block the deadlock-break.)
600
621
  if (targetCoordinators.length === 0) {
601
622
  if (modalParkedCoordinators.length > 0) {
602
- LOG.info('MeshReconcile', `Reconcile skip modal-parked: holding pending event(s) for mesh ${meshId} (${modalParkedCoordinators.length} coordinator(s) awaiting a modal answer; events left queued)`);
623
+ // ── orphan escape (MUST precede the blanket modal-park hold) ──────────
624
+ // A modal-parked coordinator with no idle/generating sibling otherwise
625
+ // wedges EVERY pending event under `modal_parked` until that modal resolves
626
+ // — including a STRICT-routed completion whose originating coordinator
627
+ // session is GONE (an orphan: the worktree/session that produced it was
628
+ // removed, or that coordinator session died). Such an event will never be
629
+ // deliverable to its target session no matter what the modal-parked sibling
630
+ // does, so holding it under modal_parked is a permanent-held leak (the very
631
+ // "data restart re-reproduces it" symptom — the gate is reconstructed live
632
+ // from the still-parked modal, so a restart does not clear it). Route those
633
+ // orphan events through the strict-route hold/expire path so the bounded
634
+ // STRICT_SESSION_MATCH_TTL eventually expires them (recoverable, ledgered)
635
+ // instead of leaving them held forever. A strict event whose target session
636
+ // IS live but merely modal-parked is left to the blanket hold below (it is
637
+ // genuinely transiently blocked, not orphaned).
638
+ const liveSessionIds = new Set(
639
+ meshCoordinators.map(c => readNonEmptyString(c.sessionId)).filter(Boolean),
640
+ );
641
+ let orphanEscaped = 0;
642
+ const hasPendingForOrphanPeek = !store
643
+ || (() => { try { return store.pendingEventCount(meshId) > 0; } catch { return true; } })();
644
+ if (hasPendingForOrphanPeek) {
645
+ // Identify which pending event NAMES correspond to orphan-targeted events
646
+ // (a strict targetCoordinatorSessionId that matches no live coordinator).
647
+ let peeked: readonly PendingMeshCoordinatorEvent[] = [];
648
+ try {
649
+ peeked = getPendingMeshCoordinatorEvents(meshId, drainDaemonIds.length > 0 ? drainDaemonIds : undefined);
650
+ } catch { peeked = []; }
651
+ const isOrphan = (e: PendingMeshCoordinatorEvent): boolean => {
652
+ const want = readNonEmptyString(e.targetCoordinatorSessionId);
653
+ return !!want && !liveSessionIds.has(want);
654
+ };
655
+ const orphanEventNames = new Set(peeked.filter(isOrphan).map(e => e.event));
656
+ if (orphanEventNames.size > 0) {
657
+ // The drain filter is event-NAME scoped (not per-row), so draining by the
658
+ // orphan event names also pulls any non-orphan event sharing that name. Drain
659
+ // them all, then re-route: orphan-targeted events go through the strict-route
660
+ // hold/expire path (bounded TTL → eventually ledger-expired, recoverable);
661
+ // non-orphan events of the same name are re-queued unchanged (queuedAt
662
+ // preserved) so they remain genuinely held for their still-live, modal-parked
663
+ // target. This is the same per-event strict routing PHASE 2 does below — just
664
+ // reached here because the blanket modal-park short-circuit would otherwise
665
+ // wedge the orphans forever.
666
+ let drained: PendingMeshCoordinatorEvent[] = [];
667
+ try {
668
+ drained = drainPendingMeshCoordinatorEvents(
669
+ meshId,
670
+ drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
671
+ { onlyEvents: orphanEventNames },
672
+ );
673
+ } catch (e: any) {
674
+ LOG.warn('MeshReconcile', `Orphan-escape drain failed for mesh ${meshId}: ${e?.message || e}`);
675
+ drained = [];
676
+ }
677
+ for (const pending of drained) {
678
+ if (isOrphan(pending)) {
679
+ holdOrExpireStrictUnmatchedEvent(pending, readNonEmptyString(pending.targetCoordinatorSessionId), meshId);
680
+ orphanEscaped++;
681
+ } else {
682
+ // Still-live (modal-parked) target — re-queue unchanged so it is held
683
+ // for the next modal-resolved tick, exactly like the blanket hold would.
684
+ try { queuePendingMeshCoordinatorEvent(pending); } catch { /* best-effort re-queue */ }
685
+ }
686
+ }
687
+ }
688
+ }
689
+ LOG.info('MeshReconcile', `Reconcile skip → modal-parked: holding pending event(s) for mesh ${meshId} (${modalParkedCoordinators.length} coordinator(s) awaiting a modal answer; events left queued${orphanEscaped > 0 ? `; ${orphanEscaped} orphan-targeted event(s) routed to strict-route TTL` : ''})`);
603
690
  // C1: mirror held terminal events into the ledger so a held completion's
604
691
  // worker summary is auditable/recoverable even if the modal is never
605
692
  // resolved, the coordinator restarts, or the pending file is later trimmed.
@@ -1034,6 +1121,32 @@ async function reconcileUnterminatedDirectDispatches(
1034
1121
  const evidence = extractFinalAssistantSummaryEvidence(messages);
1035
1122
  if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
1036
1123
 
1124
+ // STALE-SUMMARY guard (modal-parked / reused-session misattribution): a direct
1125
+ // dispatch frequently reuses a session that already ran a PRIOR task. read_chat
1126
+ // returns the tail of the WHOLE session, so extractFinalAssistantSummaryEvidence
1127
+ // picks the latest user-facing assistant message — which, for a task that has
1128
+ // barely started (the session momentarily reads idle between turns), is the prior
1129
+ // task's final summary. The downstream reconcile proves the summary is after the
1130
+ // LEDGER task_dispatched entry; here we additionally have the AUTHORITATIVE per-task
1131
+ // dispatchedAt (the dispatch-store row, immune to ledger-ordering quirks), so when
1132
+ // the selected transcript message is provably BEFORE this task's own dispatch we
1133
+ // refuse it outright — it is a prior task's summary, not this task's output (the
1134
+ // 2843ms-duration stale-summary bug where task 2e3f501e copy-pasted 4eca2d9d's
1135
+ // summary). When the message carries no usable timestamp we do NOT block here: the
1136
+ // downstream reconcile already rejects a non-JSON summary it cannot prove is
1137
+ // post-dispatch (transcript_not_proven_after_dispatch), and a structured
1138
+ // final_summary_json is self-attributing — so a timeless provider is not
1139
+ // over-blocked while the provable-stale case is still caught.
1140
+ const dispatchedAtMs = Date.parse(readNonEmptyString(dispatch.dispatchedAt));
1141
+ const transcriptAtMs = Date.parse(evidence.transcriptMessageAt ?? '');
1142
+ if (Number.isFinite(dispatchedAtMs) && Number.isFinite(transcriptAtMs) && transcriptAtMs < dispatchedAtMs) {
1143
+ LOG.info('MeshReconcile', `Stale-summary guard: skipping transcript reconcile for task ${taskId} on node ${nodeId} (mesh ${mesh.id}) — final assistant message (${evidence.transcriptMessageAt}) predates this task's dispatch (${dispatch.dispatchedAt}); it is a prior task's summary`);
1144
+ traceMeshEventDrop('reconcile_stale_summary_before_dispatch', {
1145
+ taskId, sessionId, nodeId, meshId: mesh.id, event: 'agent:generating_completed',
1146
+ }, `transcriptAt=${evidence.transcriptMessageAt} < dispatchedAt=${dispatch.dispatchedAt}`);
1147
+ continue;
1148
+ }
1149
+
1037
1150
  const providerSessionId = readNonEmptyString(payload.providerSessionId);
1038
1151
  const coordinatorDaemonId = selfIds.find(id => !!id);
1039
1152
  try {
@@ -102,6 +102,24 @@ type ExternalTranscriptProbe = {
102
102
 
103
103
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
104
104
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
105
+ // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
106
+ // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
107
+ // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
108
+ // turn that spawns a BACKGROUND child (e.g. `npm test &`, a backgrounded Bash tool) can paint
109
+ // a burst of child output, fall quiet, and have the screen parser read a PRIOR/intermediate
110
+ // standard assistant as if the turn were done — firing a false idle while the agent is in fact
111
+ // still generating (e.g. mid-commit). With flushDelay=0 there is no window for the resume guard
112
+ // in flushCompletedDebounceIfFinalized (latestVisibleStatus !== 'idle' → cancel) to observe the
113
+ // agent picking the turn back up. A short non-zero settle window restores that resume guard for
114
+ // mesh workers without delaying genuinely-finished turns beyond this bound. Scoped to mesh
115
+ // worker sessions so interactive native-history sessions keep the immediate flush.
116
+ const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 1500;
117
+ // TASKBUBBLE-DUP: window during which an identical user-input ack (same trimmed
118
+ // content on the same instance) is treated as a redelivery of one dispatch and
119
+ // suppressed from the chat transcript. Matches the coordinator-side
120
+ // DUPLICATE_DISPATCH_WINDOW_MS (mesh-tools) so the daemon's bubble-level guard
121
+ // covers the same retry horizon as the MCP-level dispatch dedup.
122
+ const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
105
123
 
106
124
  /** Events that signal a dispatched mesh task has reached a terminal state.
107
125
  * Detach the mesh assignment after emitting one of these so the worker's
@@ -431,6 +449,14 @@ export class CliProviderInstance implements ProviderInstance {
431
449
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
432
450
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
433
451
  private lastAcknowledgedUserInputAt = 0;
452
+ // TASKBUBBLE-DUP: per-content last-ack timestamps so the same dispatched
453
+ // prompt acked twice in quick succession (the worker buffers the first
454
+ // send during bootstrap/busy, then a redelivery — dispatch-confirm-timeout
455
+ // requeue or a reconcile re-dispatch — fires a SECOND send_chat before the
456
+ // outbound queue drains) collapses to ONE user bubble. Keyed on the trimmed
457
+ // content; an entry older than USER_INPUT_ACK_DEDUP_WINDOW_MS is treated as
458
+ // a fresh, intentional resend and is NOT suppressed.
459
+ private recentUserInputAcks = new Map<string, number>();
434
460
  private lastNativeSourceCanonicalCheckAt = 0;
435
461
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
436
462
  private cachedSqliteDb: {
@@ -1056,7 +1082,31 @@ export class CliProviderInstance implements ProviderInstance {
1056
1082
  if (!content) return;
1057
1083
 
1058
1084
  const receivedAt = Date.now();
1085
+
1086
+ // TASKBUBBLE-DUP: collapse a redelivered dispatch to one bubble. A single
1087
+ // mesh_send_task can reach this instance as TWO send_chat calls when the
1088
+ // first injection is buffered during bootstrap/busy and a retry (dispatch-
1089
+ // confirm-timeout requeue, or a reconcile re-dispatch) fires before the
1090
+ // outbound queue drains. The previous dedupKey hashed receivedAt, so the
1091
+ // two acks produced different keys and BOTH bubbled. Suppress an identical
1092
+ // content ack seen within USER_INPUT_ACK_DEDUP_WINDOW_MS; a later resend of
1093
+ // the same text (beyond the window) is a genuine new turn and still shows.
1094
+ const ackContentKey = shortHash(`${this.instanceId}:${content}`, 24);
1095
+ const lastAckAt = this.recentUserInputAcks.get(ackContentKey);
1096
+ if (lastAckAt !== undefined && receivedAt - lastAckAt <= USER_INPUT_ACK_DEDUP_WINDOW_MS) {
1097
+ // Refresh the timestamp so a steady stream of redeliveries keeps
1098
+ // collapsing, and prune stale entries to bound the map size.
1099
+ this.recentUserInputAcks.set(ackContentKey, receivedAt);
1100
+ this.pruneRecentUserInputAcks(receivedAt);
1101
+ return;
1102
+ }
1103
+ this.recentUserInputAcks.set(ackContentKey, receivedAt);
1104
+ this.pruneRecentUserInputAcks(receivedAt);
1105
+
1059
1106
  this.lastAcknowledgedUserInputAt = receivedAt;
1107
+ // The runtimeMessages dedupKey stays per-call unique (includes receivedAt)
1108
+ // so a genuine resend of the same text after the window appends a fresh
1109
+ // bubble; redelivery within the window is already suppressed above.
1060
1110
  const dedupKey = `user_input_ack:${shortHash(`${this.instanceId}:${content}:${receivedAt}`, 24)}`;
1061
1111
  this.appendRuntimeMessage(buildChatMessage({
1062
1112
  role: 'user',
@@ -1074,6 +1124,14 @@ export class CliProviderInstance implements ProviderInstance {
1074
1124
  } as ChatMessage), dedupKey);
1075
1125
  }
1076
1126
 
1127
+ /** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
1128
+ private pruneRecentUserInputAcks(now: number): void {
1129
+ if (this.recentUserInputAcks.size <= 1) return;
1130
+ for (const [key, at] of this.recentUserInputAcks) {
1131
+ if (now - at > USER_INPUT_ACK_DEDUP_WINDOW_MS) this.recentUserInputAcks.delete(key);
1132
+ }
1133
+ }
1134
+
1077
1135
  dispose(): void {
1078
1136
  this.adapter.shutdown();
1079
1137
  this.monitor.reset();
@@ -2004,8 +2062,17 @@ export class CliProviderInstance implements ProviderInstance {
2004
2062
  previousStatus: this.lastStatus,
2005
2063
  };
2006
2064
  const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2007
- const flushDelay = ownsExternalHistory ? 0 : 3000;
2008
- LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2065
+ // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
2066
+ // transcript is authoritative). For mesh worker sessions, give the
2067
+ // generating→idle transition a short settle window so a background-child
2068
+ // false idle (quiet after a backgrounded test/command while the parent turn
2069
+ // continues) gets caught by the resume guard in flushCompletedDebounceIfFinalized
2070
+ // instead of firing an early completion the coordinator can never correct.
2071
+ const meshWorkerSession = this.isMeshWorkerSession();
2072
+ const flushDelay = ownsExternalHistory
2073
+ ? (meshWorkerSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2074
+ : 3000;
2075
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshWorker=${meshWorkerSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2009
2076
  this.scheduleCompletedDebounceFlush(flushDelay);
2010
2077
  }
2011
2078
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {