@adhdev/daemon-core 0.9.82-rc.484 → 0.9.82-rc.485

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.484",
3
+ "version": "0.9.82-rc.485",
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.484",
51
- "@adhdev/session-host-core": "0.9.82-rc.484",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.485",
51
+ "@adhdev/session-host-core": "0.9.82-rc.485",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -208,6 +208,13 @@ export class ProviderCliAdapter implements CliAdapter {
208
208
  private lastScreenSnapshot = '';
209
209
  private lastScreenText = '';
210
210
  private lastScreenSnapshotReadAt = Number.NEGATIVE_INFINITY;
211
+ // (FALSEIDLE Path-C) Count of CONSECUTIVE getStatus polls that observed a
212
+ // gate-eligible static-idle screen (detect=idle, no modal, quiet, empty
213
+ // partial buffer). For a mesh/autonomous worker we require several such
214
+ // polls in a row before confirming static-idle (see getStatus), so a
215
+ // single momentarily-silent point-sample of a still-live turn cannot flip
216
+ // it. Reset to 0 the instant any poll is ineligible.
217
+ private staticIdlePollStreak = 0;
211
218
 
212
219
  // Server log forwarding
213
220
  private serverConn: any = null;
@@ -287,6 +294,13 @@ export class ProviderCliAdapter implements CliAdapter {
287
294
  result: any;
288
295
  } | null = null;
289
296
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
297
+ // (FALSEIDLE Path-C) Consecutive gate-eligible getStatus polls a mesh/autonomous
298
+ // session must show before the poll-static-idle confirm fires. 2 = one extra
299
+ // status tick of hysteresis: enough to reject a single momentary-silence
300
+ // point-sample of a still-live turn, cheap enough not to materially delay a
301
+ // genuine boot-wedge release (the wedge screen is stably static, so it clears
302
+ // every consecutive poll and confirms on the 2nd).
303
+ private static readonly STATIC_IDLE_POLL_CONFIRM_COUNT = 2;
290
304
 
291
305
  private readonly providerResolutionMeta: ProviderResolutionMeta;
292
306
 
@@ -394,6 +408,20 @@ export class ProviderCliAdapter implements CliAdapter {
394
408
  return this.timeouts.statusActivityHold;
395
409
  }
396
410
 
411
+ // (FALSEIDLE Path-C) Whether this session is a mesh worker or coordinator's
412
+ // own autonomous session. Mirrors CliProviderInstance.isAutonomousMeshSession
413
+ // over the runtimeSettings the instance mirrors down via updateRuntimeSettings
414
+ // (meshNodeFor / meshActiveTaskId / meshNodeId / launchedByCoordinator =
415
+ // isMeshWorkerSession, plus meshCoordinatorFor for the coordinator's own turn).
416
+ // Such a session has no human at the keyboard to correct a premature idle, so
417
+ // the poll-static-idle confirm is debounced for it (multiple consecutive idle
418
+ // polls) rather than fired on a single point-sample.
419
+ private isAutonomousMeshSession(): boolean {
420
+ const s = this.runtimeSettings;
421
+ return !!(s?.meshNodeFor || s?.meshActiveTaskId || s?.meshNodeId
422
+ || s?.launchedByCoordinator || s?.meshCoordinatorFor);
423
+ }
424
+
397
425
  // Resolved timeouts
398
426
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
399
427
 
@@ -956,15 +984,56 @@ export class ProviderCliAdapter implements CliAdapter {
956
984
  const quietForMs = this.lastNonEmptyOutputAt
957
985
  ? (now - this.lastNonEmptyOutputAt)
958
986
  : Number.MAX_SAFE_INTEGER;
987
+ let eligible = false;
959
988
  if (quietForMs >= this.getStatusActivityHoldMs()) {
960
989
  const screenText = this.terminalScreen.getText();
961
990
  const pollDetect = this.runDetectStatus(screenText || this.recentOutputBuffer);
962
991
  const pollModal = this.runParseApproval(screenText)
963
992
  || this.runParseApproval(this.recentOutputBuffer);
964
- if (pollDetect === 'idle' && !pollModal) {
993
+ // (FALSEIDLE Path-C) Final-assistant / pending-response discriminator.
994
+ // Paths A and B refuse to finalize a turn whose partial-response buffer
995
+ // is still non-empty (getCompletedFinalizationBlock 'partial_response_pending'
996
+ // / completionFinalAssistantEvidence turnClosed at cli-provider-instance.ts).
997
+ // Path C (this poll) previously OMITTED it, so a genuinely-live but
998
+ // momentarily-silent turn — silent thinking, a backgrounded/long tool child,
999
+ // the gap between two assistant bubbles — whose currentTurnScope anchor was
1000
+ // lost still satisfied the weaker gate and flipped to idle prematurely.
1001
+ // Require an EMPTY partial buffer here too. getPartialResponse() returns the
1002
+ // accumulated assistant stream while isWaitingForResponse (which
1003
+ // applyGenerating leaves set on the boot-banner wedge too), so this does NOT
1004
+ // reintroduce the D4b wedge: the attach/boot-banner seeds only the static
1005
+ // ready screen — no assistant turn ever streamed — so its partial buffer is
1006
+ // empty and the gate still releases it. A mid-turn quiet gap holds a
1007
+ // non-empty buffer and is deferred.
1008
+ const partial = this.getPartialResponse();
1009
+ const partialPending = typeof partial === 'string' && partial.trim().length > 0;
1010
+ eligible = pollDetect === 'idle' && !pollModal && !partialPending;
1011
+ }
1012
+ if (eligible) {
1013
+ // (FALSEIDLE Path-C) Debounce for autonomous mesh sessions. A worker /
1014
+ // coordinator has no human to correct a premature idle, and a single
1015
+ // runDetectStatus point-sample can land in a live turn's momentary silence.
1016
+ // Require STATIC_IDLE_POLL_CONFIRM_COUNT consecutive eligible polls before
1017
+ // confirming, so the FSM must observe a sustained static-idle screen — a
1018
+ // turn that resumes (fresh output, or a re-armed turn scope) resets the
1019
+ // streak. The status poll runs on the 30s-idle / 5s-generating heartbeat and
1020
+ // this getStatus gate is re-hit each dashboard status tick, so 2 confirms is
1021
+ // ~one extra tick of hysteresis — enough to reject a one-sample silence gap
1022
+ // without materially delaying a genuine boot-wedge release. Foreground /
1023
+ // attended sessions keep the single-poll confirm (a human is watching Send).
1024
+ const requiredStreak = this.isAutonomousMeshSession()
1025
+ ? ProviderCliAdapter.STATIC_IDLE_POLL_CONFIRM_COUNT
1026
+ : 1;
1027
+ this.staticIdlePollStreak += 1;
1028
+ if (this.staticIdlePollStreak >= requiredStreak) {
965
1029
  this.engine.confirmPollStaticIdle('poll_static_idle');
1030
+ this.staticIdlePollStreak = 0;
966
1031
  }
1032
+ } else {
1033
+ this.staticIdlePollStreak = 0;
967
1034
  }
1035
+ } else {
1036
+ this.staticIdlePollStreak = 0;
968
1037
  }
969
1038
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
970
1039
  let effectiveModal = startupModal || this.engine.activeModal;
@@ -92,12 +92,62 @@ export function getMeshWithCache(components: DaemonComponents, meshId: string):
92
92
  * worktree node therefore read a permanently stale 'running' here, so
93
93
  * shouldDeferDispatchForBootstrap deferred its claim forever. We now MERGE the inline
94
94
  * cache's dynamic runtime bootstrap state onto the config node (config keeps its static
95
- * fields; worktreeBootstrap is preferred from the inline cache when the inline entry
96
- * carries a status) so the gate view sees the terminal stamp. Regression-safe: when the
97
- * inline entry has no bootstrap status the config value is kept, and when bootstrap is
98
- * genuinely still 'running' (no terminal stamp yet) the gate still defers only a node
99
- * whose inline stamp has actually reached a terminal state opens the gate.
95
+ * fields; worktreeBootstrap is preferred from the inline cache) so EVERY consumer of the
96
+ * merged view not just tryAssignQueueTask's gate observes the terminal stamp.
97
+ *
98
+ * RESIDUAL-getMeshWithCache-bootstrap-overlay (precedence guard): the overlay is DIRECTIONAL
99
+ * it prefers the inline entry ONLY when the inline runtime state is actually fresher, never
100
+ * merely because the inline entry carries a status. inlineBootstrapIsFresher() (below) permits
101
+ * the overlay in exactly two cases, mirroring the mission's "terminal OR strictly newer" rule:
102
+ * (1) the inline state is TERMINAL ('complete'/'failed') while the config state is NOT — the
103
+ * markWorktreeBootstrapTerminalState synchronous stamp the async config persist has not
104
+ * yet caught up to; this is the whole point of the overlay (opens the gate).
105
+ * (2) both states are non-terminal but the inline startedAt is STRICTLY newer — a re-driven
106
+ * bootstrap whose fresher 'running' epoch the config has not observed.
107
+ * It REFUSES the overlay when the config state is already terminal and the inline state is a
108
+ * stale/non-terminal 'running' — otherwise a stale inline 'running' would MASK a genuinely
109
+ * complete config node and re-defer its claim forever (the exact anti-case this guard closes).
110
+ * And when both are 'running' with no newer epoch, the config value is kept and the gate still
111
+ * defers — the half-built-worktree → empty-session defense is preserved: only a terminal-confirmed
112
+ * inline state, never an ambiguous read, ever opens the gate.
100
113
  */
114
+ const BOOTSTRAP_TERMINAL_STATUSES = new Set(['complete', 'failed']);
115
+
116
+ function bootstrapEpochMs(bootstrap: any): number {
117
+ const raw = readNonEmptyString(bootstrap?.startedAt) || readNonEmptyString(bootstrap?.completedAt);
118
+ if (!raw) return 0;
119
+ const parsed = Date.parse(raw);
120
+ return Number.isFinite(parsed) ? parsed : 0;
121
+ }
122
+
123
+ /**
124
+ * Directional freshness test for the bootstrap overlay: may the inline runtime state
125
+ * REPLACE the config runtime state? True only when the inline state is terminal and the
126
+ * config state is not (the synchronous terminal stamp the async persist lags), or when
127
+ * both are non-terminal but the inline epoch is strictly newer. A terminal config state is
128
+ * never overwritten by a non-terminal inline read (the stale-'running'-masks-complete
129
+ * anti-case), and equal states never trigger a rewrite.
130
+ */
131
+ function inlineBootstrapIsFresher(inlineBootstrap: any, configBootstrap: any): boolean {
132
+ const inlineStatus = readNonEmptyString(inlineBootstrap?.status);
133
+ if (!inlineStatus) return false;
134
+ const configStatus = readNonEmptyString(configBootstrap?.status);
135
+ const inlineTerminal = BOOTSTRAP_TERMINAL_STATUSES.has(inlineStatus);
136
+ const configTerminal = !!configStatus && BOOTSTRAP_TERMINAL_STATUSES.has(configStatus);
137
+ // Config already terminal: only a DIFFERENT terminal inline state (e.g. config 'complete'
138
+ // vs a later 'failed' re-drive) may supersede it; a non-terminal inline read must never
139
+ // mask a terminal config state.
140
+ if (configTerminal) {
141
+ return inlineTerminal && inlineStatus !== configStatus
142
+ && bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
143
+ }
144
+ // Config not terminal: an inline terminal state is always fresher (opens the gate).
145
+ if (inlineTerminal) return true;
146
+ // Both non-terminal: prefer inline only when its epoch is strictly newer (a re-driven
147
+ // bootstrap the config has not observed). Equal/older ⇒ keep config, gate still defers.
148
+ return bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
149
+ }
150
+
101
151
  function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
102
152
  const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
103
153
  const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
@@ -111,10 +161,10 @@ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
111
161
  if (!cachedId) return false;
112
162
  return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
113
163
  });
114
- // Overlay the inline cache's fresher worktreeBootstrap state onto any config node
115
- // that also exists in the inline cache. Only override when the inline entry actually
116
- // carries a bootstrap status (an incomplete inline entry never masks a genuine config
117
- // 'running'), mirroring the inline-first read the bootstrap gate does directly.
164
+ // Overlay the inline cache's fresher worktreeBootstrap state onto any config node that
165
+ // also exists in the inline cache. inlineBootstrapIsFresher() gates the overlay to the
166
+ // "terminal OR strictly newer" cases, so a stale inline 'running' can never mask a
167
+ // terminal config state and the gate's deferral is preserved for a genuine 'running'.
118
168
  let overlaidLocalNodes: any[] = localNodes;
119
169
  let overlaid = false;
120
170
  for (let i = 0; i < localNodes.length; i++) {
@@ -122,14 +172,14 @@ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
122
172
  const localId = readMeshNodeId(localNode);
123
173
  if (!localId) continue;
124
174
  const inlineMatch = cachedNodes.find((cachedNode: any) => meshNodeIdMatches(cachedNode, localId));
125
- const inlineBootstrapStatus = readNonEmptyString(inlineMatch?.worktreeBootstrap?.status);
126
- if (!inlineMatch || !inlineBootstrapStatus) continue;
175
+ if (!inlineMatch) continue;
176
+ if (!inlineBootstrapIsFresher(inlineMatch.worktreeBootstrap, localNode.worktreeBootstrap)) continue;
127
177
  if (!overlaid) {
128
178
  overlaidLocalNodes = [...localNodes];
129
179
  overlaid = true;
130
180
  }
131
- // Keep the config node's static fields; prefer the inline cache's dynamic
132
- // bootstrap runtime state (fresher terminal stamp).
181
+ // Keep the config node's static fields; overlay only the dynamic worktreeBootstrap
182
+ // runtime substate (fresher terminal stamp / epoch) — config identity is unchanged.
133
183
  overlaidLocalNodes[i] = { ...localNode, worktreeBootstrap: inlineMatch.worktreeBootstrap };
134
184
  }
135
185
  if (!cacheOnly.length && !overlaid) return localMesh;