@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +148 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +147 -16
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-stale.d.ts +59 -0
- package/dist/providers/cli-provider-instance.d.ts +19 -0
- package/package.json +3 -3
- package/src/cli-adapters/cli-state-engine.ts +19 -1
- package/src/commands/chat-commands-read.ts +33 -1
- package/src/index.ts +1 -0
- package/src/mesh/mesh-completion-synthesis.ts +35 -1
- package/src/mesh/mesh-event-forwarding.ts +25 -0
- package/src/mesh/mesh-events-stale.ts +109 -0
- package/src/mesh/mesh-reconcile-loop.ts +59 -12
- package/src/providers/cli-provider-instance.ts +27 -1
|
@@ -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.
|
|
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.
|
|
53
|
-
"@adhdev/session-host-core": "1.0.28-rc.
|
|
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
|
-
|
|
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
|
@@ -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
|
|
949
|
-
// the caller then skips its own bare-payload ledger write, which lacked
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
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
|
/**
|