@adhdev/daemon-core 0.9.82-rc.501 → 0.9.82-rc.503

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.
@@ -145,6 +145,7 @@ export declare class CliProviderInstance implements ProviderInstance {
145
145
  private busyEpoch;
146
146
  private fastCollapseSynthesizedTaskId;
147
147
  private startupGraceCollapseAt;
148
+ private meshTaskInjectedAt;
148
149
  private settings;
149
150
  private monitor;
150
151
  private generatingDebounceTimer;
@@ -440,6 +441,31 @@ export declare class CliProviderInstance implements ProviderInstance {
440
441
  * turn completes overwrites it. Returns undefined for a non-task ad-hoc turn.
441
442
  */
442
443
  private completingTurnTaskId;
444
+ /**
445
+ * ANTIGRAVITY-PREMATURE-COMPLETION gate: has the CURRENTLY-injected task actually
446
+ * entered generating (a real onTurnStarted for it)? Used to reject stale external-
447
+ * native completion evidence that predates the injected task's turn.
448
+ *
449
+ * The injected task's id is the session scalar `meshActiveTaskId`, stamped by
450
+ * attachMeshAssignment BEFORE the PTY turn starts. That stamp also records
451
+ * `meshTaskInjectedAt`. The turn that has genuinely started is marked by
452
+ * `adapter.currentTurnStartedAt` (set ONLY by onTurnStarted). Two naive signals both
453
+ * FAIL for a reused-idle session:
454
+ * - `currentTurnStartedAt > 0` alone: it persists from the PRIOR turn, so it is
455
+ * already > 0 the instant a new task is injected (pre-onTurnStarted).
456
+ * - `currentTurnTaskId === meshActiveTaskId` alone: forceSendMessage (the mesh
457
+ * inject path) pre-binds currentTurnTaskId to the new taskId at inject time,
458
+ * BEFORE the turn starts, so this matches prematurely too.
459
+ * The robust discriminator is TEMPORAL: the producing turn must have STARTED AFTER
460
+ * the injection — `currentTurnStartedAt > meshTaskInjectedAt`. Only then has the
461
+ * injected task's own onTurnStarted fired.
462
+ * - No injected task since boot (meshTaskInjectedAt === 0, e.g. an ad-hoc/dashboard
463
+ * turn or a non-mesh session): fall back to the plain "a turn has started" check
464
+ * so non-mesh completion is unaffected.
465
+ * Fails CLOSED for the injected-but-not-started window; open once the injected turn is
466
+ * genuinely underway (preserving the rc.480/481 completion-fires win).
467
+ */
468
+ private injectedTaskHasStartedGenerating;
443
469
  private meshTraceCtx;
444
470
  private completionTraceOn;
445
471
  private fsmTraceOn;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.501",
3
+ "version": "0.9.82-rc.503",
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": "0.9.82-rc.501",
51
- "@adhdev/session-host-core": "0.9.82-rc.501",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.503",
51
+ "@adhdev/session-host-core": "0.9.82-rc.503",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -891,6 +891,39 @@ export function stampPendingEventV2(
891
891
  // has no owner to leak from and must reach whatever coordinator drains here.
892
892
  const dispatchedBySelfFallback = selfFallback && stamp.scope === 'broadcast';
893
893
 
894
+ // CODA-TERMINAL-EVENT-HELD-WHILE-GENERATING (project-mesh-self-fallback-terminal-
895
+ // broadcast-drop): an ownerless self-fallback broadcast terminal event
896
+ // (worktree_bootstrap_complete / refine:completed / a summary-less
897
+ // agent:generating_completed emitted while THIS machine's coordinator CLI session is
898
+ // generating) previously carried NO targetCoordinatorDaemonId. The reconcile loop
899
+ // holds it under `generating_no_idle_coordinator`, and its `event_held` ledger mirror
900
+ // records targetCoordinatorDaemonId:null — so the held event is not addressable to
901
+ // the local coordinator's per-daemon scoped file, and (live 2026-07-12) the
902
+ // coordinator only ever learns of it by polling the ledger, violating the no-polling
903
+ // rule.
904
+ //
905
+ // The event's INTENDED coordinator IS this machine's own coordinator: the
906
+ // self-fallback minted `dispatchedBy` under this daemon's own machineId precisely
907
+ // because the originating task was dispatched by this machine's coordinator. Stamp
908
+ // `targetCoordinatorDaemonId` = that same self daemon id so the held event is
909
+ // addressable to the local coordinator's scoped file / drain filter, WITHOUT
910
+ // changing its BROADCAST scope or the `dispatchedBySelfFallback` machine-level
911
+ // `deliverSelfFallback` guard (which already keeps a replica completion on machine A
912
+ // from fanning out to a coordinator on machine B). The target is a MACHINE id,
913
+ // matched by machine core, so it only ever reaches a coordinator on THIS machine.
914
+ //
915
+ // Gate: apply ONLY when the event carries NO targetCoordinatorSessionId. A
916
+ // session-strict event (targetCoordinatorSessionId set, coordinator daemon id
917
+ // unresolved) is deliberately held/expired by the reconcile loop's strict-route path
918
+ // keyed on the SESSION, not the daemon — stamping a daemon target there would let it
919
+ // drain to a sibling before its session returns and break the strict-route hold. An
920
+ // event that already carries an explicit daemon target is likewise left untouched.
921
+ const selfFallbackTarget = dispatchedBySelfFallback
922
+ && !readNonEmptyString(event.targetCoordinatorDaemonId)
923
+ && !readNonEmptyString(event.targetCoordinatorSessionId)
924
+ ? readNonEmptyString(stamp.dispatchedBy.daemonId)
925
+ : undefined;
926
+
894
927
  return {
895
928
  ...event,
896
929
  protocolVersion: stamp.protocolVersion,
@@ -899,6 +932,7 @@ export function stampPendingEventV2(
899
932
  dispatchedBy: stamp.dispatchedBy,
900
933
  ...(stamp.intendedFor ? { intendedFor: stamp.intendedFor } : {}),
901
934
  ...(dispatchedBySelfFallback ? { dispatchedBySelfFallback: true } : {}),
935
+ ...(selfFallbackTarget ? { targetCoordinatorDaemonId: selfFallbackTarget } : {}),
902
936
  };
903
937
  }
904
938
 
@@ -72,18 +72,26 @@ export async function pullRemoteNodeQueues(
72
72
  if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) return;
73
73
  if (daemonIdListIncludes(candidateDaemonIds, nodeDaemonId)) return;
74
74
 
75
- // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a)): a degraded peer whose
76
- // DataChannel is not open would sink this pull into peer.connectQueue and stall
77
- // until CONNECT_TIMEOUT_MS (90s), formerly freezing the whole serial loop and
78
- // delaying completion-event recovery from healthy nodes. Skip such a node THIS
79
- // tick and retry next tick — LOSSLESS: an unconnected peer has not drained
80
- // anything (drained=0 preserved), so its events are recovered whole on the next
81
- // successful tick. Skip = delay, never loss.
82
- // • snapshot present and state !== 'connected' skip (continue next tick).
83
- // snapshot null/undefined (getter unwired, e.g. standalone) DO NOT skip;
84
- // fall through to the legacy path so this stays regression-free.
85
- const peerSnapshot = components.getMeshPeerConnectionStatus?.(nodeDaemonId);
86
- if (peerSnapshot && String(peerSnapshot.state) !== 'connected') return;
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
+ }
87
95
 
88
96
  for (const pendingEventArgs of pulls) {
89
97
  let events: unknown;
@@ -185,15 +193,23 @@ export async function collectLiveNodesWithSessions(
185
193
  const isLocalNode = !nodeDaemonId
186
194
  || daemonIdListIncludes(selfIds, nodeDaemonId)
187
195
  || daemonIdsEquivalent(nodeDaemonId, localDaemonId);
188
- // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a)): mirror pullRemoteNodeQueues.
189
- // Without this the 90s connect-deadline block re-enters via this Promise.all —
190
- // a degraded remote's get_status_metadata sinks into peer.connectQueue and stalls
191
- // the whole prune probe. Only call the remote when the peer is 'connected'; an
192
- // unconnected peer is left undecorated (empty session list), same as unreachable.
193
- // Getter unwired (null/undefined) → do NOT skip, fall through (regression-free).
196
+ // Peer-connected pre-check (EVENT-DELIVERY-DELAY fix(a) + OFFLINE-NODE-FANOUT):
197
+ // mirror pullRemoteNodeQueues. Without this the 90s connect-deadline block
198
+ // re-enters via this Promise.all — a degraded remote's get_status_metadata sinks
199
+ // into peer.connectQueue and stalls the whole prune probe. Only call the remote
200
+ // when the peer is 'connected'; an unconnected peer is left undecorated (empty
201
+ // session list), same as unreachable.
202
+ // • getter WIRED (cloud) → a null snapshot means "no peer object right now" =
203
+ // NOT connected (offline node whose failPeer deleted the peer). Skip (leave
204
+ // undecorated) rather than dialing into another 90s connect wait — the same
205
+ // null-race harden as pullRemoteNodeQueues.
206
+ // • getter UNWIRED (standalone) → do NOT skip, fall through (regression-free).
194
207
  if (!isLocalNode) {
195
- const peerSnapshot = components.getMeshPeerConnectionStatus?.(nodeDaemonId);
196
- if (peerSnapshot && String(peerSnapshot.state) !== 'connected') return node;
208
+ const getPeerStatus = components.getMeshPeerConnectionStatus;
209
+ if (getPeerStatus) {
210
+ const peerSnapshot = getPeerStatus(nodeDaemonId);
211
+ if (!peerSnapshot || String(peerSnapshot.state) !== 'connected') return node;
212
+ }
197
213
  }
198
214
  let statusResult: unknown;
199
215
  try {
@@ -256,6 +256,15 @@ export class CliProviderInstance implements ProviderInstance {
256
256
  // R4b miss: collapse at boot+8s, dispatch at boot+12.4s > the 12s boot window).
257
257
  // Anchoring on the collapse moment makes the window cover dispatch-delay+turn.
258
258
  private startupGraceCollapseAt: number | null = null;
259
+ // ANTIGRAVITY-PREMATURE-COMPLETION gate: wall-clock when the CURRENT mesh task
260
+ // was injected/attached (attachMeshAssignment). The injected task counts as having
261
+ // genuinely entered generating only once a turn STARTS after this moment
262
+ // (currentTurnStartedAt > meshTaskInjectedAt) — because currentTurnStartedAt
263
+ // persists from the PRIOR turn and forceSendMessage pre-binds currentTurnTaskId at
264
+ // inject time, so neither alone distinguishes "injected but not yet generating"
265
+ // from "genuinely generating". This timestamp is that discriminator. 0 = no task
266
+ // injected since boot (ad-hoc/non-mesh turns fall back to the plain turn-started check).
267
+ private meshTaskInjectedAt = 0;
259
268
  private settings: Record<string, any> = {};
260
269
  private monitor: StatusMonitor;
261
270
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
@@ -862,6 +871,13 @@ export class CliProviderInstance implements ProviderInstance {
862
871
  */
863
872
  attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; dispatchNonce?: number; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
864
873
  if (!assignment?.meshId) return;
874
+ // ANTIGRAVITY-PREMATURE-COMPLETION gate: stamp the injection moment for a task
875
+ // attach so injectedTaskHasStartedGenerating() can require the producing turn to
876
+ // START after this point (rejecting the prior turn's stale native-history tail
877
+ // that would otherwise fire generating_completed before generating_started).
878
+ if (assignment.taskId && assignment.taskId.trim()) {
879
+ this.meshTaskInjectedAt = Date.now();
880
+ }
865
881
  this.settings = {
866
882
  ...this.settings,
867
883
  meshNodeFor: assignment.meshId,
@@ -1468,7 +1484,25 @@ export class CliProviderInstance implements ProviderInstance {
1468
1484
 
1469
1485
  const externalMessages = this.readExternalCompletionMessages();
1470
1486
  if (externalMessages) {
1471
- const present = turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
1487
+ // ANTIGRAVITY-PREMATURE-COMPLETION (recur): the external-native transcript is
1488
+ // the WHOLE session's native-history, not turn-scoped by the provider. On a
1489
+ // reused-idle antigravity session, a completion-gate poll can run AFTER a new
1490
+ // task is injected but BEFORE that task's onTurnStarted fires. The transcript
1491
+ // then still tails the PRIOR turn's final-assistant bubble; completionHasFinal‑
1492
+ // AssistantMessage accepts it (turnStartedAt is 0/undefined pre-onTurnStarted →
1493
+ // fails open, or the prior bubble post-dates the prior turn → passes) and a
1494
+ // generating_completed fires for the NEW task BEFORE generating_started — the
1495
+ // exact live 06:34→06:35 inversion. Gate external-native evidence on the current
1496
+ // injected task having genuinely entered generating: if a task is attached but its
1497
+ // turn has not started (turnStartedInjectedTask() === false), the tail is stale by
1498
+ // construction, so this evidence must NOT satisfy the completion gate. Fail CLOSED
1499
+ // (present=false) rather than open. This does NOT regress the rc.480/481 win: once
1500
+ // the injected task's onTurnStarted fires, currentTurnStartedAt/currentTurnTaskId
1501
+ // bind to it and a real final bubble still fires completion normally.
1502
+ const injectedTaskGenerating = this.injectedTaskHasStartedGenerating();
1503
+ const present = injectedTaskGenerating
1504
+ && turnClosed
1505
+ && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
1472
1506
  // Dashboard tail-repair cache: this runs on EVERY completion check
1473
1507
  // (mesh AND non-mesh — the non-mesh path suppresses the
1474
1508
  // generating_completed emit, so completionFinalSummary never runs there
@@ -1924,6 +1958,43 @@ export class CliProviderInstance implements ProviderInstance {
1924
1958
  return typeof scalar === 'string' && scalar.trim() ? scalar : undefined;
1925
1959
  }
1926
1960
 
1961
+ /**
1962
+ * ANTIGRAVITY-PREMATURE-COMPLETION gate: has the CURRENTLY-injected task actually
1963
+ * entered generating (a real onTurnStarted for it)? Used to reject stale external-
1964
+ * native completion evidence that predates the injected task's turn.
1965
+ *
1966
+ * The injected task's id is the session scalar `meshActiveTaskId`, stamped by
1967
+ * attachMeshAssignment BEFORE the PTY turn starts. That stamp also records
1968
+ * `meshTaskInjectedAt`. The turn that has genuinely started is marked by
1969
+ * `adapter.currentTurnStartedAt` (set ONLY by onTurnStarted). Two naive signals both
1970
+ * FAIL for a reused-idle session:
1971
+ * - `currentTurnStartedAt > 0` alone: it persists from the PRIOR turn, so it is
1972
+ * already > 0 the instant a new task is injected (pre-onTurnStarted).
1973
+ * - `currentTurnTaskId === meshActiveTaskId` alone: forceSendMessage (the mesh
1974
+ * inject path) pre-binds currentTurnTaskId to the new taskId at inject time,
1975
+ * BEFORE the turn starts, so this matches prematurely too.
1976
+ * The robust discriminator is TEMPORAL: the producing turn must have STARTED AFTER
1977
+ * the injection — `currentTurnStartedAt > meshTaskInjectedAt`. Only then has the
1978
+ * injected task's own onTurnStarted fired.
1979
+ * - No injected task since boot (meshTaskInjectedAt === 0, e.g. an ad-hoc/dashboard
1980
+ * turn or a non-mesh session): fall back to the plain "a turn has started" check
1981
+ * so non-mesh completion is unaffected.
1982
+ * Fails CLOSED for the injected-but-not-started window; open once the injected turn is
1983
+ * genuinely underway (preserving the rc.480/481 completion-fires win).
1984
+ */
1985
+ private injectedTaskHasStartedGenerating(): boolean {
1986
+ const turnStartedAt = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
1987
+ ? (this.adapter as any).currentTurnStartedAt as number
1988
+ : 0;
1989
+ const turnStarted = Number.isFinite(turnStartedAt) && turnStartedAt > 0;
1990
+ if (this.meshTaskInjectedAt <= 0) {
1991
+ // No mesh task injected since boot — plain "a turn has started" suffices.
1992
+ return turnStarted;
1993
+ }
1994
+ // A task was injected: the producing turn must have STARTED after that injection.
1995
+ return turnStarted && turnStartedAt > this.meshTaskInjectedAt;
1996
+ }
1997
+
1927
1998
  // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1928
1999
  // the primary grep anchor; instanceId is the session fallback.
1929
2000
  private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {