@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.
- package/dist/cli-adapters/provider-cli-shared.d.ts +1 -1
- package/dist/git/change-impact-config.d.ts +159 -0
- package/dist/git/git-status.d.ts +14 -0
- package/dist/git/index.d.ts +2 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1140 -607
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1090 -564
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-active-work.d.ts +66 -0
- package/dist/mesh/mesh-events-stale.d.ts +1 -1
- package/dist/providers/status-monitor.d.ts +7 -7
- package/dist/shared-types.d.ts +1 -1
- package/package.json +2 -2
- package/src/agent-stream/provider-adapter.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +2 -2
- package/src/cli-adapters/provider-cli-shared.ts +1 -1
- package/src/commands/chat-commands.ts +1 -1
- package/src/commands/cli-manager.ts +1 -1
- package/src/commands/router.ts +46 -0
- package/src/git/change-impact-config.ts +354 -0
- package/src/git/git-status.ts +182 -16
- package/src/git/index.ts +16 -0
- package/src/index.ts +2 -2
- package/src/mesh/mesh-active-work.ts +154 -0
- package/src/mesh/mesh-events-coordinator.ts +13 -12
- package/src/mesh/mesh-events-stale.ts +4 -4
- package/src/mesh/mesh-events-utils.ts +3 -3
- package/src/mesh/mesh-reconcile-loop.ts +144 -1
- package/src/providers/acp-provider-instance.ts +6 -5
- package/src/providers/cli-provider-instance.ts +14 -10
- package/src/providers/extension-provider-instance.ts +7 -6
- package/src/providers/ide-provider-instance.ts +10 -6
- package/src/providers/read-chat-contract.ts +1 -1
- package/src/providers/status-monitor.d.ts +7 -7
- package/src/providers/status-monitor.ts +37 -22
- package/src/shared-types.ts +3 -0
- package/src/status/reporter.ts +1 -1
package/src/git/git-status.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
230
|
-
* can't be obtained or any changed path is outside the
|
|
231
|
-
* AND is not a recognized non-runtime root file
|
|
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) =>
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
1423
|
-
const reconciledCompletion =
|
|
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 === '
|
|
1431
|
-
LOG.info('MeshEvents', `Reconciled
|
|
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 === '
|
|
1439
|
-
LOG.info('MeshEvents', `Suppressed
|
|
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 === '
|
|
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
|
|
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: '
|
|
269
|
-
reconciledFromEvent: 'monitor:
|
|
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: '
|
|
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 === '
|
|
141
|
-
return `[System] ${args.nodeLabel} already has completion evidence${metadata}. The
|
|
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:
|
|
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') {
|