@adhdev/daemon-core 0.9.82-rc.445 → 0.9.82-rc.446

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.445",
3
+ "version": "0.9.82-rc.446",
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.445",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.446",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -2564,28 +2564,91 @@ export class CliProviderInstance implements ProviderInstance {
2564
2564
  if (shortFinalSummary) {
2565
2565
  this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
2566
2566
  }
2567
+ // FALSE-IDLE short-gen settle: snapshot the producing turn's start + taskId NOW,
2568
+ // before `generatingStartedAt` is reset below — the settle-arm path (mesh sessions,
2569
+ // see the mesh branch further down) needs the same turn anchor the normal
2570
+ // completedDebounce branch captures, and generatingStartedAt is the fallback for it.
2571
+ const shortEngineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2572
+ && Number.isFinite((this.adapter as any).currentTurnStartedAt)
2573
+ ? (this.adapter as any).currentTurnStartedAt as number
2574
+ : 0;
2575
+ const shortTurnStartedAt = shortEngineTurnStart || this.generatingStartedAt || 0;
2576
+ const shortTaskId = this.completingTurnTaskId();
2567
2577
  this.generatingDebouncePending = null;
2568
2578
  this.generatingStartedAt = 0;
2569
- const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || shortEvidenceSource === 'external-native') && !shortFinalSummary;
2579
+ // FALSE-IDLE short-gen: a short-generating completion with NO transcript
2580
+ // backing at all (shortEvidenceSource === 'unavailable': both the screen parse
2581
+ // AND the external-native transcript failed to yield a final assistant) is just
2582
+ // as unproven as an 'external-native' source that returned no final assistant.
2583
+ // Fold 'unavailable' into the missing-evidence predicate so a zero-evidence dip
2584
+ // (the mid-turn point-sample that triggered this whole false-idle bug) is treated
2585
+ // as weak/held, not fired as a genuine completion. A real shortFinalSummary being
2586
+ // present still clears the gate (the !shortFinalSummary guard is unchanged).
2587
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true
2588
+ || shortEvidenceSource === 'external-native'
2589
+ || shortEvidenceSource === 'unavailable') && !shortFinalSummary;
2570
2590
  if (missingEvidence) {
2571
2591
  LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
2572
2592
  }
2573
- // When evidence is missing and there is no active mesh task context, suppress
2574
- // the completion event. Providers with requiresFinalAssistantBeforeIdle or
2575
- // external-native history must confirm a final assistant message before the
2576
- // coordinator records task_completed. Only emit here if a mesh task is active
2577
- // so the coordinator can apply its own timeout/retry logic.
2578
- const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
2579
- if (missingEvidence && !hasMeshContext) {
2580
- LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
2581
- // completedDebouncePending intentionally left null the session is now idle
2582
- // with no confirmed turn, matching the startup-blip suppression semantics.
2583
- // (No EvtTrace: not a mesh session, so nothing routes to a coordinator.)
2584
- } else {
2585
- // EVTTRACE: completion fired (short-generating idle path).
2593
+ if (this.isAutonomousMeshSession()) {
2594
+ // FALSE-IDLE short-gen settle (the core fix): for an autonomously-progressing
2595
+ // mesh session (delegated worker OR self-coordinator), the short-generating
2596
+ // branch was a POINT-SAMPLE a single idle read from getStatus({allowParse:false})
2597
+ // that fires the completion INLINE with zero continuity backing. When the worker
2598
+ // is merely mid-turn (a sub-3s dip between two tool calls), this synchronously
2599
+ // emitted a false agent:generating_completed the coordinator can never correct.
2600
+ //
2601
+ // Route it through the SAME settle + continuity machinery as the normal
2602
+ // completedDebounce branch: arm completedDebouncePending capturing busyEpochAtArm
2603
+ // and lastOutputAtArm, then scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS)
2604
+ // so flushCompletedDebounceIfFinalized() re-verifies CONTINUOUS idle before emitting.
2605
+ // A busy re-entry (busyEpoch bump) or new PTY output within the settle window —
2606
+ // exactly what happens when the worker resumes its next tool call — CANCELS the
2607
+ // false completion. Genuine completions (real final assistant) still emit at the
2608
+ // end of the (short, 4s) settle window; missingEvidence flows through the
2609
+ // finalization block (missing_final_assistant → CANON-C weak/held) rather than
2610
+ // being frozen as a genuine inline fire.
2611
+ this.completedDebouncePending = {
2612
+ chatTitle,
2613
+ duration: Math.round(shortDurationMs / 1000),
2614
+ timestamp: now,
2615
+ firstObservedAt: now,
2616
+ // Short-gen enters from generating→idle (or waiting_approval→idle); the
2617
+ // completedDebounce finalization gate treats previousStatus for its
2618
+ // approval-resolution / inter-approval-valley handling. lastStatus is the
2619
+ // status we transitioned FROM here.
2620
+ previousStatus: this.lastStatus,
2621
+ ...(shortTaskId ? { taskId: shortTaskId } : {}),
2622
+ ...(shortTurnStartedAt ? { turnStartedAt: shortTurnStartedAt } : {}),
2623
+ // FALSE-IDLE continuity: same arm-time snapshots as the normal branch so the
2624
+ // flush guard can prove continuous idle across the settle window.
2625
+ busyEpochAtArm: this.busyEpoch,
2626
+ ...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
2627
+ ? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
2628
+ : {}),
2629
+ };
2630
+ LOG.info('CLI', `[${this.type}] short-generating routed through settle window (${shortDurationMs}ms, source=${shortEvidenceSource}, missingEvidence=${missingEvidence}) — arming completedDebouncePending instead of inline fire`);
2631
+ // EVTTRACE: now traces the settle ARM (not an inline fire) for mesh sessions,
2632
+ // so logs show the short-gen path deferring to continuity re-check.
2586
2633
  if (this.isMeshWorkerSession()) {
2587
- traceMeshEventStage('fired', this.meshTraceCtx(), `short-generating idle (source=${shortEvidenceSource})`);
2634
+ traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
2588
2635
  }
2636
+ this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
2637
+ } else if (missingEvidence) {
2638
+ // NON-MESH, missing evidence: suppress the completion event entirely (the
2639
+ // original !hasMeshContext suppression). A genuinely non-mesh session has no
2640
+ // coordinator to notify, and firing a completion with no confirmed final
2641
+ // assistant would just surface an empty/unconfirmed turn. Leave
2642
+ // completedDebouncePending null — the session is now idle with no confirmed
2643
+ // turn, matching the startup-blip suppression semantics. (No EvtTrace: not a
2644
+ // mesh session, so nothing routes to a coordinator.)
2645
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, non-mesh session (source=${shortEvidenceSource})`);
2646
+ } else {
2647
+ // NON-MESH interactive fast-path with a CONFIRMED summary: keep the existing
2648
+ // inline fire. The dashboard UX reason for the short path (a fast turn should
2649
+ // surface its completion promptly without a 4s settle) still holds, and a
2650
+ // non-mesh session has no coordinator to be falsely notified — the false-idle
2651
+ // bug being fixed is specifically the mesh worker/coordinator misfire above.
2589
2652
  this.pushEvent({
2590
2653
  event: 'agent:generating_completed',
2591
2654
  chatTitle,
@@ -2596,7 +2659,6 @@ export class CliProviderInstance implements ProviderInstance {
2596
2659
  reason: 'short_generating_suppressed',
2597
2660
  shortDurationMs,
2598
2661
  finalAssistantEvidenceSource: shortEvidenceSource,
2599
- ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
2600
2662
  },
2601
2663
  });
2602
2664
  }
@@ -556,54 +556,112 @@ interface AgyDbStepRow {
556
556
  * Parse a per-session conversations/<uuid>.db (SQLite) into NativeHistoryMessages.
557
557
  * Returns null when the db is unreadable, empty, or yields no chat messages.
558
558
  */
559
+ /**
560
+ * True when an error thrown by better-sqlite3 open/read is a transient
561
+ * SQLITE_BUSY / "database is locked" condition rather than a permanent one.
562
+ *
563
+ * On win32 antigravity holds a mandatory WAL write/checkpoint lock while it
564
+ * persists a step; a readonly open racing that lock throws SQLITE_BUSY. That
565
+ * is transient — the answer IS already on disk — so it must be retried, NOT
566
+ * collapsed to "no session" (which erases the just-written assistant answer on
567
+ * a chat_history re-query). macOS advisory locking + WAL reader-doesn't-block-
568
+ * writer masks this, which is why it is win32-specific.
569
+ */
570
+ function isSqliteBusyError(err: unknown): boolean {
571
+ if (!err) return false;
572
+ const code = (err as any).code;
573
+ if (typeof code === 'string' && code.includes('SQLITE_BUSY')) return true;
574
+ const msg = err instanceof Error ? err.message : String(err);
575
+ return /SQLITE_BUSY|database is locked|database table is locked/i.test(msg);
576
+ }
577
+
578
+ const AGY_DB_BUSY_TIMEOUT_MS = 3000;
579
+ const AGY_DB_MAX_ATTEMPTS = 4;
580
+ const AGY_DB_RETRY_BACKOFF_MS = [50, 100, 150];
581
+
582
+ function sleepBusy(ms: number): void {
583
+ // Synchronous busy-wait: parseConversationDb is a sync function called from a
584
+ // sync read path, and better-sqlite3 itself is synchronous. The waits are
585
+ // tiny (≤150ms) and only occur under genuine lock contention, so a short
586
+ // spin-sleep is acceptable and keeps the call site synchronous.
587
+ const end = Date.now() + ms;
588
+ while (Date.now() < end) { /* spin */ }
589
+ }
590
+
559
591
  function parseConversationDb(
560
592
  filePath: string,
561
593
  sessionId: string,
562
594
  workspace?: string,
563
595
  ): NativeHistoryMessage[] | null {
564
- let db: any;
596
+ let Database: any;
565
597
  try {
566
- const Database = loadBetterSqlite3();
567
- db = new Database(filePath, { readonly: true, fileMustExist: true });
598
+ Database = loadBetterSqlite3();
568
599
  } catch (err) {
569
- // better-sqlite3 unavailable (ABI mismatch / not installed in this bundle)
570
- // or the db handle failed to open. This is the silent-degrade that made a
571
- // live read_chat return 0 assistant messages with no trace — the answers
572
- // are on disk but unreadable. Log it (once-ish, at WARN) so the failure is
573
- // greppable in daemon logs and distinguishable from "no db file". The
574
- // reader still degrades gracefully (returns null → dispatcher falls back to
575
- // brain/.pb), but the operator now knows WHY the .db path produced nothing.
600
+ // better-sqlite3 binding genuinely unavailable (ABI mismatch / not built
601
+ // into this bundle). This is the only true "cannot read at all" case — a
602
+ // real load failure, distinct from transient lock contention below. Warn
603
+ // once at WARN so it is greppable; the reader degrades gracefully (returns
604
+ // null dispatcher falls back to brain/.pb).
576
605
  LOG.warn(
577
606
  'NativeHistory',
578
- `antigravity .db reader could not open ${path.basename(filePath)}: ${err instanceof Error ? err.message : String(err)} (better-sqlite3 load/open failed — assistant answers in this .db will not surface)`,
607
+ `antigravity .db reader could not load better-sqlite3 for ${path.basename(filePath)}: ${err instanceof Error ? err.message : String(err)} (native binding unavailable — assistant answers in this .db will not surface)`,
579
608
  );
580
609
  return null;
581
610
  }
582
611
 
583
- let rows: AgyDbStepRow[];
584
- try {
585
- rows = db
586
- .prepare(
587
- `SELECT idx, step_type, step_payload
588
- FROM steps
589
- WHERE step_type IN (${AGY_STEP_TYPE_USER}, ${AGY_STEP_TYPE_MODEL})
590
- ORDER BY idx ASC`,
591
- )
592
- .all() as AgyDbStepRow[];
593
- } catch (err) {
594
- // `steps` table absent / unexpected schema — a real (but recoverable)
595
- // shape mismatch, not the binding-missing case above. Log at debug so a
596
- // schema drift in a future antigravity release is diagnosable without
597
- // spamming logs for every legacy db.
598
- LOG.debug(
599
- 'NativeHistory',
600
- `antigravity .db ${path.basename(filePath)} has no readable steps table: ${err instanceof Error ? err.message : String(err)}`,
601
- );
602
- return null;
603
- } finally {
604
- try { db.close(); } catch { /* ignore */ }
612
+ let rows: AgyDbStepRow[] | null = null;
613
+ let lastBusyErr: unknown;
614
+
615
+ for (let attempt = 1; attempt <= AGY_DB_MAX_ATTEMPTS; attempt++) {
616
+ let db: any;
617
+ try {
618
+ db = new Database(filePath, { readonly: true, fileMustExist: true });
619
+ // Ask SQLite itself to wait (rather than failing fast) if the WAL
620
+ // lock is momentarily held by antigravity. Set as early as possible
621
+ // after open so the prepare/all below inherits the wait.
622
+ try { db.pragma(`busy_timeout = ${AGY_DB_BUSY_TIMEOUT_MS}`); } catch { /* ignore */ }
623
+ rows = db
624
+ .prepare(
625
+ `SELECT idx, step_type, step_payload
626
+ FROM steps
627
+ WHERE step_type IN (${AGY_STEP_TYPE_USER}, ${AGY_STEP_TYPE_MODEL})
628
+ ORDER BY idx ASC`,
629
+ )
630
+ .all() as AgyDbStepRow[];
631
+ break; // success
632
+ } catch (err) {
633
+ if (isSqliteBusyError(err)) {
634
+ // Transient WAL lock contention. Do NOT collapse to null on the first
635
+ // failure — the assistant answer is already persisted; treating a busy
636
+ // lock as "no session" is exactly what erased answers on re-query.
637
+ // Retry with a small backoff; only give up after attempts exhausted.
638
+ lastBusyErr = err;
639
+ if (attempt < AGY_DB_MAX_ATTEMPTS) {
640
+ sleepBusy(AGY_DB_RETRY_BACKOFF_MS[attempt - 1] ?? 150);
641
+ continue;
642
+ }
643
+ LOG.warn(
644
+ 'NativeHistory',
645
+ `antigravity .db ${path.basename(filePath)} stayed locked (SQLITE_BUSY) after ${AGY_DB_MAX_ATTEMPTS} attempts: ${err instanceof Error ? err.message : String(err)} (WAL write/checkpoint lock contention — assistant answers may transiently not surface this read)`,
646
+ );
647
+ return null;
648
+ }
649
+ // `steps` table absent / unexpected schema, or a genuine open/parse
650
+ // failure that is not lock contention — a real (but recoverable) shape
651
+ // mismatch. Log at debug so a schema drift in a future antigravity
652
+ // release is diagnosable without spamming logs for every legacy db.
653
+ LOG.debug(
654
+ 'NativeHistory',
655
+ `antigravity .db ${path.basename(filePath)} not readable: ${err instanceof Error ? err.message : String(err)}`,
656
+ );
657
+ return null;
658
+ } finally {
659
+ try { db?.close(); } catch { /* ignore */ }
660
+ }
605
661
  }
606
662
 
663
+ void lastBusyErr; // referenced only for retry bookkeeping above
664
+
607
665
  if (!Array.isArray(rows) || rows.length === 0) return null;
608
666
 
609
667
  const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';