@adhdev/daemon-core 0.9.82-rc.510 → 0.9.82-rc.511
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/index.js +132 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +162 -64
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-forwarding.d.ts +8 -0
- package/dist/mesh/mesh-idle-reminder.d.ts +6 -4
- package/dist/mesh/mesh-queue-assignment.d.ts +1 -0
- package/package.json +3 -3
- package/src/git/git-status.ts +152 -8
- package/src/mesh/mesh-event-forwarding.ts +34 -1
- package/src/mesh/mesh-idle-reminder.ts +21 -5
- package/src/mesh/mesh-queue-assignment.ts +1 -1
- package/src/providers/spec/fsm-driver.ts +18 -1
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
|
+
export declare function bootstrapQueueTaskCountsAsHandled(task: {
|
|
3
|
+
status: string;
|
|
4
|
+
targetNodeId?: string | null;
|
|
5
|
+
autoLaunch?: {
|
|
6
|
+
status: string;
|
|
7
|
+
updatedAt: string;
|
|
8
|
+
} | null;
|
|
9
|
+
}, bootstrapNodeId: string, nowMs: number): boolean;
|
|
2
10
|
export declare function recoverMeshIdByCoordinatorAndNode(coordinatorDaemonId: string, nodeId: string): string;
|
|
3
11
|
export declare function resolveForwardEventMeshId(components: DaemonComponents, payload: Record<string, unknown>): string;
|
|
4
12
|
export declare function __resetMeshWorkspaceCacheForTests(): void;
|
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
* Design invariants:
|
|
20
20
|
* - Only fires when the mesh has ≥1 `active` mission AND is fully idle. Fully idle =
|
|
21
21
|
* buildMeshActiveWork over queue + direct dispatches reports totalActiveCount === 0
|
|
22
|
-
* && generatingCount === 0
|
|
23
|
-
* (expensive per-tick RPC): any non-terminal
|
|
24
|
-
* totalActiveCount > 0, so the idle check is
|
|
25
|
-
* reminder whenever any work is outstanding, which is
|
|
22
|
+
* && generatingCount === 0 AND no async refine job is accepted/running. We intentionally
|
|
23
|
+
* do NOT probe remote node sessions here (expensive per-tick RPC): any non-terminal
|
|
24
|
+
* queue/direct work already makes totalActiveCount > 0, so the idle check is
|
|
25
|
+
* conservative — it suppresses the reminder whenever any work is outstanding, which is
|
|
26
|
+
* the safe direction. Async refine jobs (`mesh_refine_node`) are a separate class that
|
|
27
|
+
* buildMeshActiveWork does not count, so they are checked explicitly from the ledger.
|
|
26
28
|
* - NEVER transitions a mission's status. It only surfaces a hint; the coordinator
|
|
27
29
|
* decides via mesh_mission_upsert.
|
|
28
30
|
* - Debounced per mission-set. Re-fires only when the debounce window has elapsed OR
|
|
@@ -3,6 +3,7 @@ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
|
|
|
3
3
|
export declare function __resetIdleAutoFastForwardForTests(): void;
|
|
4
4
|
export declare function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined;
|
|
5
5
|
export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
|
|
6
|
+
export declare const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90000;
|
|
6
7
|
interface AwaitClaimBackoffState {
|
|
7
8
|
cycles: number;
|
|
8
9
|
nextAttemptAtMs: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.511",
|
|
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",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.511",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.511",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
package/src/git/git-status.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
1
2
|
import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
|
|
2
3
|
import { GIT_STATUS_TIMEOUT_MS, GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
|
|
3
4
|
import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
|
|
@@ -444,6 +445,18 @@ export interface ChangedPackageClassification {
|
|
|
444
445
|
affectedPackages: string[];
|
|
445
446
|
}
|
|
446
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Same as {@link ChangedPackageClassification} but also carries the runtime-ambiguous
|
|
450
|
+
* non-package paths that forced (or would force) a daemon-affecting verdict. Callers
|
|
451
|
+
* with git access (classifyChangedPackages) inspect these to descend into submodule
|
|
452
|
+
* gitlinks — a bare submodule path (e.g. `oss`) is runtime-ambiguous from the root's
|
|
453
|
+
* point of view, but its *content* diff may be entirely web-only.
|
|
454
|
+
*/
|
|
455
|
+
interface ChangedFileListClassification extends ChangedPackageClassification {
|
|
456
|
+
/** Non-package paths that were not recognized as benign root files. */
|
|
457
|
+
ambiguousNonPackageFiles: string[];
|
|
458
|
+
}
|
|
459
|
+
|
|
447
460
|
/**
|
|
448
461
|
* Pure bucketer: classify an already-collected changed-file list into the coarse
|
|
449
462
|
* daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
|
|
@@ -454,21 +467,21 @@ export interface ChangedPackageClassification {
|
|
|
454
467
|
function classifyChangedFileList(
|
|
455
468
|
files: string[],
|
|
456
469
|
policy: ResolvedChangeImpactPolicy,
|
|
457
|
-
):
|
|
470
|
+
): ChangedFileListClassification {
|
|
458
471
|
if (files.length === 0) {
|
|
459
472
|
// No file diff (e.g. only merge metadata) — nothing actionable, but stay
|
|
460
473
|
// conservative and treat as daemon-affecting so we don't suppress a real warning.
|
|
461
|
-
return { isDaemonAffecting: true, affectedPackages: [] };
|
|
474
|
+
return { isDaemonAffecting: true, affectedPackages: [], ambiguousNonPackageFiles: [] };
|
|
462
475
|
}
|
|
463
476
|
const pkgs = new Set<string>();
|
|
464
|
-
//
|
|
477
|
+
// Non-package paths that are NOT recognized benign root files (marker/doc).
|
|
465
478
|
// Only these force daemon-affecting; benign markers/docs are ignored so a
|
|
466
479
|
// gitlink-moving root commit over a marker-only oss commit no longer over-warns.
|
|
467
|
-
|
|
480
|
+
const ambiguousNonPackageFiles: string[] = [];
|
|
468
481
|
for (const file of files) {
|
|
469
482
|
const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
|
|
470
483
|
if (!match) {
|
|
471
|
-
if (!isNonRuntimeRootFile(file, policy))
|
|
484
|
+
if (!isNonRuntimeRootFile(file, policy)) ambiguousNonPackageFiles.push(file);
|
|
472
485
|
continue;
|
|
473
486
|
}
|
|
474
487
|
pkgs.add(match[1]);
|
|
@@ -481,9 +494,9 @@ function classifyChangedFileList(
|
|
|
481
494
|
// file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
|
|
482
495
|
// therefore stay daemon-affecting (fail-safe default preserved).
|
|
483
496
|
const allBenign =
|
|
484
|
-
|
|
497
|
+
ambiguousNonPackageFiles.length === 0 &&
|
|
485
498
|
affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
|
|
486
|
-
return { isDaemonAffecting: !allBenign, affectedPackages };
|
|
499
|
+
return { isDaemonAffecting: !allBenign, affectedPackages, ambiguousNonPackageFiles };
|
|
487
500
|
}
|
|
488
501
|
|
|
489
502
|
/**
|
|
@@ -542,7 +555,138 @@ export async function classifyChangedPackages(
|
|
|
542
555
|
.split('\n')
|
|
543
556
|
.map((line) => line.trim())
|
|
544
557
|
.filter(Boolean);
|
|
545
|
-
|
|
558
|
+
const rootVerdict = classifyChangedFileList(files, policy);
|
|
559
|
+
return refineVerdictThroughSubmodules(repoPath, fromRef, toRef, options, policy, rootVerdict);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* A `.gitmodules`-registered submodule root path (e.g. `oss`) appears in the root diff
|
|
564
|
+
* as a bare gitlink — no `packages/...` segment — so classifyChangedFileList treats it
|
|
565
|
+
* as runtime-ambiguous and the root verdict is daemon-affecting. But the submodule's
|
|
566
|
+
* *content* diff may be entirely web-only (a web-core-only oss commit) — in which case
|
|
567
|
+
* the daemon genuinely isn't affected and the refine gate should skip daemon-scoped
|
|
568
|
+
* validation. This descends into each submodule that is the *only* thing blocking a
|
|
569
|
+
* benign verdict, classifies its own gitlink-range content diff with the SAME policy,
|
|
570
|
+
* and folds the result back.
|
|
571
|
+
*
|
|
572
|
+
* Strictly conservative — a submodule verdict can only ever KEEP the root benign or
|
|
573
|
+
* flip an otherwise-benign root back to daemon-affecting; it never overrides a root
|
|
574
|
+
* that was daemon-affecting for its own reasons (a runtime package / unknown package /
|
|
575
|
+
* a runtime-ambiguous non-submodule file). If ANY blocking non-package path is not a
|
|
576
|
+
* registered submodule, or any submodule probe fails, we keep the conservative root
|
|
577
|
+
* verdict (fail-safe: never silence a real warning on uncertainty).
|
|
578
|
+
*/
|
|
579
|
+
async function refineVerdictThroughSubmodules(
|
|
580
|
+
repoPath: string,
|
|
581
|
+
fromRef: string,
|
|
582
|
+
toRef: string,
|
|
583
|
+
options: GitStatusOptions,
|
|
584
|
+
policy: ResolvedChangeImpactPolicy,
|
|
585
|
+
rootVerdict: ChangedFileListClassification,
|
|
586
|
+
): Promise<ChangedPackageClassification> {
|
|
587
|
+
const strip = ({ isDaemonAffecting, affectedPackages }: ChangedPackageClassification) => ({ isDaemonAffecting, affectedPackages });
|
|
588
|
+
const ambiguous = rootVerdict.ambiguousNonPackageFiles;
|
|
589
|
+
// Fast path: nothing to descend into, or the root is daemon-affecting for a reason
|
|
590
|
+
// other than an ambiguous path (an unknown/daemon package). Submodule descent only
|
|
591
|
+
// ever addresses the ambiguous-non-package reason, so it cannot help here.
|
|
592
|
+
if (ambiguous.length === 0) return strip(rootVerdict);
|
|
593
|
+
|
|
594
|
+
let submodulePaths: Set<string>;
|
|
595
|
+
try {
|
|
596
|
+
submodulePaths = await listSubmodulePaths(repoPath, options);
|
|
597
|
+
} catch {
|
|
598
|
+
return strip(rootVerdict); // can't read .gitmodules → stay conservative.
|
|
599
|
+
}
|
|
600
|
+
// Every blocking ambiguous path must be a registered submodule for descent to be able
|
|
601
|
+
// to clear the verdict — otherwise a non-submodule ambiguous file keeps it daemon.
|
|
602
|
+
if (ambiguous.length === 0 || !ambiguous.every((f) => submodulePaths.has(f))) {
|
|
603
|
+
return strip(rootVerdict);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const submoduleAffectedPackages: string[] = [];
|
|
607
|
+
for (const subPath of ambiguous) {
|
|
608
|
+
let range: { from: string; to: string };
|
|
609
|
+
try {
|
|
610
|
+
range = await resolveSubmoduleGitlinkRange(repoPath, fromRef, toRef, subPath, options);
|
|
611
|
+
} catch {
|
|
612
|
+
return strip(rootVerdict); // couldn't read the gitlink SHAs → conservative.
|
|
613
|
+
}
|
|
614
|
+
// A recursive classify inside the submodule reuses the SAME repo config resolution:
|
|
615
|
+
// the submodule has its own packages/ layout and may carry its own change-impact
|
|
616
|
+
// config; classifyChangedPackages(subRepo, ...) resolves it there.
|
|
617
|
+
let subVerdict: ChangedPackageClassification;
|
|
618
|
+
try {
|
|
619
|
+
subVerdict = await classifyChangedPackages(join(repoPath, subPath), range.from, range.to, {
|
|
620
|
+
...options,
|
|
621
|
+
// Do not force the root's injected config onto the submodule — let it resolve
|
|
622
|
+
// its own .adhdev/change-impact.* (or fall back to defaults).
|
|
623
|
+
changeImpactConfig: undefined,
|
|
624
|
+
});
|
|
625
|
+
} catch {
|
|
626
|
+
return strip(rootVerdict); // submodule diff failed → conservative.
|
|
627
|
+
}
|
|
628
|
+
if (subVerdict.isDaemonAffecting) {
|
|
629
|
+
// The submodule content really does touch daemon runtime → keep daemon-affecting,
|
|
630
|
+
// surfacing the submodule packages so the reason is visible.
|
|
631
|
+
return {
|
|
632
|
+
isDaemonAffecting: true,
|
|
633
|
+
affectedPackages: [...new Set([...rootVerdict.affectedPackages, ...subVerdict.affectedPackages])].sort(),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
submoduleAffectedPackages.push(...subVerdict.affectedPackages);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Every ambiguous path was a submodule whose content is web-only. The remaining root
|
|
640
|
+
// packages (if any) must themselves be benign web-only for the whole change to be
|
|
641
|
+
// benign — reuse the exact same rule by re-checking the root package set.
|
|
642
|
+
const rootPackagesBenign = rootVerdict.affectedPackages.every(
|
|
643
|
+
(p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p),
|
|
644
|
+
);
|
|
645
|
+
return {
|
|
646
|
+
isDaemonAffecting: !rootPackagesBenign,
|
|
647
|
+
affectedPackages: [...new Set([...rootVerdict.affectedPackages, ...submoduleAffectedPackages])].sort(),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Registered submodule paths from `.gitmodules` (empty set if none / unreadable). */
|
|
652
|
+
async function listSubmodulePaths(repoPath: string, options: GitStatusOptions): Promise<Set<string>> {
|
|
653
|
+
// `git config -f .gitmodules --get-regexp path` lists `submodule.<name>.path <path>`.
|
|
654
|
+
const res = await runGit(repoPath, ['config', '-f', '.gitmodules', '--get-regexp', 'path'], options);
|
|
655
|
+
const paths = new Set<string>();
|
|
656
|
+
for (const line of res.stdout.split('\n')) {
|
|
657
|
+
const trimmed = line.trim();
|
|
658
|
+
if (!trimmed) continue;
|
|
659
|
+
const idx = trimmed.indexOf(' ');
|
|
660
|
+
if (idx === -1) continue;
|
|
661
|
+
const p = trimmed.slice(idx + 1).trim();
|
|
662
|
+
if (p) paths.add(p);
|
|
663
|
+
}
|
|
664
|
+
return paths;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Read the old/new subproject SHAs a root gitlink moved between over `fromRef..toRef`.
|
|
669
|
+
* `git diff <range> -- <subPath>` on a gitlink prints `-Subproject commit <old>` /
|
|
670
|
+
* `+Subproject commit <new>`. Throws if either SHA can't be resolved.
|
|
671
|
+
*/
|
|
672
|
+
async function resolveSubmoduleGitlinkRange(
|
|
673
|
+
repoPath: string,
|
|
674
|
+
fromRef: string,
|
|
675
|
+
toRef: string,
|
|
676
|
+
subPath: string,
|
|
677
|
+
options: GitStatusOptions,
|
|
678
|
+
): Promise<{ from: string; to: string }> {
|
|
679
|
+
const res = await runGit(repoPath, ['diff', `${fromRef}..${toRef}`, '--', subPath], options);
|
|
680
|
+
let from = '';
|
|
681
|
+
let to = '';
|
|
682
|
+
for (const line of res.stdout.split('\n')) {
|
|
683
|
+
const m = line.match(/^([+-])Subproject commit ([0-9a-f]{7,40})/);
|
|
684
|
+
if (!m) continue;
|
|
685
|
+
if (m[1] === '-') from = m[2];
|
|
686
|
+
else to = m[2];
|
|
687
|
+
}
|
|
688
|
+
if (!from || !to) throw new Error(`no gitlink range for submodule ${subPath}`);
|
|
689
|
+
return { from, to };
|
|
546
690
|
}
|
|
547
691
|
|
|
548
692
|
/**
|
|
@@ -44,8 +44,40 @@ import {
|
|
|
44
44
|
runIdleMaintenanceThenAssignQueue,
|
|
45
45
|
maybeAutoFastForwardIdleNode,
|
|
46
46
|
sessionHasActiveAssignment,
|
|
47
|
+
AUTO_LAUNCH_AWAIT_CLAIM_MS,
|
|
47
48
|
} from './mesh-queue-assignment.js';
|
|
48
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// BOOTSTRAP-MSG: worktreeHasQueuedTask predicate (exported for unit testing)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Returns true when a queue task entry should be counted as "this worktree node already
|
|
54
|
+
// has work being handled" — suppressing the misleading 'use mesh_launch_session' advice
|
|
55
|
+
// in the worktree_bootstrap_complete system message.
|
|
56
|
+
//
|
|
57
|
+
// Mirrors the autoLaunchPending logic in triggerMeshQueue (mesh-queue-assignment.ts):
|
|
58
|
+
// • assigned → true (session claimed it)
|
|
59
|
+
// • pending, no al → true (queue will auto-launch, no action needed)
|
|
60
|
+
// • pending, al started|completed within AUTO_LAUNCH_AWAIT_CLAIM_MS
|
|
61
|
+
// → true (session spun up, will claim soon)
|
|
62
|
+
// • pending, al started|completed but OUTSIDE the window
|
|
63
|
+
// → false (launch timed out, manual launch IS needed)
|
|
64
|
+
// • pending, other al → true (not yet tried, queue will handle)
|
|
65
|
+
export function bootstrapQueueTaskCountsAsHandled(
|
|
66
|
+
task: { status: string; targetNodeId?: string | null; autoLaunch?: { status: string; updatedAt: string } | null },
|
|
67
|
+
bootstrapNodeId: string,
|
|
68
|
+
nowMs: number,
|
|
69
|
+
): boolean {
|
|
70
|
+
if (!meshNodeIdMatches({ id: task.targetNodeId } as MeshNodeIdentified, bootstrapNodeId)) return false;
|
|
71
|
+
if (task.status === 'assigned') return true;
|
|
72
|
+
const al = task.autoLaunch;
|
|
73
|
+
if (!al) return true;
|
|
74
|
+
if (al.status === 'started' || al.status === 'completed') {
|
|
75
|
+
const launchedAtMs = Date.parse(al.updatedAt);
|
|
76
|
+
return Number.isFinite(launchedAtMs) && nowMs - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
// The set of coordinator-daemon ids this daemon answers to when draining the
|
|
50
82
|
// pending-events queue. Mirrors resolveCoordinatorDaemonIds in mesh-reconcile-loop:
|
|
51
83
|
// a unicast event may be stamped with the status id, the bare machineId, OR the
|
|
@@ -1319,8 +1351,9 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
1319
1351
|
// Only meaningful for the 'complete' transition (a failed bootstrap claims nothing).
|
|
1320
1352
|
if (args.event === 'worktree_bootstrap_complete' && bootstrapNodeId) {
|
|
1321
1353
|
try {
|
|
1354
|
+
const nowMs = Date.now();
|
|
1322
1355
|
worktreeHasQueuedTask = getQueue(args.meshId, { status: ['pending', 'assigned'] })
|
|
1323
|
-
.some((task) =>
|
|
1356
|
+
.some((task) => bootstrapQueueTaskCountsAsHandled(task, bootstrapNodeId, nowMs));
|
|
1324
1357
|
} catch (e: any) {
|
|
1325
1358
|
LOG.warn('MeshQueue', `Failed to check queued task for ${bootstrapNodeId} (mesh ${args.meshId}): ${e?.message || e}`);
|
|
1326
1359
|
}
|
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
* Design invariants:
|
|
20
20
|
* - Only fires when the mesh has ≥1 `active` mission AND is fully idle. Fully idle =
|
|
21
21
|
* buildMeshActiveWork over queue + direct dispatches reports totalActiveCount === 0
|
|
22
|
-
* && generatingCount === 0
|
|
23
|
-
* (expensive per-tick RPC): any non-terminal
|
|
24
|
-
* totalActiveCount > 0, so the idle check is
|
|
25
|
-
* reminder whenever any work is outstanding, which is
|
|
22
|
+
* && generatingCount === 0 AND no async refine job is accepted/running. We intentionally
|
|
23
|
+
* do NOT probe remote node sessions here (expensive per-tick RPC): any non-terminal
|
|
24
|
+
* queue/direct work already makes totalActiveCount > 0, so the idle check is
|
|
25
|
+
* conservative — it suppresses the reminder whenever any work is outstanding, which is
|
|
26
|
+
* the safe direction. Async refine jobs (`mesh_refine_node`) are a separate class that
|
|
27
|
+
* buildMeshActiveWork does not count, so they are checked explicitly from the ledger.
|
|
26
28
|
* - NEVER transitions a mission's status. It only surfaces a hint; the coordinator
|
|
27
29
|
* decides via mesh_mission_upsert.
|
|
28
30
|
* - Debounced per mission-set. Re-fires only when the debounce window has elapsed OR
|
|
@@ -39,6 +41,7 @@ import { getMeshMissions, type MeshMissionRecord } from './mesh-missions.js';
|
|
|
39
41
|
import { getQueue, getActiveDirectDispatches } from './mesh-work-queue.js';
|
|
40
42
|
import { readLedgerEntries } from './mesh-ledger.js';
|
|
41
43
|
import { buildMeshActiveWork } from './mesh-active-work.js';
|
|
44
|
+
import { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs } from './mesh-refine-status.js';
|
|
42
45
|
|
|
43
46
|
/** Coordinator instance the reminder is injected into (the idle CLI session). */
|
|
44
47
|
type CoordinatorInstance = ReturnType<DaemonComponents['instanceManager']['getInstance']>;
|
|
@@ -119,15 +122,28 @@ export function maybeInjectIdleActiveMissionReminder(
|
|
|
119
122
|
// We pass no `nodes`: totalActiveCount already counts pending/assigned queue tasks
|
|
120
123
|
// and un-acknowledged direct dispatches from the store alone, so the check stays
|
|
121
124
|
// cheap (no per-node status RPC) and conservative.
|
|
125
|
+
const ledgerEntries = readLedgerEntries(meshId, { tail: 200 });
|
|
122
126
|
const summary = buildMeshActiveWork({
|
|
123
127
|
meshId,
|
|
124
128
|
queue: getQueue(meshId),
|
|
125
129
|
directDispatches: getActiveDirectDispatches(meshId),
|
|
126
|
-
ledgerEntries
|
|
130
|
+
ledgerEntries,
|
|
127
131
|
now,
|
|
128
132
|
}).summary;
|
|
129
133
|
if (summary.totalActiveCount !== 0 || summary.generatingCount !== 0) return false;
|
|
130
134
|
|
|
135
|
+
// Async refine jobs are NOT modeled as queue/direct dispatches, so buildMeshActiveWork
|
|
136
|
+
// never counts them — an accepted/running `mesh_refine_node` job (each pass runs
|
|
137
|
+
// typecheck/test/build for minutes) would otherwise read as "no work in flight" and the
|
|
138
|
+
// reminder would push the coordinator to close a mission whose verification is still
|
|
139
|
+
// in progress. Derive in-flight refine jobs from the SAME ledger tail already read
|
|
140
|
+
// (buildMeshAsyncRefineJobs maps `task_dispatched` refine entries with no terminal to
|
|
141
|
+
// accepted/running) and suppress the reminder while any is non-terminal.
|
|
142
|
+
const activeRefineJobs = summarizeMeshAsyncRefineJobs(
|
|
143
|
+
buildMeshAsyncRefineJobs({ meshId, ledgerEntries }),
|
|
144
|
+
).activeJobs;
|
|
145
|
+
if (activeRefineJobs.length > 0) return false;
|
|
146
|
+
|
|
131
147
|
// Debounce — same mission set within the window is nudged at most once.
|
|
132
148
|
const store = MeshRuntimeStore.getInstance();
|
|
133
149
|
const hash = missionSetHash(activeMissions);
|
|
@@ -709,7 +709,7 @@ const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
|
|
|
709
709
|
// successfully-launched session whose claim we are still waiting on, do not launch it
|
|
710
710
|
// again until the window lapses. It is generous (a slow remote spawn can take tens of
|
|
711
711
|
// seconds) but bounded so a launch that silently never reaches idle is eventually retried.
|
|
712
|
-
const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
|
|
712
|
+
export const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
|
|
713
713
|
|
|
714
714
|
// AUTOLAUNCH-CLAIM-CHURN. For a REMOTE node the launch→claim handshake is purely
|
|
715
715
|
// event-sourced: the worker's agent:ready must be pulled (reconcile PHASE 1) to run
|
|
@@ -790,7 +790,24 @@ export class FsmDriver implements ISpecDriver {
|
|
|
790
790
|
if (!rule) return null;
|
|
791
791
|
const hay = sectionText(sections, rule.section, fullScreen);
|
|
792
792
|
const minCount = rule.min_count ?? 2;
|
|
793
|
-
|
|
793
|
+
let buttons = extractButtonsFromRule(rule, hay);
|
|
794
|
+
if (buttons.length < minCount && rule.section) {
|
|
795
|
+
// Whole-screen fallback: the modal `section` can resolve too short
|
|
796
|
+
// when a spec's `until` anchor clips the section BEFORE the choices
|
|
797
|
+
// (e.g. a claude-cli approval whose command preview carries a leading
|
|
798
|
+
// shell-redirect line — `>/dev/null 2>&1` — that an over-broad
|
|
799
|
+
// `[…>…]` modal-terminator anchor mistakes for the input prompt,
|
|
800
|
+
// stranding the `❯ 1. Yes / 2. No` buttons below the cut and wedging
|
|
801
|
+
// auto-approve forever). The buttons are still present in the full
|
|
802
|
+
// buffer, so re-extract from it. `lastContiguousNumberedBlock`
|
|
803
|
+
// (inside extractButtonsFromRule) already isolates the real
|
|
804
|
+
// bottom-most choice block from any stray body-numbered lines the
|
|
805
|
+
// wider scope pulls in, so this cannot bind the wrong rows. Guards
|
|
806
|
+
// it to the buttons-under-count case only, so a correctly-scoped
|
|
807
|
+
// spec pays nothing.
|
|
808
|
+
const whole = extractButtonsFromRule(rule, fullScreen);
|
|
809
|
+
if (whole.length >= minCount) buttons = whole;
|
|
810
|
+
}
|
|
794
811
|
if (buttons.length < minCount) return null;
|
|
795
812
|
const title = this.deriveTitle(state, sections, fullScreen);
|
|
796
813
|
return { title, buttons };
|