@adhdev/daemon-core 0.9.82-rc.556 → 0.9.82-rc.558

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.
@@ -170,7 +170,9 @@ export declare class CliProviderInstance implements ProviderInstance {
170
170
  * with no approval never carries recency and is never held (no regression).
171
171
  */
172
172
  private static readonly APPROVAL_RESUME_GRACE_MS;
173
- private static readonly MESH_WORKER_STALL_THRESHOLD_MS;
173
+ private static readonly MESH_WORKER_STALL_IDLE_THRESHOLD_MS;
174
+ private static readonly MESH_WORKER_STALL_TURN_THRESHOLD_MS;
175
+ private static readonly MESH_WORKER_STALL_REFIRE_COOLDOWN_MS;
174
176
  private adapter;
175
177
  private context;
176
178
  private events;
@@ -179,6 +181,8 @@ export declare class CliProviderInstance implements ProviderInstance {
179
181
  private generatingStartedAt;
180
182
  private meshStallAnchorAt;
181
183
  private meshStallEmittedForAnchor;
184
+ private meshStallTurnActiveLast;
185
+ private meshStallLastFiredAt;
182
186
  private busyEpoch;
183
187
  private fastCollapseSynthesizedTaskId;
184
188
  private startupGraceCollapseAt;
@@ -507,25 +511,40 @@ export declare class CliProviderInstance implements ProviderInstance {
507
511
  * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
508
512
  * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
509
513
  * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
510
- * live worker's screen has been byte-for-byte unchanged for
511
- * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
512
- * monitor:no_progress event down the existing task_stalled ledger +
513
- * pendingCoordinatorEvent path.
514
+ * live worker's screen has been byte-for-byte unchanged past the turn-scoped
515
+ * threshold (below), fire ONE informational monitor:no_progress event down the
516
+ * existing task_stalled ledger + pendingCoordinatorEvent path.
514
517
  *
515
- * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
516
- * (which would couple it to the generating-only StatusMonitor and the
517
- * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
518
- * minutes; that is accepted and surfaced as an informational stall (NOT a
519
- * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
520
- * called here, so status heartbeats never move the stall anchor.
518
+ * The reported status is read for TWO bounded purposes only — it does NOT
519
+ * suppress the fire (sticky-status blindness would hide a real wedge):
520
+ * Fix B (anchor re-arm on turn end): the FSM's completion/idle transition
521
+ * never touches the stall anchor, so a completed worker that goes idle would
522
+ * otherwise keep counting from its LAST pre-completion output and false-fire
523
+ * in the quiet valley right after finishing. We detect the turn-active edge
524
+ * (hasAdapterPendingResponse()) and, on active → inactive, re-arm the anchor
525
+ * to `now` so the post-completion idle valley starts a fresh clock.
526
+ * • Fix C (turn-scoped threshold): while a turn is genuinely in flight the bar
527
+ * is raised (MESH_WORKER_STALL_TURN_THRESHOLD_MS) to absorb long normal
528
+ * thinking gaps; outside a turn the tighter idle bound applies. This is a
529
+ * RAISE, not a skip — a real mid-turn wedge still fires late at the turn bound.
521
530
  *
522
531
  * Anchoring: the episode arms against the current lastOutputAt; a worker that
523
532
  * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
524
533
  * time) so a silent spawn is still caught. Any new output re-arms the anchor
525
534
  * and clears the emitted flag, so one continuous stall emits at most once and a
526
- * later stall re-arms cleanly.
535
+ * later stall re-arms cleanly. Fix E adds a per-session refire cooldown so a
536
+ * dribble of one-byte-per-few-minutes output cannot page the coordinator on
537
+ * every re-arm.
527
538
  */
528
539
  checkMeshWorkerStall(now?: number): void;
540
+ /**
541
+ * FALSE-STALL-WATCHDOG-OVERFIRE: drop the entire stall episode for this session.
542
+ * Called when the session is no longer a mesh worker or the PTY is dead — the
543
+ * next live tick re-arms a fresh episode. Clears the fix-B turn-edge state and
544
+ * the fix-E refire cooldown alongside the base anchor so a re-armed session
545
+ * starts with a clean slate (a restart is not throttled by a prior stall).
546
+ */
547
+ private resetMeshStallEpisode;
529
548
  /**
530
549
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
531
550
  * 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": "0.9.82-rc.556",
3
+ "version": "0.9.82-rc.558",
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.556",
51
- "@adhdev/session-host-core": "0.9.82-rc.556",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.558",
51
+ "@adhdev/session-host-core": "0.9.82-rc.558",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -259,9 +259,30 @@ export class CliProviderInstance implements ProviderInstance {
259
259
  // event. Unlike the StatusMonitor no-progress watchdog (which only runs while a
260
260
  // turn is generating), this observes pure screen stasis regardless of the
261
261
  // reported status — a worker parked idle, wedged mid-turn, or spawned with no
262
- // output at all. 180s matches DEFAULT_MONITOR_CONFIG.noProgressThresholdSec so
263
- // the two watchdogs agree on the same "long interval" bound.
264
- private static readonly MESH_WORKER_STALL_THRESHOLD_MS = 180_000;
262
+ // output at all.
263
+ //
264
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix C): the threshold is now turn-scoped. When
265
+ // an explicit turn is in flight (hasAdapterPendingResponse() — the adapter's
266
+ // currentTurnScope / isWaitingForResponse / isProcessing / partial buffer), a
267
+ // long silent thinking gap (claude-cli opus/high can go minutes between visible
268
+ // tokens) is normal, so the bar is raised to MESH_WORKER_STALL_TURN_THRESHOLD_MS.
269
+ // Outside a turn (idle) the tighter MESH_WORKER_STALL_IDLE_THRESHOLD_MS applies.
270
+ // This is a THRESHOLD RAISE, not a suppression: a genuine mid-turn wedge still
271
+ // fires (late, at the turn bound) rather than being hidden behind a sticky
272
+ // generating status. 180s matches DEFAULT_MONITOR_CONFIG.noProgressThresholdSec
273
+ // so the idle bound agrees with the StatusMonitor watchdog's "long interval".
274
+ private static readonly MESH_WORKER_STALL_IDLE_THRESHOLD_MS = 180_000;
275
+ private static readonly MESH_WORKER_STALL_TURN_THRESHOLD_MS = 360_000;
276
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix E): minimum spacing between two stall
277
+ // notifications for the SAME session, even across anchor re-arms. The
278
+ // per-anchor meshStallEmittedForAnchor guard already stops a single continuous
279
+ // stall from re-firing; this cooldown additionally throttles the churn where a
280
+ // worker dribbles one byte every few minutes (each re-arming the anchor and then
281
+ // re-crossing the bar), which would otherwise page the coordinator repeatedly.
282
+ // The stall is still fired for observability — just not more than once per
283
+ // window per session. Set larger than the stall thresholds so consecutive
284
+ // re-armed stalls a few minutes apart collapse into a single notification.
285
+ private static readonly MESH_WORKER_STALL_REFIRE_COOLDOWN_MS = 600_000;
265
286
 
266
287
  private adapter: ProviderCliAdapter;
267
288
  private context: InstanceContext | null = null;
@@ -286,6 +307,18 @@ export class CliProviderInstance implements ProviderInstance {
286
307
  // -1 = not yet initialised for this session.
287
308
  private meshStallAnchorAt = -1;
288
309
  private meshStallEmittedForAnchor = false;
310
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix B): the turn-active state observed on the
311
+ // PREVIOUS stall tick. When a turn ends (active → inactive: the completion/idle
312
+ // transition), the anchor is force re-armed to `now` so the completed→idle valley
313
+ // does not fire against the pre-completion output clock. The turn-end edge is the
314
+ // signal; updateStatus's own idle transition does not touch the stall anchor, so
315
+ // this watchdog-local edge detector owns the re-arm. undefined = no prior tick.
316
+ private meshStallTurnActiveLast: boolean | undefined = undefined;
317
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix E): wall-clock of the most recent stall
318
+ // emission for this session, or -1 if none. Enforces
319
+ // MESH_WORKER_STALL_REFIRE_COOLDOWN_MS between successive emissions across
320
+ // anchor re-arms (the per-anchor guard only covers a single continuous stall).
321
+ private meshStallLastFiredAt = -1;
289
322
  // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
290
323
  // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
291
324
  // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
@@ -2108,30 +2141,36 @@ export class CliProviderInstance implements ProviderInstance {
2108
2141
  * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
2109
2142
  * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
2110
2143
  * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
2111
- * live worker's screen has been byte-for-byte unchanged for
2112
- * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
2113
- * monitor:no_progress event down the existing task_stalled ledger +
2114
- * pendingCoordinatorEvent path.
2144
+ * live worker's screen has been byte-for-byte unchanged past the turn-scoped
2145
+ * threshold (below), fire ONE informational monitor:no_progress event down the
2146
+ * existing task_stalled ledger + pendingCoordinatorEvent path.
2115
2147
  *
2116
- * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
2117
- * (which would couple it to the generating-only StatusMonitor and the
2118
- * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
2119
- * minutes; that is accepted and surfaced as an informational stall (NOT a
2120
- * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
2121
- * called here, so status heartbeats never move the stall anchor.
2148
+ * The reported status is read for TWO bounded purposes only — it does NOT
2149
+ * suppress the fire (sticky-status blindness would hide a real wedge):
2150
+ * Fix B (anchor re-arm on turn end): the FSM's completion/idle transition
2151
+ * never touches the stall anchor, so a completed worker that goes idle would
2152
+ * otherwise keep counting from its LAST pre-completion output and false-fire
2153
+ * in the quiet valley right after finishing. We detect the turn-active edge
2154
+ * (hasAdapterPendingResponse()) and, on active → inactive, re-arm the anchor
2155
+ * to `now` so the post-completion idle valley starts a fresh clock.
2156
+ * • Fix C (turn-scoped threshold): while a turn is genuinely in flight the bar
2157
+ * is raised (MESH_WORKER_STALL_TURN_THRESHOLD_MS) to absorb long normal
2158
+ * thinking gaps; outside a turn the tighter idle bound applies. This is a
2159
+ * RAISE, not a skip — a real mid-turn wedge still fires late at the turn bound.
2122
2160
  *
2123
2161
  * Anchoring: the episode arms against the current lastOutputAt; a worker that
2124
2162
  * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
2125
2163
  * time) so a silent spawn is still caught. Any new output re-arms the anchor
2126
2164
  * and clears the emitted flag, so one continuous stall emits at most once and a
2127
- * later stall re-arms cleanly.
2165
+ * later stall re-arms cleanly. Fix E adds a per-session refire cooldown so a
2166
+ * dribble of one-byte-per-few-minutes output cannot page the coordinator on
2167
+ * every re-arm.
2128
2168
  */
2129
2169
  checkMeshWorkerStall(now: number = Date.now()): void {
2130
2170
  if (!this.isMeshWorkerSession()) {
2131
2171
  // Not (or no longer) a mesh worker — drop any armed episode so a session
2132
2172
  // whose mesh markers were detached does not carry a stale anchor.
2133
- this.meshStallAnchorAt = -1;
2134
- this.meshStallEmittedForAnchor = false;
2173
+ this.resetMeshStallEpisode();
2135
2174
  return;
2136
2175
  }
2137
2176
  // Defensive: not every CliAdapter implementation exposes isAlive() — the
@@ -2144,27 +2183,53 @@ export class CliProviderInstance implements ProviderInstance {
2144
2183
  if (typeof this.adapter.isAlive === 'function' && !this.adapter.isAlive()) {
2145
2184
  // Dead PTY: nothing to watch. agent:stopped covers the exit; re-arm on
2146
2185
  // the next live session so a restart starts a fresh episode.
2147
- this.meshStallAnchorAt = -1;
2148
- this.meshStallEmittedForAnchor = false;
2186
+ this.resetMeshStallEpisode();
2149
2187
  return;
2150
2188
  }
2151
2189
 
2152
2190
  let lastOutputAt: number;
2191
+ let observedStatus = 'unknown';
2153
2192
  try {
2154
2193
  // allowParse:false — a cheap status read that must NOT trigger parsing
2155
2194
  // and must NOT mutate lastOutputAt (getState/getStatus never bump it).
2156
- const status = this.adapter.getStatus({ allowParse: false }) as { lastOutputAt?: unknown };
2195
+ // Read once for both the anchor clock and the observedStatus context.
2196
+ const status = this.adapter.getStatus({ allowParse: false }) as { lastOutputAt?: unknown; status?: unknown };
2157
2197
  lastOutputAt = typeof status?.lastOutputAt === 'number' && Number.isFinite(status.lastOutputAt)
2158
2198
  ? status.lastOutputAt
2159
2199
  : 0;
2200
+ if (typeof status?.status === 'string' && status.status) observedStatus = status.status;
2160
2201
  } catch {
2161
2202
  return; // defensive: a failed status read just skips this tick
2162
2203
  }
2163
2204
 
2205
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix B + C): observe whether a turn is
2206
+ // genuinely in flight. hasAdapterPendingResponse() reads the adapter's
2207
+ // currentTurnScope / isWaitingForResponse / isProcessing / partial buffer —
2208
+ // real liveness, NOT the sticky FSM status. This drives both the turn-end
2209
+ // anchor re-arm (B) and the turn-scoped threshold (C).
2210
+ let turnActive = false;
2211
+ try {
2212
+ turnActive = this.hasAdapterPendingResponse();
2213
+ } catch { /* defensive: missing adapter diagnostics → treat as no active turn */ }
2214
+ const turnEnded = this.meshStallTurnActiveLast === true && !turnActive;
2215
+ this.meshStallTurnActiveLast = turnActive;
2216
+
2164
2217
  // The anchor is the last raw output; before any output, the spawn time so a
2165
2218
  // silent worker is still caught.
2166
2219
  const anchor = lastOutputAt > 0 ? lastOutputAt : this.startedAt;
2167
2220
 
2221
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix B): a turn just ended (active → idle).
2222
+ // The completion/idle FSM transition does not touch the stall anchor, so the
2223
+ // post-completion quiet valley would otherwise be measured from the last
2224
+ // pre-completion output and false-fire almost immediately. Force the anchor
2225
+ // forward to `now` so the idle valley starts a fresh clock. This overrides a
2226
+ // stale (pre-completion) anchor even though `anchor` itself did not advance.
2227
+ if (turnEnded && this.meshStallAnchorAt !== -1) {
2228
+ this.meshStallAnchorAt = Math.max(anchor, now);
2229
+ this.meshStallEmittedForAnchor = false;
2230
+ return;
2231
+ }
2232
+
2168
2233
  if (this.meshStallAnchorAt === -1) {
2169
2234
  // First observation this session — arm against the current anchor.
2170
2235
  this.meshStallAnchorAt = anchor;
@@ -2181,20 +2246,31 @@ export class CliProviderInstance implements ProviderInstance {
2181
2246
 
2182
2247
  if (this.meshStallEmittedForAnchor) return; // already fired for this stall
2183
2248
 
2249
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix C): raise the bar while a turn is in
2250
+ // flight so a long normal thinking gap is absorbed; a genuine mid-turn wedge
2251
+ // still fires — late, at the turn bound — rather than being hidden.
2252
+ const threshold = turnActive
2253
+ ? CliProviderInstance.MESH_WORKER_STALL_TURN_THRESHOLD_MS
2254
+ : CliProviderInstance.MESH_WORKER_STALL_IDLE_THRESHOLD_MS;
2184
2255
  const stalledMs = now - this.meshStallAnchorAt;
2185
- if (stalledMs < CliProviderInstance.MESH_WORKER_STALL_THRESHOLD_MS) return;
2256
+ if (stalledMs < threshold) return;
2186
2257
 
2258
+ // FALSE-STALL-WATCHDOG-OVERFIRE (fix E): per-session refire cooldown. Mark
2259
+ // this anchor emitted regardless so a still-static anchor does not re-check
2260
+ // every tick, but suppress the actual coordinator notification (and the
2261
+ // trace/event) when the previous emission for this session was too recent.
2187
2262
  this.meshStallEmittedForAnchor = true;
2263
+ if (this.meshStallLastFiredAt >= 0
2264
+ && now - this.meshStallLastFiredAt < CliProviderInstance.MESH_WORKER_STALL_REFIRE_COOLDOWN_MS) {
2265
+ return;
2266
+ }
2267
+ this.meshStallLastFiredAt = now;
2188
2268
 
2189
- // observedStatus is surfaced as context only deliberately NOT stamped as
2190
- // the reconciliation-triggering `status` field (which would let
2269
+ // observedStatus (read above from the single allowParse:false status probe)
2270
+ // is surfaced as context only deliberately NOT stamped as the
2271
+ // reconciliation-triggering `status` field (which would let
2191
2272
  // buildNoProgressCompletionReconciliation mistake an idle stall for a
2192
2273
  // completion). See mesh-events-stale.buildNoProgressCompletionReconciliation.
2193
- let observedStatus = 'unknown';
2194
- try {
2195
- const s = (this.adapter.getStatus({ allowParse: false }) as { status?: unknown })?.status;
2196
- if (typeof s === 'string' && s) observedStatus = s;
2197
- } catch { /* best-effort context only */ }
2198
2274
 
2199
2275
  if (this.isMeshWorkerSession()) {
2200
2276
  traceMeshEventStage('fired', this.meshTraceCtx('monitor:no_progress'), 'mesh_worker_stall_watchdog');
@@ -2217,6 +2293,20 @@ export class CliProviderInstance implements ProviderInstance {
2217
2293
  });
2218
2294
  }
2219
2295
 
2296
+ /**
2297
+ * FALSE-STALL-WATCHDOG-OVERFIRE: drop the entire stall episode for this session.
2298
+ * Called when the session is no longer a mesh worker or the PTY is dead — the
2299
+ * next live tick re-arms a fresh episode. Clears the fix-B turn-edge state and
2300
+ * the fix-E refire cooldown alongside the base anchor so a re-armed session
2301
+ * starts with a clean slate (a restart is not throttled by a prior stall).
2302
+ */
2303
+ private resetMeshStallEpisode(): void {
2304
+ this.meshStallAnchorAt = -1;
2305
+ this.meshStallEmittedForAnchor = false;
2306
+ this.meshStallTurnActiveLast = undefined;
2307
+ this.meshStallLastFiredAt = -1;
2308
+ }
2309
+
2220
2310
  /**
2221
2311
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
2222
2312
  * persist before the in-progress settle gate is torn down. For a delegated