@adhdev/daemon-core 0.9.82-rc.403 → 0.9.82-rc.404

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.
@@ -86,6 +86,7 @@ export declare class CliProviderInstance implements ProviderInstance {
86
86
  private lastStatus;
87
87
  private agentReadyEmitted;
88
88
  private generatingStartedAt;
89
+ private fastCollapseSynthesizedTaskId;
89
90
  private settings;
90
91
  private monitor;
91
92
  private generatingDebounceTimer;
@@ -257,6 +258,34 @@ export declare class CliProviderInstance implements ProviderInstance {
257
258
  * never claimed twice and a queued task is never double-dispatched.
258
259
  */
259
260
  private emitAgentReadyOnce;
261
+ /**
262
+ * GENERATING-BOUNDARY synthetic emit for a first turn that started AND finished
263
+ * inside the startup-grace window without the FSM ever observing a 'generating'
264
+ * frame (an unobservably-fast turn). Synthesizes the agent:generating_started +
265
+ * agent:generating_completed pair so the mesh coordinator learns the worker went
266
+ * idle. Returns true iff it synthesized.
267
+ *
268
+ * Two callers share this defense line:
269
+ * - 'startup_grace_fast_collapse' — the starting→idle transition (the FSM whose
270
+ * spec lacks/hadn't-synced the starting→busy edge collapses starting→idle
271
+ * directly, with no intervening 'generating' frame).
272
+ * - 'startup_grace_idle_turn_collapse' — the idle→idle no-status-change poll
273
+ * (the launch settle already drained starting→idle BEFORE the first turn, so
274
+ * the turn runs+completes while status stays 'idle' the whole time and
275
+ * detectStatusTransition never re-enters its change block — the live rc.403
276
+ * Probe1 miss).
277
+ *
278
+ * False-positive safety (must NOT fire on a benign boot): currentTurnTaskId is
279
+ * set ONLY by onTurnStarted (a real turn STARTED this boot) and persists past
280
+ * completion, so it excludes a true idle boot (null) and a queued-pending first
281
+ * turn that only runs after grace (onTurnStarted not yet called → null).
282
+ * !hasAdapterPendingResponse() excludes a turn still mid-flight. generating
283
+ * never armed (generatingStartedAt===0 && !generatingDebouncePending) means the
284
+ * whole idle→busy→idle never happened for this turn. The once-per-turn guard
285
+ * (fastCollapseSynthesizedTaskId) keeps the re-polled idle-stayed caller from
286
+ * re-emitting every poll.
287
+ */
288
+ private maybeSynthesizeStartupGraceCollapse;
260
289
  private detectStatusTransition;
261
290
  private pushEvent;
262
291
  private flushEvents;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.403",
3
+ "version": "0.9.82-rc.404",
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",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.403",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.404",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -140,6 +140,16 @@ const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
140
140
  // DUPLICATE_DISPATCH_WINDOW_MS (mesh-tools) so the daemon's bubble-level guard
141
141
  // covers the same retry horizon as the MCP-level dispatch dedup.
142
142
  const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
143
+ // GENERATING-BOUNDARY (R4b): age-since-boot window inside which a "first turn that
144
+ // ran+completed without the FSM ever observing a 'generating' frame" is attributed
145
+ // to the startup-grace collapse and synthesized (reason 'startup_grace_idle_turn_collapse').
146
+ // The spec's startup-grace exit is elapsed_ms=8000; a turn dispatched into that window
147
+ // can complete a little after it drains, so the window is the 8s grace plus a settle
148
+ // buffer. The strong discriminator is generatingStartedAt===0 (generating was never
149
+ // observed) AND a started-but-finished turn — the window only keeps the synthesized
150
+ // reason honest and scopes the synthesis to boot, so a much-later unobservably-fast turn
151
+ // is not mislabelled a startup collapse.
152
+ const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12_000;
143
153
 
144
154
  /** Events that signal a dispatched mesh task has reached a terminal state.
145
155
  * Detach the mesh assignment after emitting one of these so the worker's
@@ -464,6 +474,13 @@ export class CliProviderInstance implements ProviderInstance {
464
474
  // first sets it; the other becomes a no-op.
465
475
  private agentReadyEmitted = false;
466
476
  private generatingStartedAt: number = 0;
477
+ // GENERATING-BOUNDARY (R4b): the per-turn taskId for which a startup-grace
478
+ // started+completed pair was already synthesized. Both fast-collapse callers
479
+ // (starting→idle transition AND the idle-stayed no-status-change poll) route
480
+ // through maybeSynthesizeStartupGraceCollapse; the idle-stayed caller re-polls
481
+ // steadily while the session sits idle, so this guard makes the synthesis fire
482
+ // AT MOST ONCE per collapsed turn.
483
+ private fastCollapseSynthesizedTaskId: string | null = null;
467
484
  private settings: Record<string, any> = {};
468
485
  private monitor: StatusMonitor;
469
486
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
@@ -2022,6 +2039,95 @@ export class CliProviderInstance implements ProviderInstance {
2022
2039
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
2023
2040
  }
2024
2041
 
2042
+ /**
2043
+ * GENERATING-BOUNDARY synthetic emit for a first turn that started AND finished
2044
+ * inside the startup-grace window without the FSM ever observing a 'generating'
2045
+ * frame (an unobservably-fast turn). Synthesizes the agent:generating_started +
2046
+ * agent:generating_completed pair so the mesh coordinator learns the worker went
2047
+ * idle. Returns true iff it synthesized.
2048
+ *
2049
+ * Two callers share this defense line:
2050
+ * - 'startup_grace_fast_collapse' — the starting→idle transition (the FSM whose
2051
+ * spec lacks/hadn't-synced the starting→busy edge collapses starting→idle
2052
+ * directly, with no intervening 'generating' frame).
2053
+ * - 'startup_grace_idle_turn_collapse' — the idle→idle no-status-change poll
2054
+ * (the launch settle already drained starting→idle BEFORE the first turn, so
2055
+ * the turn runs+completes while status stays 'idle' the whole time and
2056
+ * detectStatusTransition never re-enters its change block — the live rc.403
2057
+ * Probe1 miss).
2058
+ *
2059
+ * False-positive safety (must NOT fire on a benign boot): currentTurnTaskId is
2060
+ * set ONLY by onTurnStarted (a real turn STARTED this boot) and persists past
2061
+ * completion, so it excludes a true idle boot (null) and a queued-pending first
2062
+ * turn that only runs after grace (onTurnStarted not yet called → null).
2063
+ * !hasAdapterPendingResponse() excludes a turn still mid-flight. generating
2064
+ * never armed (generatingStartedAt===0 && !generatingDebouncePending) means the
2065
+ * whole idle→busy→idle never happened for this turn. The once-per-turn guard
2066
+ * (fastCollapseSynthesizedTaskId) keeps the re-polled idle-stayed caller from
2067
+ * re-emitting every poll.
2068
+ */
2069
+ private maybeSynthesizeStartupGraceCollapse(
2070
+ chatTitle: string,
2071
+ now: number,
2072
+ reason: 'startup_grace_fast_collapse' | 'startup_grace_idle_turn_collapse',
2073
+ ): boolean {
2074
+ const startedTurnTaskId = typeof (this.adapter as any)?.currentTurnTaskId === 'string'
2075
+ && (this.adapter as any).currentTurnTaskId.trim()
2076
+ ? (this.adapter as any).currentTurnTaskId as string
2077
+ : undefined;
2078
+ const fastCollapsed = !!startedTurnTaskId
2079
+ && !this.hasAdapterPendingResponse()
2080
+ && !this.generatingStartedAt
2081
+ && !this.generatingDebouncePending;
2082
+ if (!fastCollapsed) return false;
2083
+ // Once-per-turn: the idle-stayed caller re-polls steadily while the session
2084
+ // sits idle; without this guard it would re-emit the pair every poll.
2085
+ if (this.fastCollapseSynthesizedTaskId === startedTurnTaskId) return false;
2086
+
2087
+ let fcFinalSummary: string | undefined;
2088
+ let fcEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
2089
+ try {
2090
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
2091
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
2092
+ fcEvidenceSource = evidence.source;
2093
+ fcFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
2094
+ } catch { /* best-effort */ }
2095
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || fcEvidenceSource === 'external-native') && !fcFinalSummary;
2096
+ // Mirror the short-generating idle path's suppression: a provider that
2097
+ // requires a final assistant (or external-native history) with NO confirmed
2098
+ // summary and NO mesh context emits nothing — the session is idle with no
2099
+ // confirmed turn, matching startup-blip semantics. With mesh context we still
2100
+ // emit so the coordinator can apply its own timeout/retry logic. Left UNMARKED
2101
+ // so a later poll can retry once the transcript's final assistant lands.
2102
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
2103
+ if (missingEvidence && !hasMeshContext) {
2104
+ LOG.info('CLI', `[${this.type}] ${reason} suppressed: missing final assistant evidence, no mesh context (source=${fcEvidenceSource})`);
2105
+ return false;
2106
+ }
2107
+ // Mark BEFORE emitting so a re-entrant poll cannot double-fire.
2108
+ this.fastCollapseSynthesizedTaskId = startedTurnTaskId;
2109
+ LOG.info('CLI', `[${this.type}] ${reason}: synthesizing started+completed (taskId=${startedTurnTaskId} source=${fcEvidenceSource} hadFinalSummary=${!!fcFinalSummary})`);
2110
+ // Retroactive started so the started→completed pair (and the chat bubble) is
2111
+ // well-formed; pushEvent stamps the per-turn taskId for CANON-B ack.
2112
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now });
2113
+ if (this.isMeshWorkerSession()) {
2114
+ traceMeshEventStage('fired', this.meshTraceCtx(), `${reason} (source=${fcEvidenceSource})`);
2115
+ }
2116
+ this.pushEvent({
2117
+ event: 'agent:generating_completed',
2118
+ chatTitle,
2119
+ duration: 0,
2120
+ timestamp: now,
2121
+ finalSummary: fcFinalSummary,
2122
+ completionDiagnostic: {
2123
+ reason,
2124
+ finalAssistantEvidenceSource: fcEvidenceSource,
2125
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
2126
+ },
2127
+ });
2128
+ return true;
2129
+ }
2130
+
2025
2131
  private detectStatusTransition(): void {
2026
2132
  const now = Date.now();
2027
2133
  // Status-change handling is a hot path: PTY output can fire it many times
@@ -2297,54 +2403,7 @@ export class CliProviderInstance implements ProviderInstance {
2297
2403
  // excluded so we don't fire a premature mid-turn completion; idle→busy self-
2298
2404
  // corrects once the FSM reaches idle). We fire only when a turn started AND has
2299
2405
  // already finished: started-this-boot && !still-in-flight.
2300
- const startedTurnTaskId = typeof (this.adapter as any)?.currentTurnTaskId === 'string'
2301
- && (this.adapter as any).currentTurnTaskId.trim()
2302
- ? (this.adapter as any).currentTurnTaskId as string
2303
- : undefined;
2304
- const fastCollapsed = !!startedTurnTaskId
2305
- && !this.hasAdapterPendingResponse()
2306
- && !this.generatingStartedAt
2307
- && !this.generatingDebouncePending;
2308
- if (fastCollapsed) {
2309
- let fcFinalSummary: string | undefined;
2310
- let fcEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
2311
- try {
2312
- const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
2313
- const evidence = this.completionFinalAssistantEvidence(parsedMessages);
2314
- fcEvidenceSource = evidence.source;
2315
- fcFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
2316
- } catch { /* best-effort */ }
2317
- const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || fcEvidenceSource === 'external-native') && !fcFinalSummary;
2318
- // Mirror the short-generating idle path's suppression: a provider that
2319
- // requires a final assistant (or external-native history) with NO confirmed
2320
- // summary and NO mesh context emits nothing — the session is idle with no
2321
- // confirmed turn, matching startup-blip semantics. With mesh context we still
2322
- // emit so the coordinator can apply its own timeout/retry logic.
2323
- const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
2324
- if (missingEvidence && !hasMeshContext) {
2325
- LOG.info('CLI', `[${this.type}] startup-grace fast-collapse suppressed: missing final assistant evidence, no mesh context (source=${fcEvidenceSource})`);
2326
- } else {
2327
- LOG.info('CLI', `[${this.type}] startup-grace fast-collapse: synthesizing started+completed (taskId=${startedTurnTaskId} source=${fcEvidenceSource} hadFinalSummary=${!!fcFinalSummary})`);
2328
- // Retroactive started so the started→completed pair (and the chat bubble)
2329
- // is well-formed; pushEvent stamps the per-turn taskId for CANON-B ack.
2330
- this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now });
2331
- if (this.isMeshWorkerSession()) {
2332
- traceMeshEventStage('fired', this.meshTraceCtx(), `startup-grace fast-collapse (source=${fcEvidenceSource})`);
2333
- }
2334
- this.pushEvent({
2335
- event: 'agent:generating_completed',
2336
- chatTitle,
2337
- duration: 0,
2338
- timestamp: now,
2339
- finalSummary: fcFinalSummary,
2340
- completionDiagnostic: {
2341
- reason: 'startup_grace_fast_collapse',
2342
- finalAssistantEvidenceSource: fcEvidenceSource,
2343
- ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
2344
- },
2345
- });
2346
- }
2347
- }
2406
+ this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_fast_collapse');
2348
2407
  } else if (newStatus === 'error') {
2349
2408
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2350
2409
  this.generatingDebouncePending = null;
@@ -2373,6 +2432,32 @@ export class CliProviderInstance implements ProviderInstance {
2373
2432
  this.lastStatus = newStatus;
2374
2433
  }
2375
2434
 
2435
+ // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
2436
+ // fast-collapse arm above only fires when the FIRST turn itself drives the
2437
+ // starting→idle transition. When the launch settle already drained
2438
+ // starting→idle BEFORE the first turn arrives, the session is already 'idle'
2439
+ // and a turn that runs+completes inside the startup-grace window — too fast
2440
+ // for any poll to observe a 'generating' frame — produces NO status change
2441
+ // at all (idle→idle). detectStatusTransition's change block above is skipped
2442
+ // entirely, so neither the idle→generating arm nor the starting→idle
2443
+ // fast-collapse arm ever runs, and the mesh coordinator never learns the
2444
+ // worker went idle (the live rc.403 Probe1 miss). Catch it here: an
2445
+ // already-idle poll (no status change), still inside the startup-grace
2446
+ // window, where a turn started this boot and has already finished without
2447
+ // ever arming generating. The same false-positive-safe discriminators apply
2448
+ // (currentTurnTaskId set && !pending && generating never armed), plus a
2449
+ // once-per-turn guard since this branch is re-polled while the session sits
2450
+ // idle. Normal turns that DO reach 'busy' set generatingStartedAt and are
2451
+ // excluded; a queued-pending first turn that only runs after grace falls
2452
+ // outside the window and completes normally via idle→busy→idle.
2453
+ if (
2454
+ newStatus === 'idle'
2455
+ && previousStatus === 'idle'
2456
+ && (now - this.startedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS
2457
+ ) {
2458
+ this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_idle_turn_collapse');
2459
+ }
2460
+
2376
2461
  // Re-arm the queue-claim agent:ready on the FSM's first GENUINE ready.
2377
2462
  //
2378
2463
  // The boot-time starting→idle one-shot above is the historical claim