@adhdev/daemon-core 1.0.18-rc.15 → 1.0.18-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.
@@ -58,6 +58,28 @@ export declare function extractFinalAssistantSummaryEvidence(messages: ChatMessa
58
58
  * grace gate) on top of this structural check.
59
59
  */
60
60
  export declare function selectFinalAssistantTurnEndMessage(messages: ChatMessage[] | null | undefined): ChatMessage | null;
61
+ /**
62
+ * EARLY-IDLE-COMPLETION-FALSE-POSITIVE — trailing tool/terminal activity detector.
63
+ *
64
+ * True when the transcript's LAST non-empty user-facing assistant bubble is followed
65
+ * by one or more TOOL / TERMINAL activity bubbles (a tool_use/command the assistant
66
+ * fired AFTER its text) — i.e. the assistant emitted a preamble ("Let me explore…"),
67
+ * then started running Read/Grep, so the turn is still executing and its answer has
68
+ * not landed. Callers that would otherwise promote that preamble to a turn-end summary
69
+ * off a momentary (startup-grace / inter-tool) idle read use this as a veto.
70
+ *
71
+ * Deliberately NARROW so the pure-PTY completion rescue is preserved:
72
+ * - Only TOOL/TERMINAL activity trailing the assistant vetoes. A trailing THOUGHT or
73
+ * status bubble does NOT (a finished turn can end on an internal thought), matching
74
+ * selectFinalAssistantTurnEndMessage's skip set minus the "still-working" signals.
75
+ * - A genuinely FINISHED worker (final assistant last, no trailing tool activity —
76
+ * the kimi pure-PTY continuous-idle shape) returns false, so its early completion
77
+ * is untouched.
78
+ *
79
+ * Returns false when there is no final assistant bubble at all (the caller's
80
+ * selectFinalAssistantTurnEndMessage already handles that as "not a turn end").
81
+ */
82
+ export declare function hasTrailingToolActivityAfterFinalAssistant(messages: ChatMessage[] | null | undefined): boolean;
61
83
  export declare const BUILTIN_CHAT_MESSAGE_KINDS: readonly ["standard", "thought", "tool", "terminal", "system"];
62
84
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];
63
85
  export type ChatMessageKind = BuiltinChatMessageKind | (string & {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.15",
3
+ "version": "1.0.18-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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.15",
51
- "@adhdev/session-host-core": "1.0.18-rc.15",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.16",
51
+ "@adhdev/session-host-core": "1.0.18-rc.16",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -26,7 +26,7 @@ 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
28
  import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
29
- import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
29
+ import { extractFinalAssistantSummaryEvidence, hasTrailingToolActivityAfterFinalAssistant } from '../providers/chat-message-normalization.js';
30
30
  import type { ChatMessage } from '../types.js';
31
31
  import {
32
32
  getMeshV2BackstopCounters,
@@ -483,6 +483,17 @@ export async function pollAssignedTaskTerminalEvidence(
483
483
  const evidence = extractFinalAssistantSummaryEvidence(messages);
484
484
  if (!evidence.finalSummary) return null; // idle but no assistant result yet → not a turn-end
485
485
 
486
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE (poll defense-in-depth): a momentary idle read
487
+ // (startup-grace, or the sliver between an assistant preamble and the tool it fires) can
488
+ // show an assistant "Let me explore…" bubble FOLLOWED by trailing Read/Grep tool_use — a
489
+ // turn that is still executing, not a turn-end. selectFinalAssistantTurnEndMessage skips
490
+ // those trailing tool bubbles and would promote the preamble, so guard here: if a
491
+ // tool/terminal activity bubble trails the final assistant message, the worker is mid-turn
492
+ // — refuse the completion (fall through to the caller's reclaim/grace path). A genuinely
493
+ // finished pure-PTY worker ends on its final assistant with no trailing tool activity, so
494
+ // the kimi rescue is preserved.
495
+ if (hasTrailingToolActivityAfterFinalAssistant(messages)) return null;
496
+
486
497
  // Stale-summary guard (same bar as PHASE 4): a reused session's transcript tail may hold a
487
498
  // PRIOR task's summary. Require the final assistant message to be dated at/after THIS task's
488
499
  // dispatch. When either timestamp is unusable, do NOT short-circuit — fall through so we never
@@ -69,13 +69,15 @@ import {
69
69
  resolveCoordinatorDaemonIds,
70
70
  daemonHostsMesh,
71
71
  resolveCoordinatorSelfIds,
72
+ daemonIdListIncludes,
72
73
  } from './mesh-reconcile-identity.js';
73
74
  import {
74
75
  resolveAutoPruneMinAgeMs,
75
76
  resolvePendingHeldDrainEscalateMs,
76
77
  resolveReconcileIntervalMs,
77
78
  } from './mesh-reconcile-config.js';
78
- import { pullRemoteNodeQueues, pullPendingEventsFromNode } from './mesh-remote-event-pull.js';
79
+ import { pullRemoteNodeQueues, pullPendingEventsFromNode, reprobeWorkerStatus } from './mesh-remote-event-pull.js';
80
+ import { isPurePtyTranscriptProvider } from '../cli-adapters/provider-cli-shared.js';
79
81
  import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
80
82
  import {
81
83
  reconcileUnterminatedDirectDispatches,
@@ -787,6 +789,118 @@ function assignedRowLiveStatusIsAwaitingApproval(
787
789
  }
788
790
  }
789
791
 
792
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE — resolve whether the assigned session's provider is
793
+ // the pure-PTY transcript class (kimi and kin — no native history, no provider authority), from
794
+ // the LOCAL instance when it is present. Returns:
795
+ // true → local instance is a pure-PTY provider (the class the early rescue exists for)
796
+ // false → local instance is NOT pure-PTY (native / provider-authoritative)
797
+ // undefined → no local instance (remote worker / gone / id-form skew) — pure-PTY unknowable here
798
+ // A remote pure-PTY worker is handled by the reprobe path (a fresh-idle read positively confirms
799
+ // the session is settled before the streak accrues), not by this local-only capability check.
800
+ function resolveLocalSessionPurePty(components: DaemonComponents, sessionId: string): boolean | undefined {
801
+ try {
802
+ const instances = components.instanceManager?.getByCategory?.('cli') || [];
803
+ const inst = instances.find((i: any) => {
804
+ const sid = readNonEmptyString(i?.getState?.().instanceId);
805
+ return sid && sessionIdsEquivalent(sid, sessionId);
806
+ }) as { provider?: unknown } | undefined;
807
+ if (!inst) return undefined; // remote / gone / id-form skew — not locally observable
808
+ const provider = inst.provider;
809
+ if (!provider || typeof provider !== 'object') return undefined;
810
+ return isPurePtyTranscriptProvider(provider as Parameters<typeof isPurePtyTranscriptProvider>[0]);
811
+ } catch {
812
+ return undefined;
813
+ }
814
+ }
815
+
816
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE — decide whether the early transcript-evidence completion
817
+ // streak may ACCRUE this tick for an assigned row. This hardens the original arm gate, which only
818
+ // checked `resolveSessionBusyVerdict(...) !== 'GENERATING'` — a gate a REMOTE worker (local verdict
819
+ // UNKNOWN, not GENERATING) passed unconditionally, so its "8s continuous idle" streak degenerated to
820
+ // 8s of wall-clock even while the worker was genuinely mid-turn, and a startup-grace boot-window idle
821
+ // (before the first turn ever started) also passed. The row was then early-completed off a preamble.
822
+ //
823
+ // Two added requirements, defense-in-depth on top of the existing confirmed-delivery / no-terminal-
824
+ // ledger / bound-session gate the caller applies:
825
+ // (a) POSITIVE idle evidence, not merely "not GENERATING". A LOCAL IDLE_CONFIRMED verdict qualifies
826
+ // directly. A remote/UNKNOWN verdict is RE-PROBED with a fresh read_chat status this tick: only
827
+ // a definitively 'idle' read lets the streak accrue; any active status (generating/waiting/…),
828
+ // or an inconclusive read, resets it. This is what stops the remote worker's mid-turn busy from
829
+ // silently passing as "continuous idle".
830
+ // (b) TURN-START evidence, so a boot-window / never-consumed idle cannot early-complete a preamble.
831
+ // taskDeliveryConsumed===true (the worker emitted agent:generating_started) satisfies it. When
832
+ // the delivery was never consumed, only a LOCAL pure-PTY provider — the exact class that never
833
+ // emits generating_started and that this rescue exists for — is still allowed; a native/
834
+ // provider-authoritative worker that hasn't started its turn is NOT armed (it will complete via
835
+ // its own emit or the normal grace). A remote-not-consumed worker whose provider class is
836
+ // unknowable locally falls back to the reprobe: it may accrue only once (a) reads it positively
837
+ // idle, and the poll's post-dispatch + trailing-tool-activity guards remain the final net.
838
+ async function evaluateEarlyIdleTranscriptArm(
839
+ components: DaemonComponents,
840
+ mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
841
+ store: MeshRuntimeStore,
842
+ row: { id: string; assignedSessionId?: string; assignedNodeId?: string; assignedProviderType?: string },
843
+ localDaemonId?: string,
844
+ selfIds: string[] = [],
845
+ ): Promise<boolean> {
846
+ const sessionId = readNonEmptyString(row.assignedSessionId);
847
+ if (!sessionId) return false;
848
+
849
+ // (a) POSITIVE idle evidence.
850
+ const verdict = resolveSessionBusyVerdict(components, sessionId);
851
+ if (verdict === 'GENERATING') return false; // locally observed mid-turn — never accrue
852
+ if (verdict === 'UNKNOWN') {
853
+ // Remote / gone / id-form skew. First consult the LIVE mesh node snapshot (the same
854
+ // cross-daemon status feed the approval-hold guard and mesh_status use): if it already
855
+ // reports a definitively NON-idle status (generating / waiting_approval / awaiting_choice /
856
+ // …), the worker is not settled — reset the streak WITHOUT a read_chat. This keeps the
857
+ // streak the cheap per-tick gate for the common case and avoids probing a worker the graph
858
+ // already knows is busy or parked at an approval.
859
+ const nodeId = readNonEmptyString(row.assignedNodeId);
860
+ const node = (mesh.nodes ?? []).find(n => n.id === nodeId);
861
+ const liveStatus = nodeId
862
+ ? readNonEmptyString(sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status).toLowerCase()
863
+ : '';
864
+ if (liveStatus && liveStatus !== 'idle') return false; // live graph says non-idle → not a turn-end
865
+ if (!liveStatus) {
866
+ // No live snapshot for this session — re-probe the worker's own status this tick so a
867
+ // genuinely busy remote worker breaks the streak instead of coasting through as "not
868
+ // GENERATING". getInstance may be absent on some component surfaces (tests / standalone),
869
+ // so guard it.
870
+ const nodeDaemonId = readNonEmptyString(node?.daemonId);
871
+ const isLocalNode = !nodeDaemonId
872
+ || daemonIdsEquivalent(nodeDaemonId, localDaemonId)
873
+ || daemonIdListIncludes(selfIds, nodeDaemonId)
874
+ || !!components.instanceManager?.getInstance?.(sessionId);
875
+ const providerType = readNonEmptyString(row.assignedProviderType);
876
+ const readArgs: Record<string, unknown> = {
877
+ sessionId,
878
+ targetSessionId: sessionId,
879
+ tailLimit: 1,
880
+ ...(node?.workspace ? { workspace: node.workspace } : {}),
881
+ ...(providerType ? { agentType: providerType, providerType } : {}),
882
+ };
883
+ const probedStatus = await reprobeWorkerStatus(components, { isLocalNode, nodeDaemonId, readArgs });
884
+ // Only a positively-idle re-probe lets the streak accrue. A non-idle status (the worker
885
+ // IS busy) or an inconclusive read (null — transport error / no payload) resets it: we
886
+ // never treat "couldn't tell" as idle for this early-completion path.
887
+ if (probedStatus !== 'idle') return false;
888
+ }
889
+ }
890
+
891
+ // (b) TURN-START evidence (guards the boot-window / preamble false positive).
892
+ if (store.taskDeliveryConsumed(mesh.id, row.id)) return true; // worker emitted generating_started
893
+ // Delivery never consumed. Preserve the kimi rescue ONLY for the LOCAL pure-PTY class (which
894
+ // never emits generating_started); a native/provider-authoritative local worker that hasn't
895
+ // started its turn must NOT be armed off a boot-window idle. For a remote worker (pure-PTY
896
+ // unknowable here) the positive-idle reprobe in (a) is the gate — allow accrual and let the poll
897
+ // enforce post-dispatch + trailing-tool-activity finality.
898
+ const localPurePty = resolveLocalSessionPurePty(components, sessionId);
899
+ if (localPurePty === true) return true; // local pure-PTY — the class this rescue targets
900
+ if (localPurePty === false) return false; // local native/provider-owned, turn not started — hold
901
+ return verdict === 'UNKNOWN'; // remote, unknowable class — trust the (a) reprobe
902
+ }
903
+
790
904
  // PHASE 2.5 — assigned-stranded dispatch watchdog (Bug B). claimNextTask atomically
791
905
  // flips a row to 'assigned' BEFORE the fire-and-forget dispatch runs. If that dispatch
792
906
  // neither rejects (→ no .catch requeue) nor is confirmed delivered — a relay that hangs
@@ -840,12 +954,18 @@ async function recoverStrandedAssignedDispatches(
840
954
  // elapses. pollAssignedTaskTerminalEvidence enforces idle + final-assistant-after-dispatch, so
841
955
  // a mid-turn / stale-tail / unreadable worker is never falsely completed — a non-qualifying
842
956
  // tick clears the streak below.
843
- if (
957
+ // EARLY-IDLE-COMPLETION-FALSE-POSITIVE: the arm gate now requires POSITIVE idle evidence
958
+ // (a fresh-idle reprobe for a remote/UNKNOWN worker, not merely "not GENERATING") AND
959
+ // turn-start evidence (delivery consumed, or the local pure-PTY class this rescue targets),
960
+ // so a mid-turn remote worker or a boot-window preamble can no longer coast the continuous-
961
+ // idle streak to a false completion. See evaluateEarlyIdleTranscriptArm.
962
+ const earlyArm =
844
963
  store.taskHasConfirmedDelivery(meshId, row.id)
845
964
  && !findTerminalLedgerEvidenceForTask({ meshId, taskId: row.id })
846
965
  && readNonEmptyString(row.assignedSessionId)
847
- && resolveSessionBusyVerdict(components, row.assignedSessionId!) !== 'GENERATING'
848
- ) {
966
+ ? await evaluateEarlyIdleTranscriptArm(components, mesh, store, row, localDaemonId, selfIds)
967
+ : false;
968
+ if (earlyArm) {
849
969
  const since = assignedIdleFinalAssistantSince.get(idleTranscriptStreakKey);
850
970
  if (since === undefined) {
851
971
  assignedIdleFinalAssistantSince.set(idleTranscriptStreakKey, nowMs);
@@ -156,6 +156,59 @@ export function selectFinalAssistantTurnEndMessage(
156
156
  return null;
157
157
  }
158
158
 
159
+ /**
160
+ * EARLY-IDLE-COMPLETION-FALSE-POSITIVE — trailing tool/terminal activity detector.
161
+ *
162
+ * True when the transcript's LAST non-empty user-facing assistant bubble is followed
163
+ * by one or more TOOL / TERMINAL activity bubbles (a tool_use/command the assistant
164
+ * fired AFTER its text) — i.e. the assistant emitted a preamble ("Let me explore…"),
165
+ * then started running Read/Grep, so the turn is still executing and its answer has
166
+ * not landed. Callers that would otherwise promote that preamble to a turn-end summary
167
+ * off a momentary (startup-grace / inter-tool) idle read use this as a veto.
168
+ *
169
+ * Deliberately NARROW so the pure-PTY completion rescue is preserved:
170
+ * - Only TOOL/TERMINAL activity trailing the assistant vetoes. A trailing THOUGHT or
171
+ * status bubble does NOT (a finished turn can end on an internal thought), matching
172
+ * selectFinalAssistantTurnEndMessage's skip set minus the "still-working" signals.
173
+ * - A genuinely FINISHED worker (final assistant last, no trailing tool activity —
174
+ * the kimi pure-PTY continuous-idle shape) returns false, so its early completion
175
+ * is untouched.
176
+ *
177
+ * Returns false when there is no final assistant bubble at all (the caller's
178
+ * selectFinalAssistantTurnEndMessage already handles that as "not a turn end").
179
+ */
180
+ export function hasTrailingToolActivityAfterFinalAssistant(
181
+ messages: ChatMessage[] | null | undefined,
182
+ ): boolean {
183
+ if (!Array.isArray(messages) || messages.length === 0) return false;
184
+ let sawTrailingToolActivity = false;
185
+ for (let i = messages.length - 1; i >= 0; i--) {
186
+ const msg = messages[i];
187
+ if (!msg) continue;
188
+ const classification = classifyChatMessageVisibility(msg);
189
+ // A trailing user-facing assistant/model bubble with real text ends the scan:
190
+ // whether we saw a tool AFTER it is the verdict.
191
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
192
+ if (flattenContent(msg.content).trim()) return sawTrailingToolActivity;
193
+ // Empty streaming assistant bubble — keep scanning back past it, same as
194
+ // selectFinalAssistantTurnEndMessage (which returns null here); an empty tail
195
+ // isn't a turn end, so there is nothing to veto.
196
+ return false;
197
+ }
198
+ if (classification.isUserFacing) {
199
+ // A trailing user bubble (freshly dispatched task, no reply) → no assistant
200
+ // turn end below it to veto.
201
+ return false;
202
+ }
203
+ // Non-user-facing activity/internal bubble sitting AFTER the (not-yet-seen)
204
+ // assistant bubble. Only tool/terminal activity signals an in-flight turn.
205
+ if (classification.kind === 'tool' || classification.kind === 'terminal') {
206
+ sawTrailingToolActivity = true;
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+
159
212
  export const BUILTIN_CHAT_MESSAGE_KINDS = ['standard', 'thought', 'tool', 'terminal', 'system'] as const;
160
213
 
161
214
  export type BuiltinChatMessageKind = typeof BUILTIN_CHAT_MESSAGE_KINDS[number];