@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.
- package/dist/index.js +309 -57
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +309 -57
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +53 -0
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/providers/cli-provider-instance.d.ts +3 -0
- package/package.json +2 -2
- package/src/commands/high-family/mesh-coordinator-launch.ts +67 -2
- package/src/commands/med-family/cli-agent.ts +25 -13
- package/src/commands/med-family/fast-forward.ts +80 -48
- package/src/mesh/coordinator-prompt.ts +145 -0
- package/src/mesh/mesh-events-coordinator.ts +45 -1
- package/src/mesh/mesh-ledger.ts +5 -0
- package/src/mesh/mesh-reconcile-loop.ts +114 -1
- package/src/providers/cli-provider-instance.ts +69 -2
|
@@ -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
|
-
|
|
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);
|
package/src/mesh/mesh-ledger.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
2008
|
-
|
|
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') {
|