@adhdev/daemon-core 1.0.28-rc.16 → 1.0.28-rc.18

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.
@@ -1,4 +1,52 @@
1
1
  import type { MeshLedgerKind } from './mesh-ledger.js';
2
+ export interface TranscriptSynthCausalAdmission {
3
+ /**
4
+ * Synchronous re-check of a resolvable LOCAL live instance's CURRENT turn state — the
5
+ * same hasLiveTurnPendingEvidence() discriminator the 3a48f660 mid-turn gate uses on the
6
+ * native-event path. A pending verdict VETOES an eager transcript synth. Fail-open by
7
+ * construction: absent (remote/unknown session, separate-process caller), not a function,
8
+ * or throwing → no veto; the caller's bounded transcript evidence/settle behavior remains
9
+ * the operative net, so a missing transcript source never wedges.
10
+ */
11
+ liveTurnPendingEvidence?: () => boolean;
12
+ /**
13
+ * The caller is a BOUNDED last-resort backstop firing at its max-wait (the acked-hold
14
+ * death deadline, the delivered-no-turn redrive deadline). A bounded backstop overrides
15
+ * the live-pending veto — preserving the genuine-final fail-open / max-wait semantics —
16
+ * but NEVER overrides the trailing-tool veto (a stale weak interim summary must not
17
+ * become final merely because a timeout fired while newer transcript/tool activity
18
+ * exists).
19
+ */
20
+ boundedBackstop?: boolean;
21
+ /**
22
+ * The caller's transcript read shows tool/terminal activity AFTER the latest final-looking
23
+ * assistant bubble (hasTrailingToolActivityAfterFinalAssistant) — the bubble is interim
24
+ * narration ("Let me explore…"), the turn is still executing. Absolute veto: the worker is
25
+ * demonstrably alive and mid-turn, so even a bounded backstop keeps holding (the hold is
26
+ * still released by the session-death read-failure backstop and the stranded-reclaim /
27
+ * orphan-prune nets, so this cannot wedge forever).
28
+ */
29
+ trailingToolActivityAfterFinalAssistant?: boolean;
30
+ }
31
+ export type TranscriptSynthAdmissionVerdict = {
32
+ admitted: true;
33
+ } | {
34
+ admitted: false;
35
+ reason: 'live_turn_pending_evidence' | 'trailing_tool_activity_after_final_assistant';
36
+ };
37
+ export declare function evaluateTranscriptSynthAdmission(admission?: TranscriptSynthCausalAdmission): TranscriptSynthAdmissionVerdict;
38
+ /**
39
+ * Resolve the liveTurnPendingEvidence probe for `sessionId` from a components-shaped object
40
+ * (anything exposing instanceManager.getInstance). Returns undefined when no live instance
41
+ * resolves or the instance does not expose hasLiveTurnPendingEvidence — the fail-open
42
+ * remote/unknown case. Shared by every transcript-synth caller so the probe is resolved ONE
43
+ * way (UNIFY: same discriminator as the 3a48f660 native-event gate).
44
+ */
45
+ export declare function resolveLiveTurnPendingEvidence(components: {
46
+ instanceManager?: {
47
+ getInstance?: (sessionId: string) => unknown;
48
+ };
49
+ } | undefined, sessionId: string): (() => boolean) | undefined;
2
50
  export declare function findRecentTerminalLedgerEvidence(args: {
3
51
  meshId: string;
4
52
  sessionId?: string;
@@ -64,6 +112,17 @@ export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
64
112
  * Direct-dispatch callers omit it → the gates apply exactly as before (no regression).
65
113
  */
66
114
  preValidatedTranscriptEvidence?: boolean;
115
+ /**
116
+ * MID-TURN-CAUSAL-ADMISSION (rc.16): causal admission evidence for the unified guard —
117
+ * see TranscriptSynthCausalAdmission. Every transcript-synth caller (PHASE 4 reconcile,
118
+ * watchdog transcript propagation, MCP mesh_status poll) passes what it knows; the choke
119
+ * point evaluates it BEFORE writing the terminal ledger or queuing the coordinator
120
+ * completion. A veto returns { reconciled: false, reason } — callers treat that exactly
121
+ * like their other not-yet-proven outcomes (hold and re-evaluate next tick), so the held
122
+ * completion re-evaluates the causal evidence every pass and releases exactly once after
123
+ * the state clears (the terminal-ledger dedup above makes the release idempotent).
124
+ */
125
+ causalAdmission?: TranscriptSynthCausalAdmission;
67
126
  source?: string;
68
127
  }): {
69
128
  reconciled: boolean;
@@ -368,6 +368,25 @@ export declare class CliProviderInstance implements ProviderInstance {
368
368
  * OR isModalParked() (a live approval/choice modal) — so a session this method reports
369
369
  * pending is, by construction, one the local finalization gate would also refuse to
370
370
  * finalize right now.
371
+ *
372
+ * NATIVE-TRAILING-TOOL-GATE (rc.16 follow-up): the three adapter-state discriminators
373
+ * above are blind to a background shell tool that keeps a turn alive without the adapter
374
+ * reporting a pending response — e.g. a backgrounded `sleep 40 &` between narration
375
+ * bubbles on a write-lag native-source provider (claude-cli), which is deliberately
376
+ * un-floored (noExternalTranscriptSource omitted, mission f2f6da1b owner decision) so its
377
+ * transcript write-lag emits promptly. That un-flooring is correct for the ordinary
378
+ * fraction-of-a-second trail, but it also means the growth-hold / busy-lease protections
379
+ * in getCompletedFinalizationBlock never engage for claude-cli, so an interim final-
380
+ * LOOKING bubble followed by continuing tool calls can still finalize as a completion
381
+ * while the transcript demonstrably shows the turn still executing. Close that gap here,
382
+ * narrowly: for a native-source provider only, reuse the SAME bounded transcript read the
383
+ * class's own completion judgment already performs (probeNativeTranscriptSignals) and
384
+ * apply the SAME trailing-tool-activity veto the transcript-synth admission choke point
385
+ * uses (hasTrailingToolActivityAfterFinalAssistant) — the latest final-looking assistant
386
+ * bubble followed by tool/terminal activity is interim narration, not a turn end. Fail-open
387
+ * by construction: a non-native-source class (probe returns null), an unresolved
388
+ * transcript, or a read error never reaches the veto, so a missing/unavailable transcript
389
+ * can never wedge a session as "pending" forever.
371
390
  */
372
391
  hasLiveTurnPendingEvidence(): boolean;
373
392
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.28-rc.16",
3
+ "version": "1.0.28-rc.18",
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",
@@ -49,8 +49,8 @@
49
49
  "author": "vilmire",
50
50
  "license": "AGPL-3.0-or-later",
51
51
  "dependencies": {
52
- "@adhdev/mesh-shared": "1.0.28-rc.16",
53
- "@adhdev/session-host-core": "1.0.28-rc.16",
52
+ "@adhdev/mesh-shared": "1.0.28-rc.18",
53
+ "@adhdev/session-host-core": "1.0.28-rc.18",
54
54
  "@agentclientprotocol/sdk": "^0.16.1",
55
55
  "ajv": "^8.20.0",
56
56
  "ajv-formats": "^3.0.1",
@@ -729,7 +729,25 @@ export class CliStateEngine {
729
729
  if (!session) return;
730
730
 
731
731
  const { status, messages } = session;
732
- const modal = (session as any).activeModal ?? session.modal ?? null;
732
+ // RC17-APPROVAL-NO-MODAL: a provider's own detectStatus/parseSession script can
733
+ // report status='waiting_approval' with a modal object whose buttons array is
734
+ // empty (a half-rendered approval frame, or — as observed live on a Claude
735
+ // session with no actionable modal anywhere in the raw PTY — a bare status flip
736
+ // with no real prompt at all). Previously this raw modal was latched straight
737
+ // into this.activeModal and dispatched through applyWaitingApproval's
738
+ // ALREADY-actionable branch (line ~1042 below), which sets status +
739
+ // fires agent:waiting_approval unconditionally — the "!modal" hysteresis/warn
740
+ // path (the one place button-validity was actually enforced) only runs when the
741
+ // modal is null, not when it merely has no buttons. Treat a buttons-empty modal
742
+ // as no modal here so it flows into that same proven hysteresis: it warns, waits
743
+ // out approvalCooldown, and never fires an event unless a LATER settle pass
744
+ // observes a genuinely actionable modal. Mirrors hasNonEmptyCliModalButtons used
745
+ // elsewhere (resolveModal, stabilizeFlappingApprovalStatus) — same predicate,
746
+ // applied at the point the modal first enters the FSM.
747
+ const rawModal = (session as any).activeModal ?? session.modal ?? null;
748
+ const modal = rawModal && Array.isArray(rawModal.buttons) && rawModal.buttons.some(
749
+ (button: unknown) => typeof button === 'string' && button.trim().length > 0,
750
+ ) ? rawModal : null;
733
751
  const parsedStatus = (session as any).parsedStatus ?? null;
734
752
  const parsedMessages = normalizeCliParsedMessages(messages, {
735
753
  scope: null,
@@ -24,7 +24,7 @@ import {
24
24
  type ChatSourceTransitionCause,
25
25
  } from '../chat/source-resolver.js';
26
26
  import type { ChatMessage } from '../types.js';
27
- import { filterUserFacingChatMessages, isActivityChatMessage, isUserFacingChatMessage, normalizeChatMessages } from '../providers/chat-message-normalization.js';
27
+ import { filterUserFacingChatMessages, isActivityChatMessage, isUserFacingChatMessage, normalizeChatMessages, hasTrailingToolActivityAfterFinalAssistant } from '../providers/chat-message-normalization.js';
28
28
  import {
29
29
  READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS,
30
30
  type RuntimeChatMessageMerger,
@@ -2480,11 +2480,43 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2480
2480
  });
2481
2481
  }
2482
2482
  }
2483
+ // RC17-NATIVE-FINAL-ASSISTANT-MIDTURN: hasFinalVisibleAssistantMessage only
2484
+ // checks that the LAST visible native-transcript message is a non-empty
2485
+ // assistant/model bubble — it has no notion of whether that bubble is
2486
+ // actually the end of the turn. A live Claude repro showed this reconciling
2487
+ // generating→idle on an INTERIM narration bubble ("Starting the collector
2488
+ // now, before the two-turn protocol.") emitted mid-turn, well before the
2489
+ // real two-turn protocol/tool work ran — exactly the false-completion class
2490
+ // rc.16 (9452bd03/6677a565) closed for the mesh-event ingress via
2491
+ // hasTrailingToolActivityAfterFinalAssistant + hasLiveTurnPendingEvidence.
2492
+ // This read_chat status ingress is a SEPARATE consumer of the same
2493
+ // native-final-assistant signal (it feeds the `status` field returned to
2494
+ // read_chat/dashboard, not the mesh agent:generating_completed event) and
2495
+ // never got the equivalent veto. This reconciliation exists precisely for a
2496
+ // PTY/adapter status detector that never transitions to idle on its own (see
2497
+ // read-chat-completed-session-fallback.test.ts's antigravity stuck-busy
2498
+ // case: isProcessing() stays true forever, empty PTY messages, and native
2499
+ // history is the only signal that ever resolves it) — so gating on the
2500
+ // adapter's own pending-response bit here would neuter this reconciliation
2501
+ // for that exact intended case. Apply only STRUCTURAL, evidence-based vetoes
2502
+ // instead, mirroring rc.16's hasLiveTurnPendingEvidence: trailing tool/
2503
+ // terminal activity after the final-looking assistant bubble, scanned in (a)
2504
+ // the native messages being judged — the transcript's own admission that its
2505
+ // "final" bubble kept going — and (b) the live PTY-parsed tail
2506
+ // (returnedMessages), which carries no native-transcript write-lag and is
2507
+ // exactly how a live Claude repro was caught: the native JSONL's last row was
2508
+ // still the interim narration bubble (no trailing tool row landed there yet),
2509
+ // but the PTY had already rendered the very next "Auto-approved: Yes\nBash
2510
+ // command" tool activity. The antigravity stuck-busy case has an EMPTY PTY
2511
+ // message list, so (b) is a no-op there (the function short-circuits false on
2512
+ // an empty/absent array) and the existing regression is unaffected.
2483
2513
  if (
2484
2514
  isGeneratingLikeStatus(selectedStatus)
2485
2515
  && selectedTranscriptAuthority === 'provider'
2486
2516
  && !hasNonEmptyModalButtons(activeModal)
2487
2517
  && hasFinalVisibleAssistantMessage(selectedMessages)
2518
+ && !hasTrailingToolActivityAfterFinalAssistant(selectedMessages as any)
2519
+ && !hasTrailingToolActivityAfterFinalAssistant(returnedMessages as any)
2488
2520
  ) {
2489
2521
  selectedStatus = 'idle';
2490
2522
  selectedMessages = finalizeStreamingMessagesWhenIdle(selectedMessages, selectedStatus);
package/src/index.ts CHANGED
@@ -536,6 +536,7 @@ export {
536
536
  CHAT_MESSAGE_ACTIVITY_SOURCES,
537
537
  CHAT_MESSAGE_INTERNAL_SOURCES,
538
538
  classifyChatMessageVisibility,
539
+ hasTrailingToolActivityAfterFinalAssistant,
539
540
  isUserFacingChatMessage,
540
541
  isActivityChatMessage,
541
542
  isInternalChatMessage,
@@ -25,7 +25,7 @@ import { daemonIdListIncludes } from './mesh-reconcile-identity.js';
25
25
  import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
26
26
  import { readLedgerEntries } from './mesh-ledger.js';
27
27
  import { pruneStaleDirectDispatches } from './mesh-active-work.js';
28
- import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
28
+ import { reconcileDirectDispatchCompletionFromTranscript, resolveLiveTurnPendingEvidence } from './mesh-events-stale.js';
29
29
  import { extractFinalAssistantSummaryEvidence, hasTrailingToolActivityAfterFinalAssistant, readChatMessageTimestampMs } from '../providers/chat-message-normalization.js';
30
30
  import type { ChatMessage } from '../types.js';
31
31
  import {
@@ -230,6 +230,29 @@ export async function reconcileUnterminatedDirectDispatches(
230
230
  const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
231
231
  const evidence = extractFinalAssistantSummaryEvidence(messages);
232
232
 
233
+ // MID-TURN-CAUSAL-ADMISSION (rc.16, trailing-tool veto): the latest final-LOOKING
234
+ // assistant bubble is followed by tool/terminal activity — the worker emitted interim
235
+ // narration ("Let me find…") and is still running tools; the bubble is a preamble,
236
+ // not a turn end. This is the exact false-completion shape of the verified incidents
237
+ // (weak synth after an interim progress sentence while tools continued), and PHASE 4
238
+ // previously never ran the veto pollAssignedTaskTerminalEvidence has. The veto applies
239
+ // to EVERY synth path below — never-acked first-idle, acked fast-track, AND the death
240
+ // deadline: a stale weak interim summary must not become final merely because a
241
+ // timeout fired while newer transcript/tool activity exists. The fast-track streak is
242
+ // reset so the continuous idle-with-final-assistant run must restart from the genuine
243
+ // final bubble. This cannot wedge: the next tick re-reads and re-evaluates (a genuine
244
+ // final assistant lands AFTER the last tool call, completing one tick later), and the
245
+ // hold is still released by the session-death read-failure backstop / stranded-reclaim
246
+ // / orphan-prune nets if the worker truly goes away.
247
+ if (hasTrailingToolActivityAfterFinalAssistant(messages)) {
248
+ setHoldState(synthKey, mesh.id, { liveConfirmedSinceAck: true, consecutiveReadFailures: 0 });
249
+ LOG.info('MeshReconcile', `Mid-turn causal admission: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) — the latest final-looking assistant bubble is followed by trailing tool/terminal activity (interim narration; the turn is still executing); holding the transcript synth`);
250
+ traceMeshEventDrop('reconcile_synth_veto_trailing_tool_activity', {
251
+ taskId, sessionId, nodeId, meshId: mesh.id, event: 'agent:generating_completed',
252
+ });
253
+ continue;
254
+ }
255
+
233
256
  if (isAcked) {
234
257
  const ackedAtMs = Date.parse(readNonEmptyString(dispatch.updatedAt));
235
258
  const sinceAckMs = Number.isFinite(ackedAtMs) ? nowMs - ackedAtMs : Number.POSITIVE_INFINITY;
@@ -357,6 +380,17 @@ export async function reconcileUnterminatedDirectDispatches(
357
380
  // The ledger-recovered dispatching-coordinator daemon (inside the reconcile fn)
358
381
  // takes PRIORITY over this arg; this remains the best-available fallback.
359
382
  ...(coordinatorDaemonId ? { targetCoordinatorDaemonId: coordinatorDaemonId } : {}),
383
+ // MID-TURN-CAUSAL-ADMISSION (rc.16): pass the LOCAL live adapter's synchronous
384
+ // turn-state probe into the unified choke point — a pending verdict vetoes this
385
+ // eager synth (never-acked first-idle / acked fast-track). The death-deadline
386
+ // backstop is the bounded max-wait net and overrides the veto (genuine-final
387
+ // fail-open preserved); a remote/missing live source resolves to undefined and
388
+ // fails open onto the bounded transcript evidence above. The trailing-tool veto
389
+ // already ran at the top of this tick.
390
+ causalAdmission: {
391
+ liveTurnPendingEvidence: resolveLiveTurnPendingEvidence(components, sessionId),
392
+ boundedBackstop: backstopKind === 'ackedHoldDeathDeadlineFired',
393
+ },
360
394
  source: 'daemon_reconcile_transcript_completion',
361
395
  });
362
396
  if (result.reconciled) {
@@ -687,6 +687,31 @@ function evaluateMeshEventSuppression(
687
687
  };
688
688
  }
689
689
  }
690
+ // NO-DISPATCH-NATIVE-COMPLETION-GATE (rc.16 follow-up): a session that was launched
691
+ // but never given ANY task can still emit its own native agent:generating_completed off
692
+ // a startup/greeting artifact (e.g. cursor-cli's "→ Plan, search, build anything" idle
693
+ // prompt right after boot) — the WARMUPGAP note on markSessionTerminal below already
694
+ // recognizes this shape (no echoed taskId, no active assignment) but only skips the
695
+ // dispatch-row side effect; the event itself still becomes a coordinator-visible
696
+ // task_completed. Suppress it outright here, narrowly: the event carries no taskId, the
697
+ // session holds no active assignment (queue OR direct-dispatch), AND the session has NO
698
+ // terminal ledger history at all (a session with a PRIOR terminal has been dispatched at
699
+ // least once before and falls through to the existing terminal/dedup logic below
700
+ // unchanged). Structural/causal only — no message-content inspection. A completion that
701
+ // is NOT weak (a real final summary / worker result, not a bare false-idle status flag)
702
+ // still passes through: a session that was somehow handed a genuine answer without a
703
+ // tracked dispatch must not have that answer silently dropped.
704
+ if (!readNonEmptyString(args.metadataEvent.taskId)
705
+ && !sessionHasActiveAssignment(args.meshId, eventSessionId)
706
+ && !findRecentTerminalLedgerEvidence({ meshId: args.meshId, sessionId: eventSessionId, nodeId: eventNodeId || undefined })
707
+ && isWeakCompletionEvidence(args.metadataEvent)) {
708
+ LOG.info('MeshEvents', `Suppressed agent:generating_completed for session ${eventSessionId} (mesh ${args.meshId}): no taskId, no active assignment, and no prior terminal ledger evidence — a pre-dispatch startup/greeting artifact, not a real task completion`);
709
+ traceMeshEventDrop('no_dispatch_native_completion', traceCtx);
710
+ return {
711
+ kind: 'suppress',
712
+ result: { success: true, forwarded: 0, suppressed: true, noDispatchNativeCompletion: true },
713
+ };
714
+ }
690
715
  // CAUSAL-COMPLETION-GATE (Fix A): fail closed ONLY for the scoped auto-launch race —
691
716
  // an in-window, still-'pending' task whose autoLaunch record names this exact session,
692
717
  // with neither a consumed delivery nor a matching task_dispatched ledger entry. Every
@@ -5,6 +5,7 @@ import { markSessionDeliveriesTerminal } from './mesh-delivery-policy.js';
5
5
  import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
6
6
  import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence, buildMeshSystemMessage } from './mesh-events-utils.js';
7
7
  import { recordDebugTrace } from '../logging/debug-trace.js';
8
+ import { LOG } from '../logging/logger.js';
8
9
  import { meshNodeIdMatches, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
9
10
 
10
11
  // EARLYNOTIFY-GATEBYPASS (d): every completed-emit producer that bypasses the CLI-provider
@@ -40,6 +41,88 @@ function recordSynthCompletionGateTrace(stage: string, payload: Record<string, u
40
41
  const DIRECT_DISPATCH_RECONCILE_GRACE_MS = 60_000;
41
42
  const DIRECT_DISPATCH_IDLE_SESSION_RECONCILE_GRACE_MS = 120_000;
42
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // MID-TURN-CAUSAL-ADMISSION (rc.16 unified completion ingress)
46
+ // ---------------------------------------------------------------------------
47
+ // Verified incidents: the coordinator received a WEAK synthesized completion mid-turn —
48
+ // after an interim narration bubble ("Let me find…") — while the worker's raw PTY still
49
+ // showed `Working (43s)` and further tool calls continued. Every transcript-synth ingress
50
+ // (reconcile-loop PHASE 4, the assigned-stranded watchdog's transcript propagation, the MCP
51
+ // mesh_status poll) funnels through reconcileDirectDispatchCompletionFromTranscript, which
52
+ // queues the coordinator completion DIRECTLY — bypassing the injectMeshSystemMessage →
53
+ // evaluateMeshEventSuppression → hasLiveTurnPendingEvidence gate (3a48f660) that protects
54
+ // the native provider-event path. evaluateTranscriptSynthAdmission is the ONE reusable
55
+ // causal admission point closing that bypass: every synth caller passes its causal evidence
56
+ // through the `causalAdmission` arg and the choke point enforces it uniformly.
57
+ export interface TranscriptSynthCausalAdmission {
58
+ /**
59
+ * Synchronous re-check of a resolvable LOCAL live instance's CURRENT turn state — the
60
+ * same hasLiveTurnPendingEvidence() discriminator the 3a48f660 mid-turn gate uses on the
61
+ * native-event path. A pending verdict VETOES an eager transcript synth. Fail-open by
62
+ * construction: absent (remote/unknown session, separate-process caller), not a function,
63
+ * or throwing → no veto; the caller's bounded transcript evidence/settle behavior remains
64
+ * the operative net, so a missing transcript source never wedges.
65
+ */
66
+ liveTurnPendingEvidence?: () => boolean;
67
+ /**
68
+ * The caller is a BOUNDED last-resort backstop firing at its max-wait (the acked-hold
69
+ * death deadline, the delivered-no-turn redrive deadline). A bounded backstop overrides
70
+ * the live-pending veto — preserving the genuine-final fail-open / max-wait semantics —
71
+ * but NEVER overrides the trailing-tool veto (a stale weak interim summary must not
72
+ * become final merely because a timeout fired while newer transcript/tool activity
73
+ * exists).
74
+ */
75
+ boundedBackstop?: boolean;
76
+ /**
77
+ * The caller's transcript read shows tool/terminal activity AFTER the latest final-looking
78
+ * assistant bubble (hasTrailingToolActivityAfterFinalAssistant) — the bubble is interim
79
+ * narration ("Let me explore…"), the turn is still executing. Absolute veto: the worker is
80
+ * demonstrably alive and mid-turn, so even a bounded backstop keeps holding (the hold is
81
+ * still released by the session-death read-failure backstop and the stranded-reclaim /
82
+ * orphan-prune nets, so this cannot wedge forever).
83
+ */
84
+ trailingToolActivityAfterFinalAssistant?: boolean;
85
+ }
86
+
87
+ export type TranscriptSynthAdmissionVerdict =
88
+ | { admitted: true }
89
+ | { admitted: false; reason: 'live_turn_pending_evidence' | 'trailing_tool_activity_after_final_assistant' };
90
+
91
+ export function evaluateTranscriptSynthAdmission(admission?: TranscriptSynthCausalAdmission): TranscriptSynthAdmissionVerdict {
92
+ if (!admission) return { admitted: true };
93
+ if (admission.trailingToolActivityAfterFinalAssistant === true) {
94
+ return { admitted: false, reason: 'trailing_tool_activity_after_final_assistant' };
95
+ }
96
+ if (!admission.boundedBackstop && typeof admission.liveTurnPendingEvidence === 'function') {
97
+ let pending = false;
98
+ try { pending = admission.liveTurnPendingEvidence() === true; } catch { /* fail open — never block on a diagnostic throw */ }
99
+ if (pending) return { admitted: false, reason: 'live_turn_pending_evidence' };
100
+ }
101
+ return { admitted: true };
102
+ }
103
+
104
+ /**
105
+ * Resolve the liveTurnPendingEvidence probe for `sessionId` from a components-shaped object
106
+ * (anything exposing instanceManager.getInstance). Returns undefined when no live instance
107
+ * resolves or the instance does not expose hasLiveTurnPendingEvidence — the fail-open
108
+ * remote/unknown case. Shared by every transcript-synth caller so the probe is resolved ONE
109
+ * way (UNIFY: same discriminator as the 3a48f660 native-event gate).
110
+ */
111
+ export function resolveLiveTurnPendingEvidence(
112
+ components: { instanceManager?: { getInstance?: (sessionId: string) => unknown } } | undefined,
113
+ sessionId: string,
114
+ ): (() => boolean) | undefined {
115
+ try {
116
+ const instance = components?.instanceManager?.getInstance?.(sessionId) as
117
+ { hasLiveTurnPendingEvidence?: () => boolean } | undefined;
118
+ if (typeof instance?.hasLiveTurnPendingEvidence !== 'function') return undefined;
119
+ const probe = instance.hasLiveTurnPendingEvidence.bind(instance);
120
+ return () => probe();
121
+ } catch {
122
+ return undefined; // resolution itself failed → fail open
123
+ }
124
+ }
125
+
43
126
  export function findRecentTerminalLedgerEvidence(args: {
44
127
  meshId: string;
45
128
  sessionId?: string;
@@ -229,6 +312,17 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
229
312
  * Direct-dispatch callers omit it → the gates apply exactly as before (no regression).
230
313
  */
231
314
  preValidatedTranscriptEvidence?: boolean;
315
+ /**
316
+ * MID-TURN-CAUSAL-ADMISSION (rc.16): causal admission evidence for the unified guard —
317
+ * see TranscriptSynthCausalAdmission. Every transcript-synth caller (PHASE 4 reconcile,
318
+ * watchdog transcript propagation, MCP mesh_status poll) passes what it knows; the choke
319
+ * point evaluates it BEFORE writing the terminal ledger or queuing the coordinator
320
+ * completion. A veto returns { reconciled: false, reason } — callers treat that exactly
321
+ * like their other not-yet-proven outcomes (hold and re-evaluate next tick), so the held
322
+ * completion re-evaluates the causal evidence every pass and releases exactly once after
323
+ * the state clears (the terminal-ledger dedup above makes the release idempotent).
324
+ */
325
+ causalAdmission?: TranscriptSynthCausalAdmission;
232
326
  source?: string;
233
327
  }): { reconciled: boolean; kind?: MeshLedgerKind; alreadyTerminal?: boolean; workerResult?: unknown; ledgerEntryId?: string; reason?: string } {
234
328
  const finalSummary = readNonEmptyString(args.finalSummary);
@@ -251,6 +345,21 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
251
345
  return { reconciled: false, alreadyTerminal: true, reason: 'terminal_ledger_entry_exists' };
252
346
  }
253
347
 
348
+ // MID-TURN-CAUSAL-ADMISSION (rc.16): the unified guard every gate-bypassing synth must
349
+ // pass. Ordered BEFORE any ledger write / queue so a vetoed synth leaves no trace other
350
+ // than the completion-gate trace (observability parity with the synth-fire trace below).
351
+ const admission = evaluateTranscriptSynthAdmission(args.causalAdmission);
352
+ if (!admission.admitted) {
353
+ LOG.info('MeshEvents', `Transcript synth vetoed for task ${args.taskId} session ${args.sessionId} (source ${args.source || 'direct_task_transcript_reconciliation'}): ${admission.reason} — holding the completion; it re-evaluates on the next reconcile pass`);
354
+ recordSynthCompletionGateTrace('synth-veto', {
355
+ producer: 'transcript_reconcile',
356
+ source: args.source || 'direct_task_transcript_reconciliation',
357
+ taskId: args.taskId,
358
+ reason: admission.reason,
359
+ });
360
+ return { reconciled: false, reason: admission.reason };
361
+ }
362
+
254
363
  const nodeId = readNonEmptyString(args.nodeId) || dispatch?.nodeId;
255
364
  const providerType = readNonEmptyString(args.providerType) || dispatch?.providerType || readNonEmptyString(dispatch?.payload.providerType);
256
365
  const completedAt = args.completedAt || new Date().toISOString();
@@ -64,7 +64,7 @@ import { getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-
64
64
  import { resolveSessionBusyVerdict, runContinuousAutoFastForwardScan, runPendingCoordinatorCatchupScan } from './mesh-queue-assignment.js';
65
65
  import { readLedgerEntries } from './mesh-ledger.js';
66
66
  import type { MeshLedgerEntry } from './mesh-ledger.js';
67
- import { findTerminalLedgerEvidenceForTask, reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
67
+ import { findTerminalLedgerEvidenceForTask, reconcileDirectDispatchCompletionFromTranscript, resolveLiveTurnPendingEvidence } from './mesh-events-stale.js';
68
68
  import {
69
69
  resolveCoordinatorDaemonIds,
70
70
  daemonHostsMesh,
@@ -945,18 +945,24 @@ async function evaluateEarlyIdleTranscriptArm(
945
945
  // synth is marked WEAK so the worker's own later emit can still supersede it rather than being
946
946
  // dropped as a duplicate. That is the dedup guarantee: at most one [System] completion surfaces.
947
947
  //
948
- // Returns true if a terminal ledger for this task now exists (freshly written OR already present) —
949
- // the caller then skips its own bare-payload ledger write, which lacked the finalSummary entirely.
948
+ // Returns 'propagated' if a terminal ledger for this task now exists (freshly written OR
949
+ // already present) — the caller then skips its own bare-payload ledger write, which lacked
950
+ // the finalSummary entirely. Returns 'deferred' when the MID-TURN-CAUSAL-ADMISSION guard
951
+ // vetoed the synth on live mid-turn evidence — the caller must HOLD (no row flip, no bare
952
+ // ledger write) and re-evaluate next tick. Returns 'unavailable' when propagation could not
953
+ // run — the caller falls back to its bare ledger write (unchanged legacy behavior).
950
954
  function propagateWatchdogTranscriptCompletion(
955
+ components: DaemonComponents,
951
956
  meshId: string,
952
957
  row: { id: string; assignedNodeId?: string; assignedSessionId?: string; assignedProviderType?: string; dispatchTimestamp?: string },
953
958
  evidence: AssignedTaskTerminalEvidence,
954
959
  source: string,
955
- ): boolean {
960
+ opts?: { boundedBackstop?: boolean },
961
+ ): 'propagated' | 'deferred' | 'unavailable' {
956
962
  const sessionId = readNonEmptyString(row.assignedSessionId) || evidence.sessionId;
957
963
  if (!sessionId || !readNonEmptyString(evidence.finalSummary)) {
958
964
  // No routable session or no summary to carry — fall back to the caller's bare ledger write.
959
- return false;
965
+ return 'unavailable';
960
966
  }
961
967
  try {
962
968
  const result = reconcileDirectDispatchCompletionFromTranscript({
@@ -972,15 +978,30 @@ function propagateWatchdogTranscriptCompletion(
972
978
  // The watchdog poll already enforced idle + post-dispatch + no-trailing-tool + streak;
973
979
  // skip the reconcile's own grace/transcript_not_proven gates (dedup backstops remain).
974
980
  preValidatedTranscriptEvidence: true,
981
+ // MID-TURN-CAUSAL-ADMISSION (rc.16): the transcript poll's structural evidence can
982
+ // still straddle a genuinely mid-turn LOCAL worker (the incident shape: transcript
983
+ // reads idle-with-final-assistant while the raw PTY is mid-tool). Route the live
984
+ // adapter's synchronous probe through the unified choke point. The EARLY-IDLE
985
+ // caller is an eager path (no opts) → a pending verdict defers; the redrive-deadline
986
+ // caller passes boundedBackstop (the max-wait net) → the veto yields, preserving the
987
+ // genuine-final fail-open semantics.
988
+ causalAdmission: {
989
+ liveTurnPendingEvidence: resolveLiveTurnPendingEvidence(components, sessionId),
990
+ boundedBackstop: opts?.boundedBackstop === true,
991
+ },
975
992
  source,
976
993
  });
977
994
  // reconciled → freshly queued the coordinator completion; alreadyTerminal → a terminal
978
995
  // ledger (a real emit that raced in, or a prior watchdog synth) is already present. Either
979
996
  // way a finalSummary-bearing completion has been (or will be) delivered; the caller must NOT
980
997
  // add its summary-less ledger row on top.
981
- return result.reconciled || result.alreadyTerminal === true;
998
+ if (result.reconciled || result.alreadyTerminal === true) return 'propagated';
999
+ if (result.reason === 'live_turn_pending_evidence' || result.reason === 'trailing_tool_activity_after_final_assistant') {
1000
+ return 'deferred'; // mid-turn causal evidence — HOLD; do not complete this tick
1001
+ }
1002
+ return 'unavailable';
982
1003
  } catch {
983
- return false; // best-effort — fall back to the caller's bare ledger write
1004
+ return 'unavailable'; // best-effort — fall back to the caller's bare ledger write
984
1005
  }
985
1006
  }
986
1007
 
@@ -1062,6 +1083,29 @@ async function recoverStrandedAssignedDispatches(
1062
1083
  minFinalAssistantAgeMs: ASSIGNED_IDLE_TRANSCRIPT_COMPLETE_MS,
1063
1084
  });
1064
1085
  if (terminalEvidence) {
1086
+ // MID-TURN-CAUSAL-ADMISSION (rc.16): propagate FIRST. When the unified guard
1087
+ // defers (the LOCAL live adapter still reports the turn pending — transcript
1088
+ // idle-with-final-assistant can straddle a genuinely mid-turn worker), HOLD
1089
+ // the entire completion: no row flip, no bare ledger write, no queued event.
1090
+ // The streak resets so it re-accumulates; the next qualifying tick re-polls
1091
+ // and re-evaluates, releasing exactly once after the live state clears.
1092
+ const propagation = propagateWatchdogTranscriptCompletion(
1093
+ components, meshId, row, terminalEvidence, 'early_idle_transcript_evidence',
1094
+ );
1095
+ if (propagation === 'deferred') {
1096
+ assignedIdleFinalAssistantSince.delete(idleTranscriptStreakKey);
1097
+ LOG.info('MeshReconcile', `Deferred early transcript completion for task ${row.id} on mesh ${meshId} `
1098
+ + `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}): the live adapter still reports `
1099
+ + `the turn pending (mid-tool / streaming / modal) — holding; the completion re-evaluates next tick`);
1100
+ traceMeshEventDrop('early_idle_completion_deferred_live_pending', {
1101
+ taskId: row.id,
1102
+ sessionId: row.assignedSessionId,
1103
+ nodeId: row.assignedNodeId,
1104
+ meshId,
1105
+ event: 'agent:generating_completed',
1106
+ });
1107
+ continue;
1108
+ }
1065
1109
  assignedIdleFinalAssistantSince.delete(idleTranscriptStreakKey);
1066
1110
  updateTaskStatus(meshId, row.id, terminalEvidence.outcome);
1067
1111
  // WATCHDOG-FINALSUMMARY-LOST: propagate the completion to the coordinator WITH the
@@ -1069,9 +1113,7 @@ async function recoverStrandedAssignedDispatches(
1069
1113
  // generating_completed produces — instead of only tracing a structural DROP. When
1070
1114
  // propagation delivered (or a terminal ledger already exists), skip the bare
1071
1115
  // summary-less ledger write below; only fall back to it if propagation could not run.
1072
- const propagated = propagateWatchdogTranscriptCompletion(
1073
- meshId, row, terminalEvidence, 'early_idle_transcript_evidence',
1074
- );
1116
+ const propagated = propagation === 'propagated';
1075
1117
  if (!propagated && !findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id })) {
1076
1118
  try {
1077
1119
  appendLedgerEntry(meshId, {
@@ -1388,9 +1430,14 @@ async function recoverStrandedAssignedDispatches(
1388
1430
  // WATCHDOG-FINALSUMMARY-LOST: propagate the finalSummary-bearing completion to the
1389
1431
  // coordinator (same [System] notification as the native path); only fall back to the
1390
1432
  // bare summary-less ledger write when propagation could not run.
1391
- const propagated = propagateWatchdogTranscriptCompletion(
1392
- meshId, row, terminalEvidence, 'redrive_deadline_transcript_evidence',
1433
+ // MID-TURN-CAUSAL-ADMISSION: this is the BOUNDED max-wait net (the 15-min
1434
+ // delivered-no-turn deadline) — boundedBackstop preserves the genuine-final
1435
+ // fail-open semantics against the live-pending veto.
1436
+ const propagation = propagateWatchdogTranscriptCompletion(
1437
+ components, meshId, row, terminalEvidence, 'redrive_deadline_transcript_evidence',
1438
+ { boundedBackstop: true },
1393
1439
  );
1440
+ const propagated = propagation === 'propagated';
1394
1441
  if (!propagated && !findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id })) {
1395
1442
  try {
1396
1443
  appendLedgerEntry(meshId, {
@@ -1222,9 +1222,35 @@ export class CliProviderInstance implements ProviderInstance {
1222
1222
  * OR isModalParked() (a live approval/choice modal) — so a session this method reports
1223
1223
  * pending is, by construction, one the local finalization gate would also refuse to
1224
1224
  * finalize right now.
1225
+ *
1226
+ * NATIVE-TRAILING-TOOL-GATE (rc.16 follow-up): the three adapter-state discriminators
1227
+ * above are blind to a background shell tool that keeps a turn alive without the adapter
1228
+ * reporting a pending response — e.g. a backgrounded `sleep 40 &` between narration
1229
+ * bubbles on a write-lag native-source provider (claude-cli), which is deliberately
1230
+ * un-floored (noExternalTranscriptSource omitted, mission f2f6da1b owner decision) so its
1231
+ * transcript write-lag emits promptly. That un-flooring is correct for the ordinary
1232
+ * fraction-of-a-second trail, but it also means the growth-hold / busy-lease protections
1233
+ * in getCompletedFinalizationBlock never engage for claude-cli, so an interim final-
1234
+ * LOOKING bubble followed by continuing tool calls can still finalize as a completion
1235
+ * while the transcript demonstrably shows the turn still executing. Close that gap here,
1236
+ * narrowly: for a native-source provider only, reuse the SAME bounded transcript read the
1237
+ * class's own completion judgment already performs (probeNativeTranscriptSignals) and
1238
+ * apply the SAME trailing-tool-activity veto the transcript-synth admission choke point
1239
+ * uses (hasTrailingToolActivityAfterFinalAssistant) — the latest final-looking assistant
1240
+ * bubble followed by tool/terminal activity is interim narration, not a turn end. Fail-open
1241
+ * by construction: a non-native-source class (probe returns null), an unresolved
1242
+ * transcript, or a read error never reaches the veto, so a missing/unavailable transcript
1243
+ * can never wedge a session as "pending" forever.
1225
1244
  */
1226
1245
  hasLiveTurnPendingEvidence(): boolean {
1227
- return this.hasAdapterPendingResponse() || this.isModalParked();
1246
+ if (this.hasAdapterPendingResponse() || this.isModalParked()) return true;
1247
+ try {
1248
+ const probe = this.probeNativeTranscriptSignals();
1249
+ if (probe?.snapshot?.available === true && Array.isArray(probe.messages)) {
1250
+ if (hasTrailingToolActivityAfterFinalAssistant(probe.messages as ChatMessage[])) return true;
1251
+ }
1252
+ } catch { /* fail open — a probe error must never fabricate pending evidence */ }
1253
+ return false;
1228
1254
  }
1229
1255
 
1230
1256
  /**