@adhdev/daemon-core 0.9.82-rc.423 → 0.9.82-rc.425

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.
@@ -58,7 +58,7 @@ export interface MeshWorkerResultArtifact {
58
58
  errors: string[];
59
59
  nextAction?: string;
60
60
  requiresUserAction: boolean;
61
- source: 'explicit_metadata' | 'final_summary_json' | 'default';
61
+ source: 'explicit_metadata' | 'final_summary_json' | 'parseable_answer' | 'default';
62
62
  }
63
63
  export interface MeshTaskCompletionEvidence {
64
64
  source: 'agent_status_event';
@@ -16,6 +16,19 @@ type PersistableCliHistoryMessage = {
16
16
  senderName?: string;
17
17
  receivedAt?: number;
18
18
  };
19
+ /**
20
+ * NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
21
+ * start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
22
+ * re-armed on the next →generating, so a long turn that blips would otherwise measure only the
23
+ * final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
24
+ * mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
25
+ * for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
26
+ * Pure / unit-testable.
27
+ */
28
+ export declare function computeTurnAnchoredDurationMs(engineTurnStartedAt: number | undefined, generatingStartedAt: number, now: number): {
29
+ durationMs: number;
30
+ anchor: 'turn-start' | 'generatingStartedAt' | 'none';
31
+ };
19
32
  export declare function buildCliStructuredInputPrompt(input: InputEnvelope, options?: {
20
33
  materializeDir?: string;
21
34
  }): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.423",
3
+ "version": "0.9.82-rc.425",
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.423",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.425",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1174,6 +1174,11 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1174
1174
  : undefined,
1175
1175
  evidence: completionEvidence,
1176
1176
  // B2: evidenceLevel lets coordinator know when completion evidence is insufficient.
1177
+ // NOTIF Defect-2b: ONLY source==='default' (no parseable answer at all) is
1178
+ // 'insufficient'. 'parseable_answer' (a real JSON answer that just isn't
1179
+ // worker-result-shaped, e.g. a MAGI envelope) is concrete evidence and must
1180
+ // resolve to 'sufficient' — resolveWorkerResult now upgrades that case so a
1181
+ // complete, valid answer is no longer mislabelled insufficient/reviewRecommended.
1177
1182
  ...(completionEvidence
1178
1183
  ? completionEvidence.workerResult.source === 'default'
1179
1184
  ? { evidenceLevel: 'insufficient', reviewRecommended: true }
@@ -126,7 +126,14 @@ export interface MeshWorkerResultArtifact {
126
126
  errors: string[];
127
127
  nextAction?: string;
128
128
  requiresUserAction: boolean;
129
- source: 'explicit_metadata' | 'final_summary_json' | 'default';
129
+ // NOTIF Defect-2b: `parseable_answer` = the final summary held a parseable JSON
130
+ // ANSWER (e.g. a MAGI claim_audit / rca envelope) that is NOT worker-result-shaped
131
+ // (no status + changedFiles/errors/…). It is still concrete evidence that the worker
132
+ // produced a real, parseable answer — so it must NOT be labelled evidenceLevel
133
+ // 'insufficient' — but it is NOT a self-attributing worker result, so it is deliberately
134
+ // distinct from 'final_summary_json' and stays subject to the direct-dispatch grace gate
135
+ // in mesh-events-stale.ts (which keys on `!== 'final_summary_json'`).
136
+ source: 'explicit_metadata' | 'final_summary_json' | 'parseable_answer' | 'default';
130
137
  }
131
138
 
132
139
  export interface MeshTaskCompletionEvidence {
@@ -508,6 +515,35 @@ export function normalizeMeshWorkerResult(input?: Record<string, unknown>, sourc
508
515
  };
509
516
  }
510
517
 
518
+ /**
519
+ * NOTIF Defect-2b: does the summary contain ANY parseable JSON object answer (not just a
520
+ * worker-result-shaped one)? Some providers (and every MAGI replica) emit a complete, valid
521
+ * answer as a JSON envelope that has no `status`/`changedFiles` worker-result fields, so
522
+ * extractJsonObjectFromSummary returns undefined and the completion is mislabelled
523
+ * source='default' → evidenceLevel='insufficient' even though a real answer was produced.
524
+ * This is a conservative existence check: it only returns true when a JSON object actually
525
+ * parses out of the summary (raw or fenced), so an empty / prose-only / unparseable summary
526
+ * still resolves to 'default'.
527
+ */
528
+ function summaryHasParseableJsonAnswer(summary?: string): boolean {
529
+ const text = readNonEmptyString(summary);
530
+ if (!text) return false;
531
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
532
+ const candidates = [fenced?.[1], text].filter(Boolean) as string[];
533
+ for (const candidate of candidates) {
534
+ const trimmed = candidate.trim();
535
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue;
536
+ try {
537
+ const parsed = JSON.parse(trimmed);
538
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
539
+ && Object.keys(parsed).length > 0) {
540
+ return true;
541
+ }
542
+ } catch { /* try next candidate */ }
543
+ }
544
+ return false;
545
+ }
546
+
511
547
  function resolveWorkerResult(opts: BuildTaskCompletionEvidenceOptions): MeshWorkerResultArtifact {
512
548
  if (opts.workerResult && typeof opts.workerResult === 'object') {
513
549
  return normalizeMeshWorkerResult(opts.workerResult, 'explicit_metadata');
@@ -516,6 +552,13 @@ function resolveWorkerResult(opts: BuildTaskCompletionEvidenceOptions): MeshWork
516
552
  if (parsed) {
517
553
  return normalizeMeshWorkerResult(parsed, 'final_summary_json');
518
554
  }
555
+ // NOTIF Defect-2b: no worker-result-shaped JSON, but a parseable JSON answer IS present
556
+ // (the common MAGI / answer-only case). Treat it as concrete evidence so the completion is
557
+ // not labelled 'insufficient', while keeping the worker-result fields empty (status stays
558
+ // 'unknown') — we only know an answer parsed, not its task outcome.
559
+ if (summaryHasParseableJsonAnswer(opts.finalSummary)) {
560
+ return normalizeMeshWorkerResult(undefined, 'parseable_answer');
561
+ }
519
562
  return normalizeMeshWorkerResult(undefined, 'default');
520
563
  }
521
564
 
@@ -44,7 +44,7 @@ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
44
44
  import type { LocalMeshEntry } from '../repo-mesh-types.js';
45
45
  import { loadConfig } from '../config/config.js';
46
46
  import { listMeshes } from '../config/mesh-config.js';
47
- import { LOG } from '../logging/logger.js';
47
+ import { LOG, getLogLevel } from '../logging/logger.js';
48
48
  import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, buildPendingEventFingerprint, queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
49
49
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
50
50
  import { appendLedgerEntry } from './mesh-ledger.js';
@@ -319,6 +319,38 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
319
319
  ? (inst as any).isModalParked() === true
320
320
  : (status === 'waiting_choice' || status === 'waiting_approval');
321
321
  const sessionId = readNonEmptyString(state.instanceId);
322
+ // ── NOTIF (B) desync diagnostic (read-only, no behavior change) ───────────
323
+ // The confirmed (B) defect: a coordinator whose FSM is idle (status above ===
324
+ // 'idle' for minutes) is nonetheless classified busy here, so the generating/
325
+ // modal-park hold never drains and a worker completion is stranded until the
326
+ // user's next turn edge. Static analysis found no code path where getState()
327
+ // returns generating while lastStatus and the adapter raw are both idle — so the
328
+ // divergence is a runtime desync between the three status sources. Capture all
329
+ // three (plus the auto-approve mask state that getState() overlays at :803) for
330
+ // EVERY mesh-coordinator candidate on this tick, so the source that diverges from
331
+ // the others can be read directly against the same-tick "skip → generating"/
332
+ // "skip → modal-parked" hold logs below (pair by sessionId + timestamp).
333
+ //
334
+ // CRITICAL: reuse the `state` already fetched above (line ~301) — do NOT call
335
+ // getState() again. getState() runs maybeAutoApproveStatus() as a side effect,
336
+ // which would mutate the very auto-approve mask we are trying to observe. The
337
+ // adapter raw read uses allowParse:false, which only reads engine.activeModal and
338
+ // is side-effect-free.
339
+ if (getLogLevel() === 'debug') {
340
+ let adapterRaw = '?';
341
+ try {
342
+ const a = (inst as any).adapter;
343
+ if (a && typeof a.getStatus === 'function') {
344
+ adapterRaw = readNonEmptyString(a.getStatus({ allowParse: false })?.status) || '?';
345
+ }
346
+ } catch (e: any) {
347
+ adapterRaw = `err:${e?.message || e}`;
348
+ }
349
+ const lastStatus = readNonEmptyString((inst as any).lastStatus) || '?';
350
+ const autoApproveBusy = (inst as any).autoApproveBusy;
351
+ const maskSince = (inst as any).autoApproveMaskSince;
352
+ LOG.debug('MeshReconcile', `coordDiag sess=${sessionId || '?'} mesh=${meshId} getState=${status || '?'} lastStatus=${lastStatus} adapterRaw=${adapterRaw} autoApproveBusy=${autoApproveBusy === true} maskSince=${maskSince || 0}`);
353
+ }
322
354
  // Modal-park transition observability: a coordinator entering modal-park is what
323
355
  // begins holding completion events under `modal_parked`; one leaving it is what
324
356
  // drains them. Both transitions were previously SILENT (the operator had no log
@@ -902,6 +934,12 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
902
934
  }
903
935
  }
904
936
  LOG.info('MeshReconcile', `Reconcile skip → modal-parked: holding pending event(s) for mesh ${meshId} (${modalParkedCoordinators.length} coordinator(s) awaiting a modal answer; events left queued${orphanEscaped > 0 ? `; ${orphanEscaped} orphan-targeted event(s) routed to strict-route TTL` : ''})`);
937
+ // NOTIF (B) diagnostic: name the session(s) classified modal-parked so the
938
+ // same-tick coordDiag line (paired by sessionId) shows whether the modal-park
939
+ // overlay is a real human-await or an unreleased mask (the getState_overlay origin).
940
+ if (getLogLevel() === 'debug') {
941
+ LOG.debug('MeshReconcile', `coordHoldModalParked mesh=${meshId} heldFor=[${modalParkedCoordinators.map(c => c.sessionId || '?').join(',')}] (these were classified modal-parked; cross-ref same-tick coordDiag by sessionId)`);
942
+ }
905
943
  // C1: mirror held terminal events into the ledger so a held completion's
906
944
  // worker summary is auditable/recoverable even if the modal is never
907
945
  // resolved, the coordinator restarts, or the pending file is later trimmed.
@@ -937,6 +975,14 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
937
975
  }
938
976
  if (hasPending) {
939
977
  LOG.info('MeshReconcile', `Reconcile skip → generating: holding pending event(s) for mesh ${meshId} (${generatingCoordinators.length} coordinator(s) busy; events left queued for the next idle tick)`);
978
+ // NOTIF (B) diagnostic: this is the hold that strands the completion. Name
979
+ // the sessionId(s) the loop just classified non-idle/non-modal so the
980
+ // same-tick coordDiag line above (paired by sessionId) reveals which status
981
+ // source diverged. If a coordDiag for one of these sessions shows getState
982
+ // (or lastStatus/adapterRaw) === idle, that is the runtime desync origin.
983
+ if (getLogLevel() === 'debug') {
984
+ LOG.debug('MeshReconcile', `coordHoldGenerating mesh=${meshId} heldFor=[${generatingCoordinators.map(c => c.sessionId || '?').join(',')}] (these were classified busy; cross-ref same-tick coordDiag by sessionId)`);
985
+ }
940
986
  recordHeldTerminalEventsToLedger(
941
987
  meshId,
942
988
  drainDaemonIds.length > 0 ? drainDaemonIds : (localDaemonId ? [localDaemonId] : []),
@@ -249,6 +249,28 @@ function isCliGeneratingLikeStatus(status: unknown): boolean {
249
249
  return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
250
250
  }
251
251
 
252
+ /**
253
+ * NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
254
+ * start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
255
+ * re-armed on the next →generating, so a long turn that blips would otherwise measure only the
256
+ * final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
257
+ * mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
258
+ * for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
259
+ * Pure / unit-testable.
260
+ */
261
+ export function computeTurnAnchoredDurationMs(
262
+ engineTurnStartedAt: number | undefined,
263
+ generatingStartedAt: number,
264
+ now: number,
265
+ ): { durationMs: number; anchor: 'turn-start' | 'generatingStartedAt' | 'none' } {
266
+ const engineStart = typeof engineTurnStartedAt === 'number' && Number.isFinite(engineTurnStartedAt)
267
+ ? engineTurnStartedAt
268
+ : 0;
269
+ if (engineStart > 0) return { durationMs: now - engineStart, anchor: 'turn-start' };
270
+ if (generatingStartedAt > 0) return { durationMs: now - generatingStartedAt, anchor: 'generatingStartedAt' };
271
+ return { durationMs: 0, anchor: 'none' };
272
+ }
273
+
252
274
  export function buildCliStructuredInputPrompt(
253
275
  input: InputEnvelope,
254
276
  options: { materializeDir?: string } = {},
@@ -2375,8 +2397,21 @@ export class CliProviderInstance implements ProviderInstance {
2375
2397
  // Still emit agent:generating_completed so mesh orchestration can record
2376
2398
  // task_completed for direct dispatches that complete faster than the debounce.
2377
2399
  if (this.generatingDebouncePending) {
2378
- const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
2379
- LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
2400
+ // NOTIF Defect-2a: shortDurationMs is the REPORTED turn duration, so it must be
2401
+ // measured from the IMMUTABLE turn start — not generatingStartedAt, which is
2402
+ // reset to 0 on every mid-turn waiting_approval/idle blip (see :1864/1885/2397/
2403
+ // 2503/2521) and re-armed on the next →generating, so a long turn that blipped
2404
+ // would measure only the final 1.5-2.5s sliver. engine.currentTurnStartedAt is
2405
+ // stamped once at onTurnStarted and persists past mid-turn blips until the next
2406
+ // turn starts, so it captures the true turn length. generatingStartedAt remains
2407
+ // the fallback (and the debounce itself stays a pure UI-suppression signal,
2408
+ // decoupled from the reported duration).
2409
+ const { durationMs: shortDurationMs, anchor: durationAnchor } = computeTurnAnchoredDurationMs(
2410
+ (this.adapter as any)?.currentTurnStartedAt,
2411
+ this.generatingStartedAt,
2412
+ now,
2413
+ );
2414
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms, anchor=${durationAnchor})`);
2380
2415
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2381
2416
  // Emit completion for mesh task association even though the UI generating
2382
2417
  // started/completed pair is suppressed (too short for visible UI update).