@adhdev/daemon-core 0.9.82-rc.327 → 0.9.82-rc.329

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.
Files changed (38) hide show
  1. package/dist/cli-adapters/provider-cli-shared.d.ts +1 -1
  2. package/dist/git/change-impact-config.d.ts +159 -0
  3. package/dist/git/git-status.d.ts +14 -0
  4. package/dist/git/index.d.ts +2 -0
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +1140 -607
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1090 -564
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/mesh-active-work.d.ts +66 -0
  11. package/dist/mesh/mesh-events-stale.d.ts +1 -1
  12. package/dist/providers/status-monitor.d.ts +7 -7
  13. package/dist/shared-types.d.ts +1 -1
  14. package/package.json +2 -2
  15. package/src/agent-stream/provider-adapter.ts +1 -1
  16. package/src/cli-adapters/provider-cli-adapter.ts +2 -2
  17. package/src/cli-adapters/provider-cli-shared.ts +1 -1
  18. package/src/commands/chat-commands.ts +1 -1
  19. package/src/commands/cli-manager.ts +1 -1
  20. package/src/commands/router.ts +46 -0
  21. package/src/git/change-impact-config.ts +354 -0
  22. package/src/git/git-status.ts +182 -16
  23. package/src/git/index.ts +16 -0
  24. package/src/index.ts +2 -2
  25. package/src/mesh/mesh-active-work.ts +154 -0
  26. package/src/mesh/mesh-events-coordinator.ts +13 -12
  27. package/src/mesh/mesh-events-stale.ts +4 -4
  28. package/src/mesh/mesh-events-utils.ts +3 -3
  29. package/src/mesh/mesh-reconcile-loop.ts +144 -1
  30. package/src/providers/acp-provider-instance.ts +6 -5
  31. package/src/providers/cli-provider-instance.ts +14 -10
  32. package/src/providers/extension-provider-instance.ts +7 -6
  33. package/src/providers/ide-provider-instance.ts +10 -6
  34. package/src/providers/read-chat-contract.ts +1 -1
  35. package/src/providers/status-monitor.d.ts +7 -7
  36. package/src/providers/status-monitor.ts +37 -22
  37. package/src/shared-types.ts +3 -0
  38. package/src/status/reporter.ts +1 -1
@@ -1,6 +1,13 @@
1
1
  import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
2
  import { GIT_STATUS_TIMEOUT_MS, GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
3
  import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
4
+ import {
5
+ type ChangeImpactConfig,
6
+ type ChangeImpactKind,
7
+ type ChangeImpactTarget,
8
+ globToRegExp,
9
+ loadChangeImpactConfig,
10
+ } from './change-impact-config.js';
4
11
 
5
12
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
6
13
 
@@ -12,9 +19,42 @@ type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo:
12
19
  */
13
20
  const lastKnownGoodStatus = new Map<string, GitRepoStatus>();
14
21
 
22
+ /**
23
+ * Memoized Change Impact evaluation, keyed by the inputs that can change the
24
+ * verdict: the scope repo path, the buildCommit..HEAD pair, and the resolved
25
+ * config source. The daemonBuildBehind probe runs on every mesh_status /
26
+ * mesh_git_status / fast_forward / git-monitor hit; without this, each hit
27
+ * re-shells `git diff` for the identical commit range. The cache stays correct
28
+ * because HEAD or a config edit perturbs the key, forcing re-evaluation.
29
+ */
30
+ interface ChangeImpactEvalEntry {
31
+ isDaemonAffecting: boolean;
32
+ affectedPackages: string[];
33
+ }
34
+ const changeImpactEvalCache = new Map<string, ChangeImpactEvalEntry>();
35
+
36
+ /**
37
+ * Best-effort cache of the loaded Change Impact config per repo root, keyed by the
38
+ * config sourceKey (path+mtime). A new sourceKey (config edited/added/removed)
39
+ * supersedes the entry. Avoids re-reading + re-parsing the config file on every
40
+ * status probe while still honoring on-disk edits.
41
+ */
42
+ interface ChangeImpactConfigCacheEntry {
43
+ sourceKey: string;
44
+ config: ChangeImpactConfig | null;
45
+ }
46
+ const changeImpactConfigCache = new Map<string, ChangeImpactConfigCacheEntry>();
47
+
15
48
  /** Test seam: clear the last-known-good status cache between cases. */
16
49
  export function __resetGitStatusCacheForTests(): void {
17
50
  lastKnownGoodStatus.clear();
51
+ changeImpactEvalCache.clear();
52
+ changeImpactConfigCache.clear();
53
+ }
54
+
55
+ /** Test-only introspection: number of memoized Change Impact evaluations. */
56
+ export function __changeImpactEvalCacheSizeForTests(): number {
57
+ return changeImpactEvalCache.size;
18
58
  }
19
59
 
20
60
  /**
@@ -43,6 +83,17 @@ export interface GitStatusOptions {
43
83
  * (getDaemonBuildInfo) is used.
44
84
  */
45
85
  daemonBuildInfo?: DaemonBuildInfo;
86
+ /**
87
+ * Change Impact policy override. When provided, the daemonBuildBehind
88
+ * classifier uses this declarative config instead of auto-loading the repo's
89
+ * `.adhdev/change-impact.*` file. When omitted, getGitRepoStatus auto-loads the
90
+ * repo config (cached); when neither is present the built-in ADHDev default
91
+ * policy applies, preserving the legacy behavior exactly.
92
+ *
93
+ * Pass `null` to force the built-in default policy and skip auto-loading (used
94
+ * to assert legacy parity in tests).
95
+ */
96
+ changeImpactConfig?: ChangeImpactConfig | null;
46
97
  }
47
98
 
48
99
  interface GitUpstreamProbe {
@@ -177,7 +228,7 @@ async function collectGitRepoStatus(
177
228
  * same process surface as the daemon tooling, so it is classified as
178
229
  * daemon-affecting (conservative). Unknown package → daemon-affecting.
179
230
  */
180
- const DAEMON_RUNTIME_PACKAGES = new Set([
231
+ const DEFAULT_DAEMON_RUNTIME_PACKAGES = [
181
232
  'daemon-core',
182
233
  'daemon-standalone',
183
234
  'session-host-core',
@@ -187,14 +238,66 @@ const DAEMON_RUNTIME_PACKAGES = new Set([
187
238
  'terminal-mux-cli',
188
239
  'ghostty-vt-node',
189
240
  'mcp-server',
190
- ]);
241
+ ];
191
242
 
192
- const WEB_ONLY_PACKAGES = new Set([
243
+ const DEFAULT_WEB_ONLY_PACKAGES = [
193
244
  'web-core',
194
245
  'web-standalone',
195
246
  'web-devconsole',
196
247
  'terminal-render-web',
197
- ]);
248
+ ];
249
+
250
+ /**
251
+ * Built-in recommended action/command per impact classification. A change-impact
252
+ * config may override any of these via `impactTargets`; missing keys fall back here.
253
+ */
254
+ const DEFAULT_IMPACT_TARGETS: Record<ChangeImpactKind, ChangeImpactTarget> = {
255
+ daemon: {
256
+ recommendedCommand: 'Redeploy + restart the daemon (a local dist rebuild alone does not update a cloud daemon).',
257
+ },
258
+ web: {
259
+ recommendedCommand: 'Redeploy the web app (no daemon restart required).',
260
+ },
261
+ none: {
262
+ recommendedCommand: 'No action required.',
263
+ },
264
+ };
265
+
266
+ /**
267
+ * The resolved Change Impact policy: the built-in ADHDev defaults merged with any
268
+ * config override. This is what the classifier consults — git-status only knows the
269
+ * facts (changed files/packages), policy is data.
270
+ */
271
+ interface ResolvedChangeImpactPolicy {
272
+ daemonRuntimePackages: Set<string>;
273
+ webOnlyPackages: Set<string>;
274
+ /** Compiled config globs for additional non-runtime root files (on top of built-ins). */
275
+ nonRuntimeRootFilePatterns: RegExp[];
276
+ impactTargets: Record<ChangeImpactKind, ChangeImpactTarget>;
277
+ }
278
+
279
+ function resolveChangeImpactPolicy(config: ChangeImpactConfig | null | undefined): ResolvedChangeImpactPolicy {
280
+ // A field provided in config REPLACES the built-in default for that field; an
281
+ // omitted field falls back to the ADHDev default, so a repo with no config (or a
282
+ // partial config) behaves exactly as before.
283
+ const daemonRuntimePackages = new Set(
284
+ config?.daemonRuntimePackages && config.daemonRuntimePackages.length
285
+ ? config.daemonRuntimePackages
286
+ : DEFAULT_DAEMON_RUNTIME_PACKAGES,
287
+ );
288
+ const webOnlyPackages = new Set(
289
+ config?.webOnlyPackages && config.webOnlyPackages.length
290
+ ? config.webOnlyPackages
291
+ : DEFAULT_WEB_ONLY_PACKAGES,
292
+ );
293
+ const nonRuntimeRootFilePatterns = (config?.nonRuntimeRootFilePatterns || []).map(globToRegExp);
294
+ const impactTargets: Record<ChangeImpactKind, ChangeImpactTarget> = {
295
+ daemon: config?.impactTargets?.daemon ?? DEFAULT_IMPACT_TARGETS.daemon,
296
+ web: config?.impactTargets?.web ?? DEFAULT_IMPACT_TARGETS.web,
297
+ none: config?.impactTargets?.none ?? DEFAULT_IMPACT_TARGETS.none,
298
+ };
299
+ return { daemonRuntimePackages, webOnlyPackages, nonRuntimeRootFilePatterns, impactTargets };
300
+ }
198
301
 
199
302
  /**
200
303
  * Root-level (non-package) files that demonstrably cannot change what the daemon
@@ -210,8 +313,11 @@ const WEB_ONLY_PACKAGES = new Set([
210
313
  * real-world case (e.g. `.verify-patch-equiv-rc292`), so they are matched broadly;
211
314
  * docs are matched by the `docs/` prefix or a markdown/text-doc extension at a
212
315
  * filename we recognize as documentation (README/CHANGELOG/LICENSE/NOTICE).
316
+ *
317
+ * Config-supplied `nonRuntimeRootFilePatterns` are matched in ADDITION to these
318
+ * built-ins, so a repo can extend (never narrow) the benign set declaratively.
213
319
  */
214
- function isNonRuntimeRootFile(file: string): boolean {
320
+ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy): boolean {
215
321
  const base = file.slice(file.lastIndexOf('/') + 1);
216
322
  // Verify/convergence markers: dotfiles whose name signals a transient marker.
217
323
  if (/^\.(?:verify|marker|converge|ff-verify|patch-equiv|live-verify)\b/i.test(base)) return true;
@@ -221,19 +327,24 @@ function isNonRuntimeRootFile(file: string): boolean {
221
327
  if (/^(?:README|CHANGELOG|LICENSE|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS)(?:\.[A-Za-z0-9]+)?$/i.test(base)) {
222
328
  return true;
223
329
  }
330
+ // Config-declared additional non-runtime root globs.
331
+ for (const re of policy.nonRuntimeRootFilePatterns) {
332
+ if (re.test(file)) return true;
333
+ }
224
334
  return false;
225
335
  }
226
336
 
227
337
  /**
228
338
  * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
229
- * package. Returns isDaemonAffecting:true conservatively when the changed-file set
230
- * can't be obtained or any changed path is outside the known web-only package set
231
- * AND is not a recognized non-runtime root file (marker/doc/license).
339
+ * package, per the resolved policy. Returns isDaemonAffecting:true conservatively
340
+ * when the changed-file set can't be obtained or any changed path is outside the
341
+ * known web-only package set AND is not a recognized non-runtime root file.
232
342
  */
233
343
  async function classifyDaemonBuildChange(
234
344
  repoPath: string,
235
345
  buildCommit: string,
236
346
  options: GitStatusOptions,
347
+ policy: ResolvedChangeImpactPolicy,
237
348
  ): Promise<{ isDaemonAffecting: boolean; affectedPackages: string[] }> {
238
349
  try {
239
350
  const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
@@ -254,7 +365,7 @@ async function classifyDaemonBuildChange(
254
365
  for (const file of files) {
255
366
  const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
256
367
  if (!match) {
257
- if (!isNonRuntimeRootFile(file)) sawRuntimeAmbiguousNonPackage = true;
368
+ if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
258
369
  continue;
259
370
  }
260
371
  pkgs.add(match[1]);
@@ -267,7 +378,7 @@ async function classifyDaemonBuildChange(
267
378
  // file changed) — i.e. nothing runtime-ambiguous remains.
268
379
  const allBenign =
269
380
  !sawRuntimeAmbiguousNonPackage &&
270
- affectedPackages.every((p) => WEB_ONLY_PACKAGES.has(p) && !DAEMON_RUNTIME_PACKAGES.has(p));
381
+ affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
271
382
  return { isDaemonAffecting: !allBenign, affectedPackages };
272
383
  } catch {
273
384
  // diff probe failed → can't prove web-only; stay conservative.
@@ -275,6 +386,46 @@ async function classifyDaemonBuildChange(
275
386
  }
276
387
  }
277
388
 
389
+ /**
390
+ * Resolve the Change Impact config to apply for this status read. Priority:
391
+ * - options.changeImpactConfig === null → force built-in default policy (no load).
392
+ * - options.changeImpactConfig provided → use it verbatim (override seam).
393
+ * - otherwise → auto-load the repo's `.adhdev/change-impact.*` (cached by sourceKey).
394
+ * Returns the (possibly null) config plus the sourceKey used for cache invalidation.
395
+ */
396
+ function resolveChangeImpactConfigForRepo(
397
+ repoRoot: string | null,
398
+ options: GitStatusOptions,
399
+ ): { config: ChangeImpactConfig | null; sourceKey: string } {
400
+ if (options.changeImpactConfig === null) {
401
+ return { config: null, sourceKey: 'forced-default' };
402
+ }
403
+ if (options.changeImpactConfig !== undefined) {
404
+ // Injected override — key it to its content so a different injected config
405
+ // re-evaluates rather than reusing a prior verdict.
406
+ let key = 'injected';
407
+ try {
408
+ key = `injected:${JSON.stringify(options.changeImpactConfig)}`;
409
+ } catch {
410
+ // Non-serializable override (shouldn't happen) — fall back to a constant key.
411
+ }
412
+ return { config: options.changeImpactConfig, sourceKey: key };
413
+ }
414
+ if (!repoRoot) {
415
+ return { config: null, sourceKey: 'no-repo-root' };
416
+ }
417
+ const loaded = loadChangeImpactConfig(repoRoot);
418
+ const cached = changeImpactConfigCache.get(repoRoot);
419
+ if (cached && cached.sourceKey === loaded.sourceKey) {
420
+ return { config: cached.config, sourceKey: loaded.sourceKey };
421
+ }
422
+ // An invalid config is conservatively ignored (built-in default policy applies),
423
+ // mirroring the "fail safe" rule — never let a malformed config silence warnings.
424
+ const config = loaded.sourceType === 'repo_file' ? loaded.config ?? null : null;
425
+ changeImpactConfigCache.set(repoRoot, { sourceKey: loaded.sourceKey, config });
426
+ return { config, sourceKey: loaded.sourceKey };
427
+ }
428
+
278
429
  async function detectDaemonBuildBehind(
279
430
  repo: ResolvedGitRepo,
280
431
  submodules: GitSubmoduleStatus[] | undefined,
@@ -283,6 +434,9 @@ async function detectDaemonBuildBehind(
283
434
  const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
284
435
  if (!build.commit || build.commit === 'unknown') return undefined;
285
436
 
437
+ const { config, sourceKey: configKey } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
438
+ const policy = resolveChangeImpactPolicy(config);
439
+
286
440
  // Check the root repo first, then each submodule. The daemon build commit is
287
441
  // baked from the daemon-core (oss submodule) HEAD, so on an adhdev
288
442
  // superproject worktree the match is expected on the `oss` submodule, not the
@@ -307,12 +461,22 @@ async function detectDaemonBuildBehind(
307
461
  // Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
308
462
  // is only actually required when a daemon-runtime package changed; if only web /
309
463
  // render packages changed, the daemon is unaffected and just the web deploy is
310
- // pending. Conservative: any probe failure → treat as daemon-affecting.
311
- const { isDaemonAffecting, affectedPackages } = await classifyDaemonBuildChange(
312
- repoPath,
313
- build.commit,
314
- options,
315
- );
464
+ // pending. Conservative: any probe failure → treat as daemon-affecting. The
465
+ // verdict is memoized on (repoPath, buildCommit, head, config) to suppress
466
+ // re-evaluation on the hot status path.
467
+ const evalKey = `${repoPath}${build.commit}${head}${configKey}`;
468
+ let evaluated = changeImpactEvalCache.get(evalKey);
469
+ if (!evaluated) {
470
+ evaluated = await classifyDaemonBuildChange(repoPath, build.commit, options, policy);
471
+ changeImpactEvalCache.set(evalKey, evaluated);
472
+ }
473
+ const { isDaemonAffecting, affectedPackages } = evaluated;
474
+ const kind: ChangeImpactKind = isDaemonAffecting
475
+ ? 'daemon'
476
+ : affectedPackages.length > 0
477
+ ? 'web'
478
+ : 'none';
479
+ const target = policy.impactTargets[kind];
316
480
  const scopeLabel = scope === 'root' ? 'workspace' : scope;
317
481
  const benignDetail = affectedPackages.length > 0
318
482
  ? `only web packages changed (${affectedPackages.join(', ')})`
@@ -330,6 +494,8 @@ async function detectDaemonBuildBehind(
330
494
  scope,
331
495
  isDaemonAffecting,
332
496
  ...(affectedPackages && affectedPackages.length > 0 ? { affectedPackages } : {}),
497
+ recommendedAction: kind,
498
+ recommendedCommand: target.recommendedCommand,
333
499
  warning,
334
500
  };
335
501
  } catch {
package/src/git/index.ts CHANGED
@@ -26,6 +26,22 @@ export type { GitCommandResult as GitExecutorCommandResult, GitExecutorOptions,
26
26
  export { getGitRepoStatus, parsePorcelainV2Status } from './git-status.js';
27
27
  export type { GitStatusOptions } from './git-status.js';
28
28
 
29
+ export {
30
+ CHANGE_IMPACT_CONFIG_LOCATIONS,
31
+ CHANGE_IMPACT_CONFIG_SCHEMA,
32
+ globToRegExp,
33
+ loadChangeImpactConfig,
34
+ suggestChangeImpactConfig,
35
+ validateChangeImpactConfig,
36
+ } from './change-impact-config.js';
37
+ export type {
38
+ ChangeImpactConfig,
39
+ ChangeImpactConfigLoadResult,
40
+ ChangeImpactConfigSuggestion,
41
+ ChangeImpactKind,
42
+ ChangeImpactTarget,
43
+ } from './change-impact-config.js';
44
+
29
45
  export { getGitDiffSummary, getGitFileDiff } from './git-diff.js';
30
46
  export type { GitDiffOptions, GitFileDiffResult } from './git-diff.js';
31
47
 
package/src/index.ts CHANGED
@@ -233,8 +233,8 @@ export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplic
233
233
  // ── Mesh Work Queue (GUPP) ──
234
234
  export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
235
235
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
236
- export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
237
- export type { StaleDirectPruneClassification } from './mesh/mesh-active-work.js';
236
+ export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
237
+ export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
238
238
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
239
239
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
240
240
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
@@ -1,5 +1,7 @@
1
1
  import type { MeshLedgerEntry } from './mesh-ledger.js';
2
+ import { appendLedgerEntry } from './mesh-ledger.js';
2
3
  import type { MeshWorkQueueEntry, DirectDispatchRecord } from './mesh-work-queue.js';
4
+ import { deleteDirectDispatchesByTaskId } from './mesh-work-queue.js';
3
5
  import { meshNodeIdMatches } from '@adhdev/mesh-shared';
4
6
 
5
7
  export type MeshActiveWorkSource = 'queue' | 'direct';
@@ -440,6 +442,158 @@ export function classifyStaleDirectForPrune(
440
442
  return 'preserve_active';
441
443
  }
442
444
 
445
+ /**
446
+ * Outcome of one staleDirect prune pass. Pure data — callers (the MCP tool, the
447
+ * daemon reconcile loop) format/log this however they need. The MCP tool wraps it in
448
+ * its JSON response; the reconcile loop logs prunedCount when > 0.
449
+ */
450
+ export interface StaleDirectPruneResult {
451
+ mode: 'execute' | 'dry_run';
452
+ includeTerminal: boolean;
453
+ /** Total staleDirect (+terminal when included) candidates surfaced this pass. */
454
+ candidateCount: number;
455
+ /** Records classified prunable AND (when minAgeMs > 0) old enough to auto-prune. */
456
+ prunable: MeshActiveWorkRecord[];
457
+ prunedCount: number;
458
+ /** Prunable by classification but younger than the age gate — only populated when minAgeMs > 0. */
459
+ skippedTooYoung: MeshActiveWorkRecord[];
460
+ preservedUnacknowledged: MeshActiveWorkRecord[];
461
+ /** Prunable orphans/terminals with no store-backed row to delete (ledger-only audit). */
462
+ preservedLedgerOnly: MeshActiveWorkRecord[];
463
+ preservedNotOrphan: MeshActiveWorkRecord[];
464
+ }
465
+
466
+ export interface PruneStaleDirectDispatchesOptions {
467
+ meshId: string;
468
+ /** Active direct dispatches from MeshRuntimeStore (getActiveDirectDispatches). */
469
+ directDispatches: DirectDispatchRecord[];
470
+ /** Ledger tail used to attribute remote/terminal dispatches (readLedgerEntries). */
471
+ ledgerEntries?: MeshLedgerEntry[];
472
+ queue?: MeshWorkQueueEntry[];
473
+ /** Live mesh nodes (decorated with live session details) — drives orphan detection. */
474
+ nodes?: any[];
475
+ /** When true, actually delete + append the audit ledger entry. Default false (dry run). */
476
+ execute?: boolean;
477
+ /** Include terminal (idle/failed) direct rows as prune candidates. Default false. */
478
+ includeTerminal?: boolean;
479
+ /**
480
+ * Minimum age (ms, measured from createdAt/dispatchedAt) before a prunable orphan is
481
+ * eligible. 0 (default) prunes immediately regardless of age — the manual prune behavior.
482
+ * The daemon auto-prune passes a conservative threshold so a node/session that is only
483
+ * transiently invisible is never pruned on the spot.
484
+ */
485
+ minAgeMs?: number;
486
+ /** Audit source string written into the direct_dispatch_pruned ledger payload. */
487
+ source?: string;
488
+ now?: number;
489
+ }
490
+
491
+ /**
492
+ * Shared staleDirect prune core. Single source of truth for the prune decision + the
493
+ * mutation (store-row delete + audit-ledger append) used by BOTH the manual MCP tool
494
+ * (mesh_prune_stale_direct, minAgeMs=0) and the daemon reconcile loop's auto-prune
495
+ * PHASE (minAgeMs > 0). Pure decision logic via buildMeshActiveWork + classifyStaleDirectForPrune;
496
+ * the only side effects (on execute) are deleteDirectDispatchesByTaskId and a single
497
+ * direct_dispatch_pruned ledger entry — never touching the append-only audit history of the
498
+ * pruned dispatches themselves.
499
+ *
500
+ * Safety rules (identical for manual + auto):
501
+ * - Only records classified as staleDirectWork against the CURRENT live mesh are eligible.
502
+ * - Of those, only orphans (node/session gone) — and terminals when includeTerminal — are prunable.
503
+ * Fresh unacknowledged dispatch failures (node/session still live) are always preserved.
504
+ * - Only store-backed rows (taskId present in MeshRuntimeStore) are deleted; ledger-only remote
505
+ * entries are preserved.
506
+ * - When minAgeMs > 0, a prunable orphan younger than the gate is held back (skippedTooYoung).
507
+ * This applies ONLY to the auto path; the manual path passes minAgeMs=0 (immediate).
508
+ *
509
+ * Idempotent: a deleted row no longer appears in getActiveDirectDispatches, so a second pass
510
+ * over the same orphan finds nothing to prune.
511
+ */
512
+ export function pruneStaleDirectDispatches(opts: PruneStaleDirectDispatchesOptions): StaleDirectPruneResult {
513
+ const now = opts.now ?? Date.now();
514
+ const includeTerminal = opts.includeTerminal === true;
515
+ const execute = opts.execute === true;
516
+ const minAgeMs = Math.max(0, opts.minAgeMs ?? 0);
517
+
518
+ const activeWorkEvidence = buildMeshActiveWork({
519
+ meshId: opts.meshId,
520
+ queue: opts.queue,
521
+ ledgerEntries: opts.ledgerEntries,
522
+ directDispatches: opts.directDispatches,
523
+ nodes: opts.nodes,
524
+ now,
525
+ includeTerminalDirect: includeTerminal,
526
+ });
527
+
528
+ const candidates = [
529
+ ...activeWorkEvidence.staleDirectWork,
530
+ ...(includeTerminal ? activeWorkEvidence.terminalDirectWork : []),
531
+ ];
532
+ // Only prune store-backed dispatch rows (taskIds present in MeshRuntimeStore). Ledger-only
533
+ // remote entries have no store row to delete and are pure audit history — leave them alone.
534
+ const storeTaskIds = new Set(opts.directDispatches.map(d => d.taskId));
535
+
536
+ const prunable: MeshActiveWorkRecord[] = [];
537
+ const skippedTooYoung: MeshActiveWorkRecord[] = [];
538
+ const preservedUnacknowledged: MeshActiveWorkRecord[] = [];
539
+ const preservedLedgerOnly: MeshActiveWorkRecord[] = [];
540
+ const preservedNotOrphan: MeshActiveWorkRecord[] = [];
541
+ for (const record of candidates) {
542
+ const classification = classifyStaleDirectForPrune(record, { includeTerminal });
543
+ if (classification === 'preserve_unacknowledged') {
544
+ preservedUnacknowledged.push(record);
545
+ continue;
546
+ }
547
+ if (classification === 'preserve_active') {
548
+ preservedNotOrphan.push(record);
549
+ continue;
550
+ }
551
+ // prunable_orphan | prunable_terminal — only delete store-backed rows; ledger-only remote
552
+ // entries have no store row to delete and are pure audit history.
553
+ if (!storeTaskIds.has(record.taskId)) {
554
+ preservedLedgerOnly.push(record);
555
+ continue;
556
+ }
557
+ // Age gate (auto path only): hold back orphans that are too fresh — a node/session that is
558
+ // only transiently invisible must not be pruned the instant it disappears.
559
+ if (minAgeMs > 0) {
560
+ const ageRef = record.dispatchedAt || record.createdAt;
561
+ const ageMs = elapsedSince(ageRef, now);
562
+ if (ageMs < minAgeMs) {
563
+ skippedTooYoung.push(record);
564
+ continue;
565
+ }
566
+ }
567
+ prunable.push(record);
568
+ }
569
+
570
+ let prunedCount = 0;
571
+ if (execute && prunable.length) {
572
+ prunedCount = deleteDirectDispatchesByTaskId(opts.meshId, prunable.map(r => r.taskId));
573
+ appendLedgerEntry(opts.meshId, {
574
+ kind: 'direct_dispatch_pruned',
575
+ payload: {
576
+ source: opts.source || 'prune_stale_direct',
577
+ prunedCount,
578
+ taskIds: prunable.map(r => r.taskId),
579
+ reasons: Array.from(new Set(prunable.map(r => r.staleReason || (r.terminal ? 'terminal' : 'unknown')))),
580
+ },
581
+ });
582
+ }
583
+
584
+ return {
585
+ mode: execute ? 'execute' : 'dry_run',
586
+ includeTerminal,
587
+ candidateCount: candidates.length,
588
+ prunable,
589
+ prunedCount,
590
+ skippedTooYoung,
591
+ preservedUnacknowledged,
592
+ preservedLedgerOnly,
593
+ preservedNotOrphan,
594
+ };
595
+ }
596
+
443
597
  export function buildCompactStaleDirectWorkSummary(
444
598
  staleDirectWork: MeshActiveWorkRecord[],
445
599
  opts: { sampleLimit?: number; detailHint?: string; note?: string } = {},
@@ -21,7 +21,7 @@ import {
21
21
  findRecentTerminalLedgerEvidence,
22
22
  hasDispatchAfterTerminal,
23
23
  hasUnterminalDirectDispatchLedgerEntry,
24
- buildLongGeneratingCompletionReconciliation,
24
+ buildNoProgressCompletionReconciliation,
25
25
  } from './mesh-events-stale.js';
26
26
  import {
27
27
  buildMeshSystemMessage,
@@ -125,7 +125,7 @@ function shouldSuppressIntentionalCleanupStop(args: {
125
125
  sessionId?: string;
126
126
  nodeId?: string;
127
127
  }): boolean {
128
- if (args.event !== 'agent:stopped' && args.event !== 'monitor:long_generating') return false;
128
+ if (args.event !== 'agent:stopped' && args.event !== 'monitor:no_progress') return false;
129
129
  if (isIntentionalCleanupStopMetadata(args.metadataEvent)) return true;
130
130
  return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
131
131
  }
@@ -453,7 +453,8 @@ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?
453
453
  function sessionStateLooksActive(state: any): boolean {
454
454
  const status = readNonEmptyString(state?.status).toLowerCase();
455
455
  const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
456
- const active = new Set(['generating', 'streaming', 'long_generating', 'working', 'starting', 'waiting_approval']);
456
+ // 'long_generating' is retained as a legacy alias for the renamed 'no_progress' busy status.
457
+ const active = new Set(['generating', 'streaming', 'no_progress', 'long_generating', 'working', 'starting', 'waiting_approval']);
457
458
  return active.has(status) || active.has(chatStatus);
458
459
  }
459
460
 
@@ -1329,7 +1330,7 @@ const MESH_COORDINATOR_EVENTS = new Set([
1329
1330
  'agent:waiting_approval',
1330
1331
  'agent:stopped',
1331
1332
  'agent:ready',
1332
- 'monitor:long_generating',
1333
+ 'monitor:no_progress',
1333
1334
  'refine:accepted',
1334
1335
  'refine:completed',
1335
1336
  'refine:failed',
@@ -1341,7 +1342,7 @@ const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
1341
1342
  'agent:generating_completed': 'task_completed',
1342
1343
  'agent:waiting_approval': 'task_approval_needed',
1343
1344
  'agent:stopped': 'task_failed',
1344
- 'monitor:long_generating': 'task_stalled',
1345
+ 'monitor:no_progress': 'task_stalled',
1345
1346
  };
1346
1347
 
1347
1348
  export function isMeshCoordinatorEvent(eventName: unknown): eventName is string {
@@ -1419,24 +1420,24 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1419
1420
  return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
1420
1421
  }
1421
1422
 
1422
- if (args.event === 'monitor:long_generating') {
1423
- const reconciledCompletion = buildLongGeneratingCompletionReconciliation({
1423
+ if (args.event === 'monitor:no_progress') {
1424
+ const reconciledCompletion = buildNoProgressCompletionReconciliation({
1424
1425
  meshId: args.meshId,
1425
1426
  nodeId: args.nodeId,
1426
1427
  nodeLabel: args.nodeLabel,
1427
1428
  metadataEvent: args.metadataEvent,
1428
1429
  sourceInstanceId: args.sourceInstanceId,
1429
1430
  });
1430
- if (reconciledCompletion?.source === 'long_generating_reconciliation') {
1431
- LOG.info('MeshEvents', `Reconciled long-generating monitor to completion for session ${eventSessionId || '(unknown session)'}`);
1431
+ if (reconciledCompletion?.source === 'no_progress_reconciliation') {
1432
+ LOG.info('MeshEvents', `Reconciled no-progress monitor to completion for session ${eventSessionId || '(unknown session)'}`);
1432
1433
  return injectMeshSystemMessage(components, {
1433
1434
  ...args,
1434
1435
  event: 'agent:generating_completed',
1435
1436
  metadataEvent: reconciledCompletion,
1436
1437
  });
1437
1438
  }
1438
- if (reconciledCompletion?.source === 'long_generating_terminal_ledger_suppression') {
1439
- LOG.info('MeshEvents', `Suppressed long-generating monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1439
+ if (reconciledCompletion?.source === 'no_progress_terminal_ledger_suppression') {
1440
+ LOG.info('MeshEvents', `Suppressed no-progress monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1440
1441
  return {
1441
1442
  success: true,
1442
1443
  forwarded: 0,
@@ -1483,7 +1484,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1483
1484
  if (
1484
1485
  (terminalProviderSessionId && terminalProviderSessionId === eventProviderSessionId)
1485
1486
  || (terminalFinalSummary && terminalFinalSummary === eventFinalSummary)
1486
- || args.metadataEvent.source === 'long_generating_reconciliation'
1487
+ || args.metadataEvent.source === 'no_progress_reconciliation'
1487
1488
  ) {
1488
1489
  LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1489
1490
  return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true };
@@ -235,7 +235,7 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
235
235
  return { reconciled: true, kind, workerResult, ledgerEntryId: entry.id };
236
236
  }
237
237
 
238
- export function buildLongGeneratingCompletionReconciliation(args: {
238
+ export function buildNoProgressCompletionReconciliation(args: {
239
239
  meshId: string;
240
240
  nodeId?: string;
241
241
  nodeLabel: string;
@@ -265,8 +265,8 @@ export function buildLongGeneratingCompletionReconciliation(args: {
265
265
  providerType,
266
266
  providerSessionId,
267
267
  finalSummary,
268
- source: 'long_generating_reconciliation',
269
- reconciledFromEvent: 'monitor:long_generating',
268
+ source: 'no_progress_reconciliation',
269
+ reconciledFromEvent: 'monitor:no_progress',
270
270
  timestamp: args.metadataEvent.timestamp ?? Date.now(),
271
271
  completionDiagnostic: {
272
272
  ...(completionDiagnostic || {}),
@@ -283,7 +283,7 @@ export function buildLongGeneratingCompletionReconciliation(args: {
283
283
  if (!terminal) return null;
284
284
  return {
285
285
  ...args.metadataEvent,
286
- source: 'long_generating_terminal_ledger_suppression',
286
+ source: 'no_progress_terminal_ledger_suppression',
287
287
  terminalLedgerKind: terminal.kind,
288
288
  terminalLedgerAt: terminal.timestamp,
289
289
  };
@@ -137,8 +137,8 @@ export function buildMeshSystemMessage(args: {
137
137
  }): string {
138
138
  const metadata = formatCompletionMetadata(args.metadataEvent);
139
139
  if (args.event === 'agent:generating_completed') {
140
- if (args.metadataEvent.source === 'long_generating_reconciliation') {
141
- return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The long-generating monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
140
+ if (args.metadataEvent.source === 'no_progress_reconciliation') {
141
+ return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The no-progress monitor reconciled the terminal handoff and marked the session complete; wait for the queued completion event/status refresh before doing any manual transcript check.`;
142
142
  }
143
143
  const reviewNote = args.metadataEvent.reviewRecommended === true
144
144
  ? ' Completion evidence is insufficient — verify via git status or provider_session_id before assuming the task is done. Use mesh_read_chat once if needed, but do not poll repeatedly.'
@@ -173,7 +173,7 @@ export function buildMeshSystemMessage(args: {
173
173
  }
174
174
  return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
175
175
  }
176
- if (args.event === 'monitor:long_generating') {
176
+ if (args.event === 'monitor:no_progress') {
177
177
  return `[System] ${args.nodeLabel} is still reported as generating after a long interval${metadata}. Wait for pendingCoordinatorEvents or a completion/status event; if the user explicitly asks for status, make one bounded status check and then wait again.`;
178
178
  }
179
179
  if (args.event === 'worktree_bootstrap_complete') {