@adhdev/daemon-core 1.0.18-rc.12 → 1.0.18-rc.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,9 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
2
  import type { LocalMeshEntry } from '../repo-mesh-types.js';
3
3
  export declare function pullRemoteNodeQueues(components: DaemonComponents, mesh: LocalMeshEntry, localDaemonId: string | undefined, candidateDaemonIds: string[]): Promise<void>;
4
+ export declare function pullPendingEventsFromNode(components: DaemonComponents, meshId: string, node: {
5
+ daemonId?: string;
6
+ }, localDaemonId: string | undefined, candidateDaemonIds: string[], pulls: Array<Record<string, unknown>>): Promise<void>;
4
7
  export declare function unwrapReadChatPayload(raw: unknown): Record<string, unknown> | null;
5
8
  export declare function readChatPayloadStatus(payload: Record<string, unknown> | null): string;
6
9
  export declare function realTerminalEmitPendingForTask(meshId: string, taskId: string): boolean;
@@ -192,6 +192,7 @@ export declare class CliProviderInstance implements ProviderInstance {
192
192
  private meshStallEmittedForAnchor;
193
193
  private meshStallTurnActiveLast;
194
194
  private meshStallLastFiredAt;
195
+ private meshStallNativeTranscriptSample;
195
196
  private busyEpoch;
196
197
  private fastCollapseSynthesizedTaskId;
197
198
  private startupGraceCollapseAt;
@@ -424,6 +425,7 @@ export declare class CliProviderInstance implements ProviderInstance {
424
425
  * once at completion). Reset on the next turn's start.
425
426
  */
426
427
  private lastCompletionSummary;
428
+ private lastEmittedCompletion;
427
429
  private enforceFreshSessionLaunchIfNeeded;
428
430
  /**
429
431
  * BRAIN-ROUTING (runtime-control thinking axis): for a provider that selects
@@ -574,6 +576,49 @@ export declare class CliProviderInstance implements ProviderInstance {
574
576
  * starts with a clean slate (a restart is not throttled by a prior stall).
575
577
  */
576
578
  private resetMeshStallEpisode;
579
+ /**
580
+ * KIMI-MESH-COMPLETION-EMIT (axis 1). For a native-source provider
581
+ * (transcriptAuthority=provider — its authoritative history is an on-disk
582
+ * transcript file, e.g. kimi's wire.jsonl), sample the transcript's current
583
+ * progress fingerprint (record count + source-file mtime) WITHOUT parsing the
584
+ * PTY. Used by the stall watchdog to distinguish "PTY render is quiet but the
585
+ * worker is still doing long tool work (transcript growing)" from a genuine
586
+ * wedge. Returns null for a pure-PTY provider (no native source) or when the
587
+ * source cannot be resolved this tick — the caller then falls back to the
588
+ * unchanged lastOutputAt-only judgment.
589
+ *
590
+ * Generalized on the provider's native-source flag, NOT a hardcoded provider
591
+ * type, so every current and future pure-PTY long-tool native-source provider
592
+ * benefits. Cheap enough for the stall path: it runs only at the stall threshold
593
+ * (≥180s of PTY stasis), never on the routine 5s tick.
594
+ */
595
+ private sampleNativeTranscriptProgress;
596
+ /**
597
+ * KIMI-MESH-COMPLETION-EMIT (axis 2). Last-chance completion emit for a mesh
598
+ * DELEGATED worker whose PTY has already exited (e.g. killed by a false stall)
599
+ * and is about to be auto-cleaned (cli-manager.startCliExitMonitor →
600
+ * removeInstance closes the event-emit window forever). If the worker actually
601
+ * FINISHED its assigned turn — its authoritative native transcript holds a final
602
+ * assistant message for the injected task — but the completion event never fired
603
+ * (the stall-kill happened before the FSM's idle transition), emit it now so the
604
+ * coordinator learns the task completed instead of waiting ~180s for the reconcile
605
+ * transcript-poll to reclaim it.
606
+ *
607
+ * Scope & guards (must all hold to emit):
608
+ * • mesh worker session only — a normal standalone session's ordinary exit is
609
+ * never synthesized (isMeshWorkerSession()).
610
+ * • DOUBLE-EMIT guard — refuse if this turn's completion already fired
611
+ * (lastEmittedCompletion matches the current taskId). A worker that completed
612
+ * cleanly and is merely being cleaned up never double-emits.
613
+ * • evidence gate — reuse the same final-assistant/turn-scoped summary machinery
614
+ * as the normal completion path (completionFinalSummary over the native
615
+ * transcript). No in-turn assistant summary ⇒ no proof of completion ⇒ leave
616
+ * the reclaim path to handle a genuinely-unfinished worker.
617
+ *
618
+ * Returns true when a synthetic completion was emitted, false otherwise. Safe to
619
+ * call unconditionally from the cleanup path.
620
+ */
621
+ flushMeshCompletionBeforeCleanup(): boolean;
577
622
  /**
578
623
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
579
624
  * persist before the in-progress settle gate is torn down. For a delegated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.12",
3
+ "version": "1.0.18-rc.14",
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.12",
51
- "@adhdev/session-host-core": "1.0.18-rc.12",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.14",
51
+ "@adhdev/session-host-core": "1.0.18-rc.14",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -707,6 +707,24 @@ export class DaemonCliManager {
707
707
  clearInterval(checkStopped);
708
708
  setTimeout(() => {
709
709
  if (this.adapters.has(key)) {
710
+ // KIMI-MESH-COMPLETION-EMIT (axis 2): before removeInstance closes the
711
+ // event-emit window, give a mesh DELEGATED worker one last chance to emit
712
+ // its completion. A native-source worker (e.g. kimi) can have its PTY
713
+ // killed by a false stall AFTER it finished the task (transcript written)
714
+ // but BEFORE the FSM's idle→completed event fired — the instance is the
715
+ // only thing that can emit that event, and it is about to be removed. The
716
+ // instance-side method is a no-op for a non-mesh session or when the
717
+ // turn's completion already fired (double-emit guard) or when there is no
718
+ // transcript evidence of a finished turn. Best-effort — never blocks cleanup.
719
+ try {
720
+ const inst = instanceManager?.getInstance(key) as (ProviderInstance & { flushMeshCompletionBeforeCleanup?: () => boolean }) | undefined;
721
+ if (typeof inst?.flushMeshCompletionBeforeCleanup === 'function') {
722
+ const emitted = inst.flushMeshCompletionBeforeCleanup();
723
+ if (emitted) LOG.info('CLI', `Emitted pre-cleanup mesh completion for ${cliType} session ${key} before auto-clean`);
724
+ }
725
+ } catch (e) {
726
+ LOG.warn('CLI', `pre-cleanup mesh completion flush failed for ${key}: ${(e as Error)?.message || e}`);
727
+ }
710
728
  this.adapters.delete(key);
711
729
  this.deps.removeAgentTracking(key);
712
730
  sessionRegistry?.unregisterByInstanceKey(key);
@@ -75,7 +75,7 @@ import {
75
75
  resolvePendingHeldDrainEscalateMs,
76
76
  resolveReconcileIntervalMs,
77
77
  } from './mesh-reconcile-config.js';
78
- import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
78
+ import { pullRemoteNodeQueues, pullPendingEventsFromNode } from './mesh-remote-event-pull.js';
79
79
  import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
80
80
  import {
81
81
  reconcileUnterminatedDirectDispatches,
@@ -781,6 +781,8 @@ async function recoverStrandedAssignedDispatches(
781
781
  components: DaemonComponents,
782
782
  mesh: { id: string; nodes?: Array<{ id: string; daemonId?: string; workspace?: string }> },
783
783
  store: MeshRuntimeStore,
784
+ localDaemonId?: string,
785
+ selfIds: string[] = [],
784
786
  ): Promise<void> {
785
787
  const meshId = mesh.id;
786
788
  const assigned = getQueue(meshId, { status: ['assigned'] });
@@ -832,6 +834,45 @@ async function recoverStrandedAssignedDispatches(
832
834
  updateTaskStatus(meshId, row.id, status);
833
835
  continue;
834
836
  }
837
+ // GENERATING-STARTED-CONSUME-RACE (fix B): the delivery-consume that clears this
838
+ // gate (delivered → acked) runs ONLY when the coordinator pulls the worker's queued
839
+ // agent:generating_started in PHASE 1 (handleMeshForwardEvent → consumeSessionDelivery).
840
+ // A worker may have emitted generating_started ALREADY, but if PHASE 1 has not yet
841
+ // pulled that specific event (a skipped/slow peer tick, or the event was queued after
842
+ // this tick's pull ran), the delivery row still reads 'delivered' here and the redrive
843
+ // below tears a genuinely-generating remote worker off its task and re-injects the
844
+ // prompt (the delivered_not_consumed_redrive symptom). Close the race by issuing a
845
+ // TARGETED, in-process last-chance pull of THIS node's queue right now, then
846
+ // re-reading taskDeliveryConsumed(): if the worker's generating_started was waiting to
847
+ // be pulled, the pull consumes the delivery this tick and we skip the redrive entirely.
848
+ // Best-effort and self-scoped (the same skip/peer guards as PHASE 1) — a miss just
849
+ // falls through to the existing UNKNOWN-streak grace, so this only ever removes false
850
+ // redrives, never adds one. A local (co-hosted) worker has nothing to pull (the node's
851
+ // daemon is a self id) and this is a fast no-op.
852
+ const assignedNode = row.assignedNodeId
853
+ ? mesh.nodes?.find(n => meshNodeIdMatches(n, row.assignedNodeId))
854
+ : undefined;
855
+ if (assignedNode?.daemonId) {
856
+ try {
857
+ await pullPendingEventsFromNode(
858
+ components,
859
+ meshId,
860
+ assignedNode,
861
+ localDaemonId,
862
+ selfIds,
863
+ selfIds.length > 0
864
+ ? selfIds.map(id => ({ meshId, coordinatorDaemonId: id }))
865
+ : [{ meshId }],
866
+ );
867
+ } catch { /* best-effort last-chance pull */ }
868
+ if (store.taskDeliveryConsumed(meshId, row.id)) {
869
+ // The pull delivered the worker's generating_started (or a terminal event) and
870
+ // consumed the delivery — the task is genuinely live/handled. Reset any accrued
871
+ // streak and leave the row to PHASE 4's completion reconcile.
872
+ deliveredUnconsumedUnknownStreak.delete(`${meshId}::${row.id}`);
873
+ continue;
874
+ }
875
+ }
835
876
  const shortStreakKey = `${meshId}::${row.id}`;
836
877
  const verdict = row.assignedSessionId
837
878
  ? resolveSessionBusyVerdict(components, row.assignedSessionId)
@@ -1227,7 +1268,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1227
1268
  const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1228
1269
  if (!daemonHostsMesh(mesh, selfIds)) continue;
1229
1270
  try {
1230
- await recoverStrandedAssignedDispatches(components, mesh, store);
1271
+ await recoverStrandedAssignedDispatches(components, mesh, store, localDaemonId, selfIds);
1231
1272
  } catch (e: any) {
1232
1273
  LOG.warn('MeshReconcile', `Assigned-stranded watchdog failed for mesh ${mesh.id}: ${e?.message || e}`);
1233
1274
  }
@@ -60,57 +60,78 @@ export async function pullRemoteNodeQueues(
60
60
  // block the other nodes for the rest of the tick. Each node callback is fully
61
61
  // self-contained (local/candidate skip, peer-connected pre-check, per-candidate
62
62
  // pulls, extract→re-inject) and best-effort — allSettled swallows per-node errors.
63
- await Promise.allSettled(mesh.nodes.map(async (node) => {
64
- const nodeDaemonId = readNonEmptyString(node.daemonId);
65
- // Skip nodes without a daemon, and nodes on THIS daemon (their events are
66
- // already in the local queue drained in PHASE 2). "This daemon" is matched
67
- // against the full self-identity set (candidateDaemonIds), not just the bare
68
- // localDaemonId — a self node can be registered under the config-form daemonId
69
- // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
70
- // from ourselves over P2P is both wasteful and a self-dispatch hazard.
71
- if (!nodeDaemonId) return;
72
- if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
73
- if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
63
+ await Promise.allSettled(mesh.nodes.map(node =>
64
+ pullPendingEventsFromNode(components, meshId, node, localDaemonId, candidateDaemonIds, pulls)));
65
+ }
74
66
 
75
- // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
76
- // a degraded peer whose DataChannel is not open would sink this pull into
77
- // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
78
- // the whole serial loop and delaying completion-event recovery from healthy
79
- // nodes. Skip such a node THIS tick and retry next tick LOSSLESS: an
80
- // unconnected peer has not drained anything (drained=0 preserved), so its events
81
- // are recovered whole on the next successful tick. Skip = delay, never loss.
82
- // getter WIRED (cloud) a null/undefined snapshot means "no peer object
83
- // right now" = NOT connected (a powered-off node whose failPeer just deleted
84
- // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
85
- // dialing here would re-queue for another 90s (the null-race the guard is
86
- // meant to prevent). Only a snapshot with state === 'connected' proceeds.
87
- // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
88
- // so this stays regression-free (the standalone case the guard's history
89
- // references).
90
- const getPeerStatus = components.getMeshPeerConnectionStatus;
91
- if (getPeerStatus) {
92
- const peerSnapshot = getPeerStatus(nodeDaemonId);
93
- if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
94
- }
67
+ // Pull one node's pending coordinator events and re-inject them locally via
68
+ // handleMeshForwardEvent (which runs the delivery-consume + re-queue paths). Factored
69
+ // out of pullRemoteNodeQueues so the reconcile loop can also issue a TARGETED single-node
70
+ // pull on demand specifically, the DELIVERED-NOT-CONSUMED redrive gate calls this for
71
+ // one node right before it would re-drive, so a worker's already-emitted (but not-yet-
72
+ // pulled) agent:generating_started is consumed IN-PROCESS this tick instead of waiting for
73
+ // the next PHASE 1 pull. That closes the redrive-vs-consume race: the redrive gate reads
74
+ // taskDeliveryConsumed() AFTER this pull has had its chance to flip the delivery row.
75
+ // Returns silently on any skip/error every caller treats it as best-effort.
76
+ export async function pullPendingEventsFromNode(
77
+ components: DaemonComponents,
78
+ meshId: string,
79
+ node: { daemonId?: string },
80
+ localDaemonId: string | undefined,
81
+ candidateDaemonIds: string[],
82
+ pulls: Array<Record<string, unknown>>,
83
+ ): Promise<void> {
84
+ const dispatchMeshCommand = components.dispatchMeshCommand;
85
+ if (!dispatchMeshCommand) return;
86
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
87
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
88
+ // already in the local queue drained in PHASE 2). "This daemon" is matched
89
+ // against the full self-identity set (candidateDaemonIds), not just the bare
90
+ // localDaemonId — a self node can be registered under the config-form daemonId
91
+ // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
92
+ // from ourselves over P2P is both wasteful and a self-dispatch hazard.
93
+ if (!nodeDaemonId) return;
94
+ if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
95
+ if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
95
96
 
96
- for (const pendingEventArgs of pulls) {
97
- let events: unknown;
97
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
98
+ // a degraded peer whose DataChannel is not open would sink this pull into
99
+ // peer.connectQueue and stall until CONNECT_TIMEOUT_MS (90s), formerly freezing
100
+ // the whole serial loop and delaying completion-event recovery from healthy
101
+ // nodes. Skip such a node THIS tick and retry next tick — LOSSLESS: an
102
+ // unconnected peer has not drained anything (drained=0 preserved), so its events
103
+ // are recovered whole on the next successful tick. Skip = delay, never loss.
104
+ // • getter WIRED (cloud) → a null/undefined snapshot means "no peer object
105
+ // right now" = NOT connected (a powered-off node whose failPeer just deleted
106
+ // the peer each cycle). Treat it EXACTLY like state !== 'connected' and skip;
107
+ // dialing here would re-queue for another 90s (the null-race the guard is
108
+ // meant to prevent). Only a snapshot with state === 'connected' proceeds.
109
+ // • getter UNWIRED (standalone) → DO NOT skip; fall through to the legacy path
110
+ // so this stays regression-free (the standalone case the guard's history
111
+ // references).
112
+ const getPeerStatus = components.getMeshPeerConnectionStatus;
113
+ if (getPeerStatus) {
114
+ const peerSnapshot = getPeerStatus(nodeDaemonId);
115
+ if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return;
116
+ }
117
+
118
+ for (const pendingEventArgs of pulls) {
119
+ let events: unknown;
120
+ try {
121
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
122
+ } catch {
123
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
124
+ break; // node unreachable — don't bother with the other id form this tick.
125
+ }
126
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
127
+ for (const event of list) {
128
+ const payload = buildForwardPayloadFromPending(event);
129
+ if (!payload.event || !payload.meshId) continue;
98
130
  try {
99
- events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
100
- } catch {
101
- // Remote pull is best-effort; the node may be offline. Retry next tick.
102
- break; // node unreachable — don't bother with the other id form this tick.
103
- }
104
- const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
105
- for (const event of list) {
106
- const payload = buildForwardPayloadFromPending(event);
107
- if (!payload.event || !payload.meshId) continue;
108
- try {
109
- handleMeshForwardEvent(components, payload);
110
- } catch { /* best-effort re-inject */ }
111
- }
131
+ handleMeshForwardEvent(components, payload);
132
+ } catch { /* best-effort re-inject */ }
112
133
  }
113
- }));
134
+ }
114
135
  }
115
136
 
116
137
  // Pull the read_chat payload out of whatever envelope the transport returned.
@@ -332,6 +332,18 @@ export class CliProviderInstance implements ProviderInstance {
332
332
  // MESH_WORKER_STALL_REFIRE_COOLDOWN_MS between successive emissions across
333
333
  // anchor re-arms (the per-anchor guard only covers a single continuous stall).
334
334
  private meshStallLastFiredAt = -1;
335
+ // KIMI-MESH-COMPLETION-EMIT (axis 1): the native-source transcript progress
336
+ // fingerprint (record count + source mtime) observed the LAST time the stall
337
+ // watchdog checked a native-source provider (transcriptAuthority=provider — e.g.
338
+ // kimi wire.jsonl). A pure-PTY native-source worker running a long, screen-quiet
339
+ // tool (no viewport bytes for minutes) looks stalled by the lastOutputAt clock
340
+ // even though its transcript file is still growing. Before firing the stall, we
341
+ // compare the current transcript fingerprint against this snapshot; if the
342
+ // transcript advanced, the "stall" is a false positive of PTY-render stasis, so
343
+ // we re-arm the anchor instead of paging the coordinator (whose no_progress
344
+ // handling can lead to the worker being stopped mid-work → completion never
345
+ // emitted). null = not yet sampled for this session/anchor.
346
+ private meshStallNativeTranscriptSample: { msgCount: number; sourceMtimeMs: number } | null = null;
335
347
  // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
336
348
  // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
337
349
  // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
@@ -1418,6 +1430,15 @@ export class CliProviderInstance implements ProviderInstance {
1418
1430
  */
1419
1431
  private lastCompletionSummary: { content: string; receivedAt: number; sourceTimestampMs?: number } | null = null;
1420
1432
 
1433
+ // KIMI-MESH-COMPLETION-EMIT (axis 2, double-emit guard): the (taskId, wall-clock)
1434
+ // of the most recent agent:generating_completed this instance emitted, stamped by
1435
+ // emitGeneratingCompleted. The pre-cleanup completion flush
1436
+ // (flushMeshCompletionBeforeCleanup, driven by cli-manager's exit monitor) reads
1437
+ // this to refuse a SECOND completion for a turn whose completion already fired —
1438
+ // so a worker that finished cleanly and is simply being auto-cleaned never emits a
1439
+ // duplicate. taskId '' covers an ad-hoc (no-task) turn. null = none emitted yet.
1440
+ private lastEmittedCompletion: { taskId: string; at: number } | null = null;
1441
+
1421
1442
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
1422
1443
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
1423
1444
  if (!scriptName) return;
@@ -2404,6 +2425,38 @@ export class CliProviderInstance implements ProviderInstance {
2404
2425
  const stalledMs = now - this.meshStallAnchorAt;
2405
2426
  if (stalledMs < threshold) return;
2406
2427
 
2428
+ // KIMI-MESH-COMPLETION-EMIT (axis 1): transcript-aware stall for native-source
2429
+ // providers. A pure-PTY native-source worker (transcriptAuthority=provider,
2430
+ // e.g. kimi) running a long, screen-quiet tool emits no viewport bytes for
2431
+ // minutes, so the lastOutputAt clock above reads it as stalled — but its
2432
+ // authoritative transcript (wire.jsonl) is still being appended. Firing
2433
+ // monitor:no_progress here surfaces a false stall to the coordinator whose
2434
+ // downstream handling can stop the worker mid-work, killing the emit window
2435
+ // before the real completion propagates. So before firing, sample the native
2436
+ // transcript's progress fingerprint: if it advanced (more records OR a fresher
2437
+ // source mtime) since the last sample, the worker is demonstrably alive — treat
2438
+ // it as progress. Re-arm the anchor to `now` and reset the episode so the clock
2439
+ // restarts from this proven-live moment; only a transcript that is ALSO static
2440
+ // falls through to the genuine-stall fire below. No-op for pure-PTY providers
2441
+ // (sample is null → unchanged behavior).
2442
+ const nativeSample = this.sampleNativeTranscriptProgress();
2443
+ if (nativeSample) {
2444
+ const prev = this.meshStallNativeTranscriptSample;
2445
+ const advanced = !prev
2446
+ || nativeSample.msgCount > prev.msgCount
2447
+ || nativeSample.sourceMtimeMs > prev.sourceMtimeMs;
2448
+ this.meshStallNativeTranscriptSample = nativeSample;
2449
+ if (advanced) {
2450
+ if (this.isMeshWorkerSession()) {
2451
+ traceMeshEventDrop('mesh_worker_stall_transcript_advancing', this.meshTraceCtx('monitor:no_progress'),
2452
+ `msgCount=${nativeSample.msgCount} sourceMtime=${nativeSample.sourceMtimeMs} (PTY quiet ${Math.round(stalledMs / 1000)}s but transcript advancing)`);
2453
+ }
2454
+ this.meshStallAnchorAt = now;
2455
+ this.meshStallEmittedForAnchor = false;
2456
+ return;
2457
+ }
2458
+ }
2459
+
2407
2460
  // FALSE-STALL-WATCHDOG-OVERFIRE (fix E): per-session refire cooldown. Mark
2408
2461
  // this anchor emitted regardless so a still-static anchor does not re-check
2409
2462
  // every tick, but suppress the actual coordinator notification (and the
@@ -2454,6 +2507,116 @@ export class CliProviderInstance implements ProviderInstance {
2454
2507
  this.meshStallEmittedForAnchor = false;
2455
2508
  this.meshStallTurnActiveLast = undefined;
2456
2509
  this.meshStallLastFiredAt = -1;
2510
+ this.meshStallNativeTranscriptSample = null;
2511
+ }
2512
+
2513
+ /**
2514
+ * KIMI-MESH-COMPLETION-EMIT (axis 1). For a native-source provider
2515
+ * (transcriptAuthority=provider — its authoritative history is an on-disk
2516
+ * transcript file, e.g. kimi's wire.jsonl), sample the transcript's current
2517
+ * progress fingerprint (record count + source-file mtime) WITHOUT parsing the
2518
+ * PTY. Used by the stall watchdog to distinguish "PTY render is quiet but the
2519
+ * worker is still doing long tool work (transcript growing)" from a genuine
2520
+ * wedge. Returns null for a pure-PTY provider (no native source) or when the
2521
+ * source cannot be resolved this tick — the caller then falls back to the
2522
+ * unchanged lastOutputAt-only judgment.
2523
+ *
2524
+ * Generalized on the provider's native-source flag, NOT a hardcoded provider
2525
+ * type, so every current and future pure-PTY long-tool native-source provider
2526
+ * benefits. Cheap enough for the stall path: it runs only at the stall threshold
2527
+ * (≥180s of PTY stasis), never on the routine 5s tick.
2528
+ */
2529
+ private sampleNativeTranscriptProgress(): { msgCount: number; sourceMtimeMs: number } | null {
2530
+ if (!isNativeSourceCanonicalHistory(this.provider?.nativeHistory)) return null;
2531
+ // readExternalCompletionMessages resolves this session's OWN native-source
2532
+ // conversation (providerSessionId / persisted pin / floor claim) and, as a
2533
+ // side effect, refreshes this.lastExternalCompletionProbe with the source
2534
+ // path, mtime, and message count. Reusing it keeps the resolution logic in
2535
+ // one place and immune to the antigravity-style session-id quirks.
2536
+ let messages: unknown[] | null = null;
2537
+ try {
2538
+ messages = this.readExternalCompletionMessages();
2539
+ } catch {
2540
+ return null; // best-effort: an unresolved transcript → fall back to PTY clock
2541
+ }
2542
+ const probe = this.lastExternalCompletionProbe;
2543
+ if (!probe) return null;
2544
+ return {
2545
+ msgCount: typeof probe.msgCount === 'number' ? probe.msgCount : (Array.isArray(messages) ? messages.length : 0),
2546
+ sourceMtimeMs: typeof probe.sourceMtimeMs === 'number' ? probe.sourceMtimeMs : 0,
2547
+ };
2548
+ }
2549
+
2550
+ /**
2551
+ * KIMI-MESH-COMPLETION-EMIT (axis 2). Last-chance completion emit for a mesh
2552
+ * DELEGATED worker whose PTY has already exited (e.g. killed by a false stall)
2553
+ * and is about to be auto-cleaned (cli-manager.startCliExitMonitor →
2554
+ * removeInstance closes the event-emit window forever). If the worker actually
2555
+ * FINISHED its assigned turn — its authoritative native transcript holds a final
2556
+ * assistant message for the injected task — but the completion event never fired
2557
+ * (the stall-kill happened before the FSM's idle transition), emit it now so the
2558
+ * coordinator learns the task completed instead of waiting ~180s for the reconcile
2559
+ * transcript-poll to reclaim it.
2560
+ *
2561
+ * Scope & guards (must all hold to emit):
2562
+ * • mesh worker session only — a normal standalone session's ordinary exit is
2563
+ * never synthesized (isMeshWorkerSession()).
2564
+ * • DOUBLE-EMIT guard — refuse if this turn's completion already fired
2565
+ * (lastEmittedCompletion matches the current taskId). A worker that completed
2566
+ * cleanly and is merely being cleaned up never double-emits.
2567
+ * • evidence gate — reuse the same final-assistant/turn-scoped summary machinery
2568
+ * as the normal completion path (completionFinalSummary over the native
2569
+ * transcript). No in-turn assistant summary ⇒ no proof of completion ⇒ leave
2570
+ * the reclaim path to handle a genuinely-unfinished worker.
2571
+ *
2572
+ * Returns true when a synthetic completion was emitted, false otherwise. Safe to
2573
+ * call unconditionally from the cleanup path.
2574
+ */
2575
+ flushMeshCompletionBeforeCleanup(): boolean {
2576
+ if (!this.isMeshWorkerSession()) return false;
2577
+ const taskId = this.completingTurnTaskId();
2578
+
2579
+ // DOUBLE-EMIT guard: this turn's completion already fired — never re-emit.
2580
+ if (this.lastEmittedCompletion && this.lastEmittedCompletion.taskId === (taskId ?? '')) {
2581
+ return false;
2582
+ }
2583
+
2584
+ // Evidence gate: the injected task's own turn must have genuinely started
2585
+ // (guards against synthesizing a completion off a reused session's stale tail
2586
+ // before this task ran) and the native transcript must hold an in-turn final
2587
+ // assistant summary. turnStartedAt anchors the turn-scoped read.
2588
+ if (!this.injectedTaskHasStartedGenerating()) return false;
2589
+ const turnStartedAt = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2590
+ ? (this.adapter as any).currentTurnStartedAt as number
2591
+ : this.meshTaskInjectedAt || undefined;
2592
+ let parsedMessages: unknown;
2593
+ try {
2594
+ parsedMessages = this.adapter?.getScriptParsedStatus?.()?.messages;
2595
+ } catch { parsedMessages = undefined; }
2596
+ let finalSummary: string | undefined;
2597
+ try {
2598
+ finalSummary = this.completionFinalSummary(parsedMessages, turnStartedAt);
2599
+ } catch { finalSummary = undefined; }
2600
+ // No in-turn final assistant evidence → not a proven turn-end. Defer to the
2601
+ // coordinator's reclaim/reconcile path rather than fabricate a completion.
2602
+ if (!finalSummary) return false;
2603
+
2604
+ LOG.warn('CLI', `[${this.type}] emitting pre-cleanup mesh completion for session ${this.instanceId} `
2605
+ + `task=${taskId ?? '(none)'} — PTY exited before the completion event fired but the native transcript `
2606
+ + `shows a finished turn; synthesizing completion so the coordinator is not left waiting for reclaim.`);
2607
+ if (this.isMeshWorkerSession()) {
2608
+ traceMeshEventStage('fired', this.meshTraceCtx(), 'pre_cleanup_transcript_completion');
2609
+ }
2610
+ this.emitGeneratingCompleted({
2611
+ chatTitle: '',
2612
+ duration: undefined,
2613
+ timestamp: Date.now(),
2614
+ taskId,
2615
+ finalSummary,
2616
+ evidenceLevel: 'transcript',
2617
+ completionDiagnostic: { source: 'pre_cleanup_transcript_completion' },
2618
+ });
2619
+ return true;
2457
2620
  }
2458
2621
 
2459
2622
  /**
@@ -2997,6 +3160,10 @@ export class CliProviderInstance implements ProviderInstance {
2997
3160
  if (summary) {
2998
3161
  this.lastCompletionSummary = { content: summary, receivedAt: opts.timestamp };
2999
3162
  }
3163
+ // KIMI-MESH-COMPLETION-EMIT (axis 2, double-emit guard): record that THIS turn's
3164
+ // completion has now been emitted, keyed by its taskId, so the pre-cleanup
3165
+ // completion flush never fires a duplicate for the same turn.
3166
+ this.lastEmittedCompletion = { taskId: typeof opts.taskId === 'string' ? opts.taskId : '', at: Date.now() };
3000
3167
  this.pushEvent({
3001
3168
  event: 'agent:generating_completed',
3002
3169
  chatTitle: opts.chatTitle,