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

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.
@@ -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,21 @@ 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
+ hasLiveTurnPendingEvidence(): boolean;
358
373
  /**
359
374
  * PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
360
375
  * 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.16",
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.16",
53
+ "@adhdev/session-host-core": "1.0.28-rc.16",
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
  }
@@ -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,63 @@ 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
+ // CAUSAL-COMPLETION-GATE (Fix A): fail closed ONLY for the scoped auto-launch race —
691
+ // an in-window, still-'pending' task whose autoLaunch record names this exact session,
692
+ // with neither a consumed delivery nor a matching task_dispatched ledger entry. Every
693
+ // other completion (already-claimed tasks, direct dispatches, expired auto-launch
694
+ // windows) falls through unchanged to the existing dedup/terminal-ledger logic below.
695
+ const inWindowTask = findInWindowUnclaimedAutoLaunchTask(args.meshId, eventSessionId, Date.now());
696
+ if (inWindowTask) {
697
+ const causalEvidence = MeshRuntimeStore.getInstance().taskDeliveryConsumed(args.meshId, inWindowTask.id)
698
+ || hasMatchingTaskDispatchedLedgerEntry(args.meshId, inWindowTask.id, eventSessionId);
699
+ if (!causalEvidence) {
700
+ 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`);
701
+ traceMeshEventDrop('autolaunch_completion_before_causal_evidence', traceCtx, `taskId=${inWindowTask.id}`);
702
+ return {
703
+ kind: 'suppress',
704
+ result: { success: true, forwarded: 0, suppressed: true, autoLaunchCausalGateFailed: true, taskId: inWindowTask.id },
705
+ };
706
+ }
707
+ }
606
708
  const terminal = findRecentTerminalLedgerEvidence({
607
709
  meshId: args.meshId,
608
710
  sessionId: eventSessionId,
@@ -1056,6 +1158,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1056
1158
  eventNodeId,
1057
1159
  eventTimestamp,
1058
1160
  workerCoordinatorDaemonId,
1161
+ components,
1059
1162
  });
1060
1163
  if (suppression) {
1061
1164
  if (suppression.kind === 'reconcile') {
@@ -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).
@@ -42,7 +42,7 @@ import {
42
42
  claimAntigravityConversation,
43
43
  releaseAntigravityOwner,
44
44
  } from './native-history/antigravity-claim-registry.js';
45
- import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs } from './chat-message-normalization.js';
45
+ import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs, hasTrailingToolActivityAfterFinalAssistant } from './chat-message-normalization.js';
46
46
  import { workingDirBasename } from './working-dir.js';
47
47
  import { ManualAttendanceTracker } from './manual-attendance.js';
48
48
  import { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
@@ -1209,6 +1209,24 @@ export class CliProviderInstance implements ProviderInstance {
1209
1209
  return this.resolveModalParkStatus() !== null;
1210
1210
  }
1211
1211
 
1212
+ /**
1213
+ * MID-TURN-LIVE-STATE-GATE (broader false-idle RCA, mid-turn follow-up): a live,
1214
+ * synchronous re-check of whether this session's CURRENT turn genuinely still has
1215
+ * unresolved work. Public wrapper so the coordinator (mesh-event-forwarding) can
1216
+ * independently re-verify an incoming agent:generating_completed for a LOCAL session
1217
+ * before trusting it — defense-in-depth against a race where the completion emit and the
1218
+ * coordinator's receipt straddle a state change (screen-redraw parse artifact, decoupled-
1219
+ * immediate emit). Reuses the EXACT same discriminators this instance's own finalization
1220
+ * gate (getCompletedFinalizationBlock) uses — hasAdapterPendingResponse() (adapter
1221
+ * isWaitingForResponse / currentTurnScope / isProcessing() / a non-empty partial response)
1222
+ * OR isModalParked() (a live approval/choice modal) — so a session this method reports
1223
+ * pending is, by construction, one the local finalization gate would also refuse to
1224
+ * finalize right now.
1225
+ */
1226
+ hasLiveTurnPendingEvidence(): boolean {
1227
+ return this.hasAdapterPendingResponse() || this.isModalParked();
1228
+ }
1229
+
1212
1230
  /**
1213
1231
  * PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
1214
1232
  * reconcile loop must consult — the RAW adapter turn-state, with the
@@ -1824,7 +1842,24 @@ export class CliProviderInstance implements ProviderInstance {
1824
1842
  // `completionHasFinalAssistantMessage(...) && !hasAdapterPendingResponse()` pairing already
1825
1843
  // used by the no-progress monitor reconcile path.
1826
1844
  const turnClosed = !this.hasAdapterPendingResponse();
1827
- if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1845
+ // TX-FSM Stage 2.1 (KIMI-PARSED-RACE): a native-source provider that ALSO ships a
1846
+ // tui.transcriptPty scrape (kimi, like opencode/cursor-cli) keeps that scrape purely
1847
+ // for LIVE status convenience — the manifest itself says "Chat is authoritative from
1848
+ // nativeHistory; this PTY extraction only keeps live status available". But the parsed
1849
+ // scrape carries no tool-activity concept at all (transcriptPty only extracts
1850
+ // assistant/user text bullets), so an interim narration bullet Kimi renders just before
1851
+ // firing a tool call ("코드와 로그를 병행으로 확인하겠습니다.") satisfies
1852
+ // completionHasFinalAssistantMessage identically to a genuine final answer — and this
1853
+ // early-return fires BEFORE the richer external-native evidence below is ever consulted,
1854
+ // defeating the trailing-tool-activity veto and quiet-dwell guard added below it for
1855
+ // this exact defect (live Kimi declaring completion on an interim transcript while the
1856
+ // PTY kept generating). Skip the parsed short-circuit for the lease-gated canary
1857
+ // (busyLeaseGateEnabled: kimi/codex-cli today) so the authoritative native transcript is
1858
+ // judged first; codex-cli ships no transcriptPty so this is a no-op for it. Falls back to
1859
+ // the parsed evidence below when the native transcript is unresolved (fail-open, same as
1860
+ // before Stage 2.1).
1861
+ const preferNativeOverParsed = (this.adapter as any)?.chatMessagesOwnedExternally === true && this.busyLeaseGateEnabled();
1862
+ if (!preferNativeOverParsed && this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1828
1863
  return {
1829
1864
  present: turnClosed,
1830
1865
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
@@ -1850,8 +1885,19 @@ export class CliProviderInstance implements ProviderInstance {
1850
1885
  // the injected task's onTurnStarted fires, currentTurnStartedAt/currentTurnTaskId
1851
1886
  // bind to it and a real final bubble still fires completion normally.
1852
1887
  const injectedTaskGenerating = this.injectedTaskHasStartedGenerating();
1888
+ // TX-FSM Stage 2.1 (KIMI-PARSED-RACE, trailing-tool-activity veto): mirrors the
1889
+ // mesh coordinator's pollAssignedTaskTerminalEvidence guard (ledger 84594b15) on
1890
+ // the WORKER's own local evidence. A native-source transcript that captures
1891
+ // tool.call/tool.result as kind:'tool' bubbles (kimi's nativeHistory.records, after
1892
+ // the provider-manifest fix) proves the last VISIBLE assistant bubble was narration
1893
+ // ("Let me check the logs...") that fired a tool call, not the turn's genuine final
1894
+ // answer. A provider whose native transcript never records tool activity (nothing to
1895
+ // veto on) is unaffected — this can only ever turn a false present:true into false,
1896
+ // never the reverse.
1897
+ const trailingToolActivity = hasTrailingToolActivityAfterFinalAssistant(externalMessages as any);
1853
1898
  const present = injectedTaskGenerating
1854
1899
  && turnClosed
1900
+ && !trailingToolActivity
1855
1901
  && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
1856
1902
  // Dashboard tail-repair cache: this runs on EVERY completion check
1857
1903
  // (mesh AND non-mesh — the non-mesh path suppresses the
@@ -1873,6 +1919,18 @@ export class CliProviderInstance implements ProviderInstance {
1873
1919
  };
1874
1920
  }
1875
1921
 
1922
+ // TX-FSM Stage 2.1: the native transcript is unresolved (no session pinned yet, file
1923
+ // not yet written) for a provider whose parsed short-circuit was skipped above — fall
1924
+ // back to the parsed evidence now rather than reporting present:false forever. Fail-open,
1925
+ // identical to the pre-Stage-2.1 behaviour for this narrow (transcript-unavailable) case.
1926
+ if (preferNativeOverParsed && this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1927
+ return {
1928
+ present: turnClosed,
1929
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1930
+ source: 'parsed',
1931
+ };
1932
+ }
1933
+
1876
1934
  return {
1877
1935
  present: false,
1878
1936
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
@@ -2336,6 +2394,43 @@ export class CliProviderInstance implements ProviderInstance {
2336
2394
  } catch { /* defensive: dwell read is best-effort — fall through to emit */ }
2337
2395
  }
2338
2396
 
2397
+ // TX-FSM Stage 2.2 (KIMI-POST-FINAL-WEDGE): quiet-dwell protection for a
2398
+ // lease-gated NATIVE-SOURCE provider's OWN evidence
2399
+ // (source==='external-native', reached because preferNativeOverParsed skipped the
2400
+ // parsed short-circuit above). Use the authoritative transcript file's mtime snapshot,
2401
+ // produced by the SAME native-history read that supplied finalAssistantEvidence above,
2402
+ // rather than raw PTY output recency. Kimi repaints its idle prompt/status after the
2403
+ // genuine final answer, so lastOutputAt may advance forever even though wire.jsonl is
2404
+ // quiet; using that cosmetic clock wedged the pending completion. Conversely, before a
2405
+ // tool.call lands the interim narration itself has just advanced wire.jsonl, so the
2406
+ // transcript clock preserves the narrow pre-tool quiet-dwell protection.
2407
+ //
2408
+ // The snapshot is bounded and fail-open: ageMs below the existing dwell holds and
2409
+ // retries; a quiet transcript releases, while an unavailable/unresolved/clockless
2410
+ // snapshot cannot block completion. Deliberately NOT scoped to
2411
+ // allowMissingAssistantTimeout: this defect surfaces on a plain interactive session's
2412
+ // own idle/completion notification just as much as a mesh worker's, so restricting it to
2413
+ // autonomous mesh sessions would leave every non-mesh kimi session unprotected. A
2414
+ // provider outside the lease canary (or without adapterOwnsMessagesElsewhere) never
2415
+ // reaches this branch — untouched.
2416
+ if (adapterOwnsMessagesElsewhere
2417
+ && finalAssistantEvidence.source === 'external-native'
2418
+ && this.busyLeaseGateEnabled()) {
2419
+ try {
2420
+ const snapshot = this.lastTranscriptSignalSnapshot;
2421
+ const transcriptAgeMs = snapshot?.available === true
2422
+ && typeof snapshot.detail?.ageMs === 'number'
2423
+ && Number.isFinite(snapshot.detail.ageMs)
2424
+ ? snapshot.detail.ageMs
2425
+ : undefined;
2426
+ if (typeof transcriptAgeMs === 'number') {
2427
+ if (transcriptAgeMs < PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS) {
2428
+ return { reason: 'native_source_final_assistant_quiet_dwell', terminal: false };
2429
+ }
2430
+ }
2431
+ } catch { /* defensive: transcript dwell is best-effort — fail open */ }
2432
+ }
2433
+
2339
2434
  return null;
2340
2435
  }
2341
2436
 
@@ -3150,10 +3245,16 @@ export class CliProviderInstance implements ProviderInstance {
3150
3245
  // the settle window — so the single sample reads 'idle' even though the turn is still in
3151
3246
  // flight (it re-enters generating ~0.5s later). Require instead that the session stayed
3152
3247
  // CONTINUOUSLY idle since the debounce was armed: (1) no entry into a busy phase
3153
- // (busyEpoch unchanged), and (2) no new raw PTY output (lastOutputAt did not advance).
3154
- // Either signal ⇒ the idle was not continuous ⇒ cancel; the still-live turn re-arms its
3155
- // own completion when it genuinely finishes. This only ever cancels (never emits more),
3156
- // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
3248
+ // (busyEpoch unchanged), and (2) on the FIRST settle check, no new raw PTY output
3249
+ // (lastOutputAt did not advance).
3250
+ //
3251
+ // Once a finalization block has claimed the pending completion (loggedBlockReason is
3252
+ // set), its own bounded retry/evidence policy owns release. Re-applying the raw-output
3253
+ // cancellation on every retry lets cosmetic idle redraws (notably Kimi's prompt/status
3254
+ // repaint) delete the pending after a genuine final assistant has landed. The adapter
3255
+ // emits no second idle edge for that already-idle turn, so deletion becomes a permanent
3256
+ // internal-generating wedge. Busy re-entry remains an unconditional cancel above:
3257
+ // busyEpoch is the structural continuity signal and must win even after a hold.
3157
3258
  if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
3158
3259
  LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
3159
3260
  if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
@@ -3169,7 +3270,8 @@ export class CliProviderInstance implements ProviderInstance {
3169
3270
  return;
3170
3271
  }
3171
3272
  const latestOutputAt = typeof (latestStatus as any)?.lastOutputAt === 'number' ? (latestStatus as any).lastOutputAt as number : undefined;
3172
- if (typeof pending.lastOutputAtArm === 'number'
3273
+ if (!pending.loggedBlockReason
3274
+ && typeof pending.lastOutputAtArm === 'number'
3173
3275
  && typeof latestOutputAt === 'number'
3174
3276
  && latestOutputAt > pending.lastOutputAtArm) {
3175
3277
  LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);