@adhdev/daemon-core 1.0.28-rc.15 → 1.0.28-rc.17

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;
@@ -31,6 +31,7 @@ interface AwaitClaimBackoffState {
31
31
  }
32
32
  export declare function __resetAutoLaunchAwaitClaimBackoffForTests(): void;
33
33
  export declare function __seedAutoLaunchAwaitClaimBackoffForTests(meshId: string, taskId: string, state: AwaitClaimBackoffState): void;
34
+ export declare function isIdleSessionState(state: any): boolean;
34
35
  /** Active assignments that hold the one-active-per-node / global-parallel invariant
35
36
  * (everything except read-only diagnoses, which run unbounded by the write cap). */
36
37
  export declare function activeWriteAssignedCount(meshId: string): number;
@@ -355,6 +355,40 @@ export declare class CliProviderInstance implements ProviderInstance {
355
355
  private isTransientToolConsent;
356
356
  /** True when this session is parked on a modal awaiting a human answer. */
357
357
  isModalParked(): boolean;
358
+ /**
359
+ * MID-TURN-LIVE-STATE-GATE (broader false-idle RCA, mid-turn follow-up): a live,
360
+ * synchronous re-check of whether this session's CURRENT turn genuinely still has
361
+ * unresolved work. Public wrapper so the coordinator (mesh-event-forwarding) can
362
+ * independently re-verify an incoming agent:generating_completed for a LOCAL session
363
+ * before trusting it — defense-in-depth against a race where the completion emit and the
364
+ * coordinator's receipt straddle a state change (screen-redraw parse artifact, decoupled-
365
+ * immediate emit). Reuses the EXACT same discriminators this instance's own finalization
366
+ * gate (getCompletedFinalizationBlock) uses — hasAdapterPendingResponse() (adapter
367
+ * isWaitingForResponse / currentTurnScope / isProcessing() / a non-empty partial response)
368
+ * OR isModalParked() (a live approval/choice modal) — so a session this method reports
369
+ * pending is, by construction, one the local finalization gate would also refuse to
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.
390
+ */
391
+ hasLiveTurnPendingEvidence(): boolean;
358
392
  /**
359
393
  * PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
360
394
  * reconcile loop must consult — the RAW adapter turn-state, with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.28-rc.15",
3
+ "version": "1.0.28-rc.17",
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.15",
53
- "@adhdev/session-host-core": "1.0.28-rc.15",
52
+ "@adhdev/mesh-shared": "1.0.28-rc.17",
53
+ "@adhdev/session-host-core": "1.0.28-rc.17",
54
54
  "@agentclientprotocol/sdk": "^0.16.1",
55
55
  "ajv": "^8.20.0",
56
56
  "ajv-formats": "^3.0.1",
@@ -1161,7 +1161,13 @@ export class ProviderCliAdapter implements CliAdapter {
1161
1161
  : 1;
1162
1162
  this.staticIdlePollStreak += 1;
1163
1163
  if (this.staticIdlePollStreak >= requiredStreak) {
1164
- this.engine.confirmPollStaticIdle('poll_static_idle');
1164
+ if (this.engine.confirmPollStaticIdle('poll_static_idle')) {
1165
+ // This transition originates from the read-only status poll rather
1166
+ // than PTY output. Propagate it through the same instance callback as
1167
+ // output-driven FSM transitions so completion bookkeeping observes
1168
+ // generating→idle and can arm/finalize the pending completion.
1169
+ this.onStatusChange?.();
1170
+ }
1165
1171
  this.staticIdlePollStreak = 0;
1166
1172
  }
1167
1173
  } else {
@@ -15,6 +15,7 @@ import { getRecentActivity } from '../../config/recent-activity.js';
15
15
  import { getSavedProviderSessions } from '../../config/saved-sessions.js';
16
16
  import { listProviderHistorySessions } from '../../config/chat-history.js';
17
17
  import { buildMeshWorkerRelayStamp, readNonEmptyString } from '../../mesh/mesh-events-utils.js';
18
+ import { isIdleSessionState } from '../../mesh/mesh-queue-assignment.js';
18
19
  import { LOG } from '../../logging/logger.js';
19
20
  import { readStringValue } from '../router.js';
20
21
  import type { MedFamilyContext, MedFamilyHandler } from './types.js';
@@ -231,8 +232,8 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
231
232
  // completion event sits in the pending queue until a read_chat reconcile drains
232
233
  // it. Stamping here makes a reused/relaunched remote session relay-safe at
233
234
  // dispatch time even when it was not launched via mesh_launch_session.
235
+ const dispatchSessionId = readStringValue(args?.targetSessionId, (args as any)?.sessionId, (args as any)?.instanceId);
234
236
  {
235
- const dispatchSessionId = readStringValue(args?.targetSessionId, (args as any)?.sessionId, (args as any)?.instanceId);
236
237
  const dispatchMeshContext = args?.meshContext as Record<string, unknown> | undefined;
237
238
  if (dispatchSessionId && dispatchMeshContext) {
238
239
  try {
@@ -289,18 +290,36 @@ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
289
290
  // by the local queue-claim gate too so remote and local dispatch agree on when to defer.
290
291
  const { shouldDeferDispatchForBootstrap } = await import('../../mesh/worktree-bootstrap-config.js');
291
292
  if (shouldDeferDispatchForBootstrap(nodeObj as any)) {
292
- return {
293
- success: false,
294
- recoverable: true,
295
- dispatched: false,
296
- code: 'mesh_node_bootstrap_pending',
297
- reason: 'bootstrap_still_running',
298
- nodeId: dispatchNodeId,
299
- meshId: dispatchMeshId,
300
- ...(readStringValue(meshCtx?.taskId) ? { taskId: readStringValue(meshCtx?.taskId) } : {}),
301
- error: `Node '${dispatchNodeId}' worktree bootstrap is still running; a task injected now would land in the session input buffer before the provider is ready to consume it and be silently lost. Dispatch deferred.`,
302
- nextAction: 'Wait for the worktree_bootstrap_complete event (or poll mesh_status until the node session is ready), then re-send the task with mesh_send_task. Alternatively use mesh_enqueue_task so the queue auto-assigns it once a ready session is available.',
303
- };
293
+ // BOOTSTRAP-POLICY-CONSISTENCY (Fix B, rc.15 orchestration RCA): the node-level
294
+ // worktreeBootstrap.status flag is COARSE — it can read stale 'running' (the
295
+ // detached async persist chain lags the inline stamp) even though the explicit
296
+ // mesh_send_task caller already independently confirmed, via its own live
297
+ // get_status_metadata probe, that the PINNED target session is genuinely ready.
298
+ // Refusing that dispatch anyway is a false block. Narrowest safe override: only
299
+ // when the caller named a specific target session AND that session's live status
300
+ // — re-probed here via the same isIdleSessionState liveness check the queue
301
+ // assignment gate uses is independently confirmed idle right now. A session
302
+ // that is starting / generating / waiting_approval / any other non-idle status
303
+ // never qualifies, so this can never mask a genuinely half-built worktree.
304
+ const dispatchSessionState = dispatchSessionId
305
+ ? ctx.deps.instanceManager?.getInstance?.(dispatchSessionId)?.getState?.()
306
+ : undefined;
307
+ const sessionConfirmedReady = !!dispatchSessionState && isIdleSessionState(dispatchSessionState);
308
+ if (!sessionConfirmedReady) {
309
+ return {
310
+ success: false,
311
+ recoverable: true,
312
+ dispatched: false,
313
+ code: 'mesh_node_bootstrap_pending',
314
+ reason: 'bootstrap_still_running',
315
+ nodeId: dispatchNodeId,
316
+ meshId: dispatchMeshId,
317
+ ...(readStringValue(meshCtx?.taskId) ? { taskId: readStringValue(meshCtx?.taskId) } : {}),
318
+ error: `Node '${dispatchNodeId}' worktree bootstrap is still running; a task injected now would land in the session input buffer before the provider is ready to consume it and be silently lost. Dispatch deferred.`,
319
+ nextAction: 'Wait for the worktree_bootstrap_complete event (or poll mesh_status until the node session is ready), then re-send the task with mesh_send_task. Alternatively use mesh_enqueue_task so the queue auto-assigns it once a ready session is available.',
320
+ };
321
+ }
322
+ LOG.info('MeshQueue', `Overriding stale worktreeBootstrap 'running' flag for node ${dispatchNodeId}: explicit target session ${dispatchSessionId} is independently confirmed idle/ready — dispatching mesh_send_task directly.`);
304
323
  }
305
324
  } catch { /* best-effort — if the bootstrap probe fails, fall through and dispatch */ }
306
325
  }
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) {
@@ -505,6 +505,50 @@ function resolveActiveDirectDispatchTaskId(meshId: string, sessionId: string): s
505
505
  }
506
506
  }
507
507
 
508
+ // ---------------------------------------------------------------------------
509
+ // CAUSAL-COMPLETION-GATE (Fix A, rc.15 orchestration RCA): auto-launch sessions can emit a
510
+ // spurious agent:generating_completed before the task was ever delivered to / consumed by the
511
+ // session — a freshly spawned worker's boot/idle-prompt output can be misread by the provider's
512
+ // own completion detector as a finished turn (totalMessages=0: the worker never actually
513
+ // processed the task). The provider's weak/false-idle self-report is not guaranteed to catch a
514
+ // pure boot artifact, so this is an INDEPENDENT, coordinator-side check scoped to the exact race:
515
+ // a task still 'pending' whose in-window autoLaunch record names THIS session. For that narrow
516
+ // case we require causal evidence — the delivery was consumed (taskDeliveryConsumed) or a
517
+ // task_dispatched ledger entry proves the turn actually started — before letting the completion
518
+ // through. An already-claimed/dispatched task (the overwhelming majority of completions) never
519
+ // matches (its status is no longer 'pending'), so this never broadens into general suppression.
520
+ // ---------------------------------------------------------------------------
521
+
522
+ // The in-window, not-yet-claimed queue task (if any) whose autoLaunch record names this exact
523
+ // session. Bounded to AUTO_LAUNCH_AWAIT_CLAIM_MS — the same window the claim-churn/respawn guards
524
+ // use — so a task whose autoLaunch attempt is long expired (and has since moved on to backoff /
525
+ // direct-dispatch fallback, or a fresh auto-launch attempt) is never matched here.
526
+ function findInWindowUnclaimedAutoLaunchTask(meshId: string, sessionId: string, nowMs: number): MeshWorkQueueEntry | undefined {
527
+ return getQueue(meshId, { status: ['pending'] }).find(task => {
528
+ const al = task.autoLaunch;
529
+ if (!al || al.status !== 'completed' || !al.sessionId) return false;
530
+ if (!sessionIdsEquivalent(al.sessionId, sessionId)) return false;
531
+ const launchedAtMs = Date.parse(al.updatedAt);
532
+ return Number.isFinite(launchedAtMs) && nowMs - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
533
+ });
534
+ }
535
+
536
+ // Turn-start evidence distinct from taskDeliveryConsumed: a task_dispatched ledger entry naming
537
+ // BOTH this taskId and this sessionId proves the coordinator itself recorded a genuine dispatch
538
+ // of this task onto this session. A task still 'pending' (the only case this gate applies to)
539
+ // normally has no such entry — task_dispatched is written when the claim transitions the row to
540
+ // 'assigned' — so this is a defensive alternate signal, not the expected path.
541
+ function hasMatchingTaskDispatchedLedgerEntry(meshId: string, taskId: string, sessionId: string): boolean {
542
+ const entries = readLedgerEntries(meshId, { tail: 200 });
543
+ for (let i = entries.length - 1; i >= 0; i--) {
544
+ const entry = entries[i];
545
+ if (entry.kind !== 'task_dispatched') continue;
546
+ if (!sessionIdsEquivalent(entry.sessionId, sessionId)) continue;
547
+ if (readNonEmptyString(entry.payload?.taskId) === taskId) return true;
548
+ }
549
+ return false;
550
+ }
551
+
508
552
  // Coordinator-side suppression/reconcile gate for an incoming mesh event. Each clause is a
509
553
  // closed dedup/suppression concern that only inspects the event + already-resolved context and
510
554
  // either (a) returns a `suppress` result the caller forwards verbatim, (b) returns a `reconcile`
@@ -528,12 +572,13 @@ function evaluateMeshEventSuppression(
528
572
  eventNodeId: string;
529
573
  eventTimestamp: number | null;
530
574
  workerCoordinatorDaemonId: string | undefined;
575
+ components: DaemonComponents;
531
576
  },
532
577
  ):
533
578
  | { kind: 'suppress'; result: { success: true; forwarded: 0; suppressed: true; [extra: string]: unknown } }
534
579
  | { kind: 'reconcile'; metadataEvent: Record<string, unknown> }
535
580
  | null {
536
- const { traceCtx, eventSessionId, eventNodeId, eventTimestamp, workerCoordinatorDaemonId } = ctx;
581
+ const { traceCtx, eventSessionId, eventNodeId, eventTimestamp, workerCoordinatorDaemonId, components } = ctx;
537
582
 
538
583
  const intentionalCleanupStop = shouldSuppressIntentionalCleanupStop({
539
584
  event: args.event,
@@ -603,6 +648,88 @@ function evaluateMeshEventSuppression(
603
648
  }
604
649
  }
605
650
  if (args.event === 'agent:generating_completed' && eventSessionId) {
651
+ // MID-TURN-LIVE-STATE-GATE (broader false-idle RCA, mid-turn follow-up): before trusting
652
+ // ANY incoming completion, independently re-verify a resolvable LOCAL live instance's
653
+ // CURRENT turn state — not just the event's self-reported diagnostic — via the exact same
654
+ // discriminators the instance's OWN emission gate (getCompletedFinalizationBlock) uses
655
+ // (adapter pending-response: isWaitingForResponse / currentTurnScope / isProcessing() /
656
+ // a non-empty partial response; or a live approval/choice modal). A screen-redraw parse
657
+ // artifact, a decoupled-immediate emit, or a TOCTOU between emit and receipt can let a
658
+ // genuinely mid-turn session's completion reach here; this is a stateless, synchronous
659
+ // safety net for that race.
660
+ //
661
+ // Scope: ONLY when components.instanceManager.getInstance(sessionId) resolves a live
662
+ // instance exposing hasLiveTurnPendingEvidence() — i.e. a co-located worker/coordinator or
663
+ // single-daemon (standalone) topology. A REMOTE session (no live instance on this daemon)
664
+ // is untouched by this gate — absence of a resolvable instance is NOT treated as pending
665
+ // evidence, so a remote/unknown session is never fail-closed here; its existing async
666
+ // reconcile-loop protections (evaluateEarlyIdleTranscriptArm, transcript trailing-tool-
667
+ // activity checks) remain the operative safety net, unchanged.
668
+ //
669
+ // Bounded / never wedges: this SUPPRESSES only the ONE premature event and persists no new
670
+ // hold state — it relies on the adapter's OWN bounded finalization retry (which already
671
+ // owns re-emitting once genuinely idle, COMPLETED_FINALIZATION_MAX_WAIT_MS) plus the
672
+ // existing reconcile-loop completion nets (PHASE 4 transcript synth, assigned-stranded
673
+ // deadline, acked-hold death backstop) as fail-open backstops if the adapter's re-emit is
674
+ // itself ever lost. A later genuine completion (pending evidence cleared) is NOT touched by
675
+ // this gate and is processed normally.
676
+ const liveInstance = components.instanceManager?.getInstance?.(eventSessionId) as
677
+ { hasLiveTurnPendingEvidence?: () => boolean } | undefined;
678
+ if (typeof liveInstance?.hasLiveTurnPendingEvidence === 'function') {
679
+ let midTurnPending = false;
680
+ try { midTurnPending = liveInstance.hasLiveTurnPendingEvidence() === true; } catch { /* fail open — never block on a diagnostic throw */ }
681
+ if (midTurnPending) {
682
+ LOG.info('MeshEvents', `Suppressed agent:generating_completed for session ${eventSessionId} (mesh ${args.meshId}): live adapter re-check shows the turn is still pending (mid-tool / streaming / modal) — treating as a premature/redraw-race emit; the adapter's own finalization retry or the reconcile loop's transcript evidence will surface the real completion`);
683
+ traceMeshEventDrop('mid_turn_live_state_pending', traceCtx);
684
+ return {
685
+ kind: 'suppress',
686
+ result: { success: true, forwarded: 0, suppressed: true, midTurnLiveStatePending: true },
687
+ };
688
+ }
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
+ }
715
+ // CAUSAL-COMPLETION-GATE (Fix A): fail closed ONLY for the scoped auto-launch race —
716
+ // an in-window, still-'pending' task whose autoLaunch record names this exact session,
717
+ // with neither a consumed delivery nor a matching task_dispatched ledger entry. Every
718
+ // other completion (already-claimed tasks, direct dispatches, expired auto-launch
719
+ // windows) falls through unchanged to the existing dedup/terminal-ledger logic below.
720
+ const inWindowTask = findInWindowUnclaimedAutoLaunchTask(args.meshId, eventSessionId, Date.now());
721
+ if (inWindowTask) {
722
+ const causalEvidence = MeshRuntimeStore.getInstance().taskDeliveryConsumed(args.meshId, inWindowTask.id)
723
+ || hasMatchingTaskDispatchedLedgerEntry(args.meshId, inWindowTask.id, eventSessionId);
724
+ if (!causalEvidence) {
725
+ LOG.warn('MeshEvents', `Suppressed premature agent:generating_completed for auto-launch session ${eventSessionId} (mesh ${args.meshId}): task ${inWindowTask.id} delivery not yet consumed and no turn-start evidence — likely a boot artifact before the task was ever dispatched`);
726
+ traceMeshEventDrop('autolaunch_completion_before_causal_evidence', traceCtx, `taskId=${inWindowTask.id}`);
727
+ return {
728
+ kind: 'suppress',
729
+ result: { success: true, forwarded: 0, suppressed: true, autoLaunchCausalGateFailed: true, taskId: inWindowTask.id },
730
+ };
731
+ }
732
+ }
606
733
  const terminal = findRecentTerminalLedgerEvidence({
607
734
  meshId: args.meshId,
608
735
  sessionId: eventSessionId,
@@ -1056,6 +1183,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1056
1183
  eventNodeId,
1057
1184
  eventTimestamp,
1058
1185
  workerCoordinatorDaemonId,
1186
+ components,
1059
1187
  });
1060
1188
  if (suppression) {
1061
1189
  if (suppression.kind === 'reconcile') {
@@ -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();
@@ -1331,7 +1331,12 @@ function isTerminalSessionStatus(status: string): boolean {
1331
1331
  return ['stopped', 'failed', 'terminated', 'exited', 'closed'].includes(status);
1332
1332
  }
1333
1333
 
1334
- function isIdleSessionState(state: any): boolean {
1334
+ // Exported (Fix B, rc.15 orchestration RCA) so the explicit mesh_send_task dispatch path
1335
+ // (med-family/cli-agent.ts) can reuse the SAME status/liveness probe to independently confirm a
1336
+ // pinned target session is genuinely ready before overriding the coarse worktreeBootstrap
1337
+ // 'running' defer for that one session. Never returns true for 'starting' / 'waiting_approval' /
1338
+ // 'generating' / any other non-idle status — those still refuse the override.
1339
+ export function isIdleSessionState(state: any): boolean {
1335
1340
  const status = readNonEmptyString(state?.status).toLowerCase();
1336
1341
  if (isTerminalSessionStatus(status)) return false;
1337
1342
  return status === 'idle' || state?.activeChat?.status === 'waiting_input';
@@ -1387,6 +1392,19 @@ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nod
1387
1392
 
1388
1393
  function isLaunchableNode(node: any): boolean {
1389
1394
  if (!node || node.status === 'disabled' || node.status === 'removed') return false;
1395
+ // BOOTSTRAP-POLICY-CONSISTENCY (Fix B, rc.15 orchestration RCA): a worktree node whose
1396
+ // bootstrap is still 'running' must be excluded from auto-launch candidacy, not merely
1397
+ // deferred at claim time. Before this, isLaunchableNode passed a bootstrapping node through,
1398
+ // so maybeAutoLaunchOneQueueSession could spawn a session (launch_cli) on it — an ORPHAN,
1399
+ // since tryAssignQueueTask's own bootstrap gate (mirrors this same predicate) then defers the
1400
+ // claim onto that freshly-spawned session anyway, leaving it stranded with nothing assigned
1401
+ // while the node's real bootstrap-driven session comes up separately. Reusing
1402
+ // shouldDeferDispatchForBootstrap (rather than a raw status check) keeps this in agreement
1403
+ // with the claim gate's stale-running backstop: a 'running' stamp old enough and verified
1404
+ // git-clean is treated as silently complete and does not block launch.
1405
+ if (shouldDeferDispatchForBootstrap(node as { worktreeBootstrap?: { status?: string; startedAt?: string; updatedAt?: string; completedAt?: string }; workspace?: string })) {
1406
+ return false;
1407
+ }
1390
1408
  // Delegate the health gate to the shared resolver so the auto-launch gate and the
1391
1409
  // MAGI fan-out planner agree on exactly what "launchable health" means (online /
1392
1410
  // unknown / absent pass; degraded / offline / dirty / wrong_branch are blocked).