@adhdev/daemon-core 0.9.82-rc.28 → 0.9.82-rc.281
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/boot/daemon-lifecycle.d.ts +8 -0
- package/dist/boot/process-hardening.d.ts +50 -0
- package/dist/build-info.d.ts +37 -0
- package/dist/chat/source-machine.d.ts +166 -0
- package/dist/chat/source-resolver.d.ts +104 -0
- package/dist/chat/subscription-updates.d.ts +1 -0
- package/dist/cli-adapter-types.d.ts +23 -2
- package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +106 -63
- package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +38 -0
- package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
- package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
- package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
- package/dist/cli-adapters/terminal-screen.d.ts +4 -11
- package/dist/commands/cli-manager.d.ts +2 -1
- package/dist/commands/handler.d.ts +110 -0
- package/dist/commands/mesh-coordinator.d.ts +85 -1
- package/dist/commands/router.d.ts +160 -1
- package/dist/config/chat-history.d.ts +9 -0
- package/dist/config/config.d.ts +5 -0
- package/dist/config/mesh-config.d.ts +72 -1
- package/dist/git/git-commands.d.ts +9 -1
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/git/git-status.d.ts +7 -0
- package/dist/git/git-types.d.ts +2 -50
- package/dist/index.d.ts +51 -12
- package/dist/index.js +34388 -12966
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +38052 -16731
- package/dist/index.mjs.map +1 -1
- package/dist/installer.d.ts +1 -4
- package/dist/ipc/local-ipc-server.d.ts +91 -0
- package/dist/launch.d.ts +1 -1
- package/dist/logging/async-batch-writer.d.ts +10 -0
- package/dist/mesh/contracts.d.ts +164 -0
- package/dist/mesh/coordinator-prompt.d.ts +36 -0
- package/dist/mesh/coordinator-registry.d.ts +59 -0
- package/dist/mesh/mesh-active-work.d.ts +108 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +133 -0
- package/dist/mesh/mesh-events-pending.d.ts +45 -0
- package/dist/mesh/mesh-events-stale.d.ts +40 -0
- package/dist/mesh/mesh-events-utils.d.ts +47 -0
- package/dist/mesh/mesh-events.d.ts +6 -49
- package/dist/mesh/mesh-fast-forward.d.ts +81 -0
- package/dist/mesh/mesh-host-ownership.d.ts +9 -0
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
- package/dist/mesh/mesh-ledger.d.ts +77 -1
- package/dist/mesh/mesh-missions.d.ts +68 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
- package/dist/mesh/mesh-refine-batch.d.ts +68 -0
- package/dist/mesh/mesh-refine-status.d.ts +62 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-routing.d.ts +70 -0
- package/dist/mesh/mesh-runtime-store.d.ts +345 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-work-queue.d.ts +166 -5
- package/dist/mesh/preview-freshness.d.ts +18 -0
- package/dist/mesh/refine-config.d.ts +215 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
- package/dist/providers/acp-provider-instance.d.ts +2 -0
- package/dist/providers/approval-utils.d.ts +13 -0
- package/dist/providers/cli-provider-instance.d.ts +61 -3
- package/dist/providers/contracts.d.ts +130 -6
- package/dist/providers/external-sources.d.ts +71 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
- package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
- package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
- package/dist/providers/native-history/dispatcher.d.ts +27 -0
- package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
- package/dist/providers/native-history/index.d.ts +13 -0
- package/dist/providers/provider-instance-manager.d.ts +13 -0
- package/dist/providers/provider-instance.d.ts +13 -1
- package/dist/providers/provider-loader.d.ts +26 -4
- package/dist/providers/provider-trust.d.ts +31 -0
- package/dist/providers/read-chat-contract.d.ts +29 -0
- package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
- package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
- package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
- package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
- package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
- package/dist/providers/sdk/v1/index.d.ts +30 -0
- package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
- package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
- package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
- package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
- package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
- package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
- package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
- package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
- package/dist/providers/spec/adapter.d.ts +59 -0
- package/dist/providers/spec/cli-adapter.d.ts +140 -0
- package/dist/providers/spec/evaluator.d.ts +23 -0
- package/dist/providers/spec/fsm-driver.d.ts +278 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
- package/dist/providers/spec/fsm-loader.d.ts +14 -0
- package/dist/providers/spec/fsm-types.d.ts +106 -0
- package/dist/providers/spec/native-history-executor.d.ts +41 -0
- package/dist/providers/spec/route.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +144 -0
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/providers/types/interactive-prompt.d.ts +48 -0
- package/dist/repo-mesh-types.d.ts +154 -13
- package/dist/sessions/registry.d.ts +6 -0
- package/dist/shared-types-extra.d.ts +2 -4
- package/dist/shared-types.d.ts +42 -1
- package/dist/status/normalize.d.ts +1 -1
- package/dist/status/normalize.js +1 -0
- package/dist/status/normalize.js.map +1 -1
- package/dist/status/normalize.mjs +1 -0
- package/dist/status/normalize.mjs.map +1 -1
- package/dist/status/reporter.d.ts +2 -0
- package/dist/status/snapshot.d.ts +1 -0
- package/dist/types.d.ts +5 -0
- package/package.json +7 -3
- package/src/agent-stream/poller.ts +2 -3
- package/src/boot/daemon-lifecycle.ts +55 -12
- package/src/boot/process-hardening.ts +89 -0
- package/src/build-info.ts +73 -0
- package/src/chat/source-machine.ts +534 -0
- package/src/chat/source-resolver.ts +0 -0
- package/src/chat/subscription-updates.ts +20 -1
- package/src/cli-adapter-types.d.ts +1 -0
- package/src/cli-adapter-types.ts +22 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1215 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +674 -1139
- package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
- package/src/cli-adapters/provider-cli-parse.ts +13 -0
- package/src/cli-adapters/provider-cli-runtime.ts +3 -1
- package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/src/cli-adapters/provider-cli-shared.ts +68 -11
- package/src/cli-adapters/raw-terminal-io.ts +252 -0
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
- package/src/cli-adapters/terminal-backends/types.ts +10 -3
- package/src/cli-adapters/terminal-screen.ts +16 -81
- package/src/commands/chat-commands.ts +1813 -60
- package/src/commands/cli-manager.ts +286 -14
- package/src/commands/handler.ts +809 -2
- package/src/commands/mesh-coordinator.ts +332 -122
- package/src/commands/router.ts +5739 -566
- package/src/config/chat-history.ts +229 -24
- package/src/config/config.ts +12 -0
- package/src/config/mesh-config.ts +303 -3
- package/src/config/recent-activity.ts +8 -2
- package/src/daemon/dev-auto-implement.ts +3 -2
- package/src/daemon/dev-cli-debug.ts +10 -1
- package/src/detection/ide-detector.ts +26 -16
- package/src/git/git-commands.ts +42 -12
- package/src/git/git-diff.ts +53 -0
- package/src/git/git-status.ts +198 -7
- package/src/git/git-types.ts +14 -62
- package/src/git/git-worktree.ts +8 -1
- package/src/index.ts +131 -11
- package/src/installer.d.ts +1 -1
- package/src/installer.ts +8 -6
- package/src/ipc/local-ipc-server.ts +278 -0
- package/src/launch.d.ts +1 -1
- package/src/launch.ts +37 -28
- package/src/logging/async-batch-writer.ts +55 -0
- package/src/logging/logger.ts +2 -1
- package/src/mesh/contracts.ts +329 -0
- package/src/mesh/coordinator-prompt.ts +219 -31
- package/src/mesh/coordinator-registry.ts +121 -0
- package/src/mesh/mesh-active-work.ts +468 -0
- package/src/mesh/mesh-delivery-policy.ts +315 -0
- package/src/mesh/mesh-events-coordinator.ts +1592 -0
- package/src/mesh/mesh-events-pending.ts +508 -0
- package/src/mesh/mesh-events-stale.ts +283 -0
- package/src/mesh/mesh-events-utils.ts +254 -0
- package/src/mesh/mesh-events.ts +30 -1035
- package/src/mesh/mesh-fast-forward.ts +839 -0
- package/src/mesh/mesh-host-ownership.ts +73 -0
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
- package/src/mesh/mesh-ledger.ts +589 -102
- package/src/mesh/mesh-missions.ts +172 -0
- package/src/mesh/mesh-reconcile-loop.ts +298 -0
- package/src/mesh/mesh-refine-batch.ts +197 -0
- package/src/mesh/mesh-refine-status.ts +231 -0
- package/src/mesh/mesh-review-inbox.ts +307 -0
- package/src/mesh/mesh-routing.ts +272 -0
- package/src/mesh/mesh-runtime-store.ts +1399 -0
- package/src/mesh/mesh-task-stats.ts +154 -0
- package/src/mesh/mesh-work-queue.ts +586 -145
- package/src/mesh/preview-freshness.ts +118 -0
- package/src/mesh/refine-config.ts +403 -0
- package/src/mesh/worktree-bootstrap-config.ts +326 -0
- package/src/providers/acp-provider-instance.ts +15 -1
- package/src/providers/approval-utils.d.ts +4 -0
- package/src/providers/approval-utils.ts +47 -5
- package/src/providers/chat-message-normalization.ts +4 -11
- package/src/providers/cli-provider-instance.ts +920 -81
- package/src/providers/contracts.d.ts +55 -0
- package/src/providers/contracts.ts +141 -6
- package/src/providers/external-sources.ts +218 -0
- package/src/providers/ide-provider-instance.ts +19 -5
- package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
- package/src/providers/native-history/claude-cli-transcript.ts +396 -0
- package/src/providers/native-history/codex-cli-transcript.ts +479 -0
- package/src/providers/native-history/dispatcher.ts +340 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
- package/src/providers/native-history/index.ts +30 -0
- package/src/providers/provider-instance-manager.ts +30 -0
- package/src/providers/provider-instance.ts +10 -1
- package/src/providers/provider-loader.ts +584 -50
- package/src/providers/provider-schema.ts +87 -14
- package/src/providers/provider-trust.ts +114 -0
- package/src/providers/read-chat-contract.ts +76 -16
- package/src/providers/sdk/README.md +49 -0
- package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
- package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
- package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
- package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
- package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
- package/src/providers/sdk/v1/index.ts +152 -0
- package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
- package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
- package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
- package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
- package/src/providers/sdk/v1/types/cli/index.ts +365 -0
- package/src/providers/sdk/v1/types/common/index.ts +229 -0
- package/src/providers/sdk/v1/validators/index.ts +19 -0
- package/src/providers/sdk/v1/validators/manifest.ts +110 -0
- package/src/providers/sdk/v1/validators/taint.ts +309 -0
- package/src/providers/spec/adapter.ts +155 -0
- package/src/providers/spec/cli-adapter.ts +730 -0
- package/src/providers/spec/evaluator.ts +345 -0
- package/src/providers/spec/fsm-driver.ts +868 -0
- package/src/providers/spec/fsm-evaluator.ts +255 -0
- package/src/providers/spec/fsm-loader.ts +102 -0
- package/src/providers/spec/fsm-types.ts +199 -0
- package/src/providers/spec/native-history-executor.ts +943 -0
- package/src/providers/spec/route.ts +51 -0
- package/src/providers/spec/types.ts +189 -0
- package/src/providers/transcript-v2.ts +567 -0
- package/src/providers/types/interactive-prompt.ts +446 -0
- package/src/providers/version-archive.ts +38 -20
- package/src/repo-mesh-types.ts +183 -13
- package/src/sessions/registry.ts +6 -0
- package/src/shared-types-extra.ts +2 -4
- package/src/shared-types.ts +45 -1
- package/src/status/builders.ts +26 -6
- package/src/status/normalize.ts +2 -0
- package/src/status/reporter.ts +15 -0
- package/src/status/snapshot.ts +84 -25
- package/src/system/host-memory.ts +29 -12
- package/src/types.ts +5 -0
- package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
- package/dist/mesh/mesh-sync.d.ts +0 -53
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
- package/src/mesh/mesh-sync.ts +0 -111
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3: Mission persistence (minimal form).
|
|
3
|
+
*
|
|
4
|
+
* A mission is a persistent record of a multi-task goal so the plan lives in
|
|
5
|
+
* the system rather than in the coordinator LLM's context. Coordinator
|
|
6
|
+
* sessions can die or compact; a new coordinator reads the mission back at
|
|
7
|
+
* launch and continues.
|
|
8
|
+
*
|
|
9
|
+
* Explicit non-goals (see docs/mesh-product-plan-v2.md Phase M3): this is not
|
|
10
|
+
* a workflow engine and there is no automatic takeover daemon. Progress is
|
|
11
|
+
* never stored — it is derived from queue task statuses (mission_id) at
|
|
12
|
+
* query time.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
17
|
+
import { getQueue } from './mesh-work-queue.js';
|
|
18
|
+
|
|
19
|
+
export type MeshMissionStatus = 'active' | 'paused' | 'completed' | 'abandoned';
|
|
20
|
+
|
|
21
|
+
export const MESH_MISSION_STATUSES: MeshMissionStatus[] = ['active', 'paused', 'completed', 'abandoned'];
|
|
22
|
+
|
|
23
|
+
export interface MeshMissionRecord {
|
|
24
|
+
id: string;
|
|
25
|
+
meshId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
goal: string;
|
|
28
|
+
status: MeshMissionStatus;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MeshMissionTaskAggregate {
|
|
34
|
+
total: number;
|
|
35
|
+
pending: number;
|
|
36
|
+
assigned: number;
|
|
37
|
+
completed: number;
|
|
38
|
+
failed: number;
|
|
39
|
+
cancelled: number;
|
|
40
|
+
/** Pending tasks held back by a dependency failure (blockedReason set). */
|
|
41
|
+
blocked: number;
|
|
42
|
+
/** Latest updatedAt across the mission's tasks, or null with no tasks. */
|
|
43
|
+
lastActivityAt: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MeshMissionSummary extends MeshMissionRecord {
|
|
47
|
+
tasks: MeshMissionTaskAggregate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeMissionStatus(value: unknown): MeshMissionStatus {
|
|
51
|
+
return MESH_MISSION_STATUSES.includes(value as MeshMissionStatus)
|
|
52
|
+
? value as MeshMissionStatus
|
|
53
|
+
: 'active';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function upsertMeshMission(meshId: string, input: {
|
|
57
|
+
id?: string;
|
|
58
|
+
title: string;
|
|
59
|
+
goal?: string;
|
|
60
|
+
status?: string;
|
|
61
|
+
}): MeshMissionRecord {
|
|
62
|
+
const title = typeof input.title === 'string' ? input.title.trim() : '';
|
|
63
|
+
if (!title) throw new Error('mission_title_required: a mission needs a non-empty title');
|
|
64
|
+
if (input.status !== undefined && !MESH_MISSION_STATUSES.includes(input.status as MeshMissionStatus)) {
|
|
65
|
+
throw new Error(`invalid_mission_status: '${input.status}' (valid: ${MESH_MISSION_STATUSES.join(', ')})`);
|
|
66
|
+
}
|
|
67
|
+
const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : randomUUID();
|
|
68
|
+
const store = MeshRuntimeStore.getInstance();
|
|
69
|
+
const existing = store.getMission(meshId, id);
|
|
70
|
+
const record = {
|
|
71
|
+
id,
|
|
72
|
+
meshId,
|
|
73
|
+
title,
|
|
74
|
+
goal: typeof input.goal === 'string' ? input.goal : existing?.goal ?? '',
|
|
75
|
+
status: normalizeMissionStatus(input.status ?? existing?.status),
|
|
76
|
+
};
|
|
77
|
+
store.upsertMission(record);
|
|
78
|
+
const saved = store.getMission(meshId, id)!;
|
|
79
|
+
return { ...saved, status: normalizeMissionStatus(saved.status) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getMeshMissions(meshId: string, statuses?: MeshMissionStatus[]): MeshMissionRecord[] {
|
|
83
|
+
return MeshRuntimeStore.getInstance().getMissions(meshId, statuses)
|
|
84
|
+
.map(m => ({ ...m, status: normalizeMissionStatus(m.status) }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getMeshMission(meshId: string, missionId: string): MeshMissionRecord | null {
|
|
88
|
+
const record = MeshRuntimeStore.getInstance().getMission(meshId, missionId);
|
|
89
|
+
return record ? { ...record, status: normalizeMissionStatus(record.status) } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Aggregate task statuses for a mission at query time (no stored progress). */
|
|
93
|
+
export function summarizeMissionTasks(meshId: string, missionId: string): MeshMissionTaskAggregate {
|
|
94
|
+
const tasks = getQueue(meshId).filter(task => task.missionId === missionId);
|
|
95
|
+
const aggregate: MeshMissionTaskAggregate = {
|
|
96
|
+
total: tasks.length,
|
|
97
|
+
pending: 0,
|
|
98
|
+
assigned: 0,
|
|
99
|
+
completed: 0,
|
|
100
|
+
failed: 0,
|
|
101
|
+
cancelled: 0,
|
|
102
|
+
blocked: 0,
|
|
103
|
+
lastActivityAt: null,
|
|
104
|
+
};
|
|
105
|
+
for (const task of tasks) {
|
|
106
|
+
if (task.status === 'pending') aggregate.pending += 1;
|
|
107
|
+
else if (task.status === 'assigned') aggregate.assigned += 1;
|
|
108
|
+
else if (task.status === 'completed') aggregate.completed += 1;
|
|
109
|
+
else if (task.status === 'failed') aggregate.failed += 1;
|
|
110
|
+
else if (task.status === 'cancelled') aggregate.cancelled += 1;
|
|
111
|
+
if (task.status === 'pending' && task.blockedReason) aggregate.blocked += 1;
|
|
112
|
+
if (task.updatedAt && (!aggregate.lastActivityAt || task.updatedAt > aggregate.lastActivityAt)) {
|
|
113
|
+
aggregate.lastActivityAt = task.updatedAt;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return aggregate;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function summarizeMeshMission(meshId: string, mission: MeshMissionRecord): MeshMissionSummary {
|
|
120
|
+
return { ...mission, tasks: summarizeMissionTasks(meshId, mission.id) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Active mission summaries for mesh_status / coordinator prompt injection. */
|
|
124
|
+
export function getActiveMeshMissionSummaries(meshId: string): MeshMissionSummary[] {
|
|
125
|
+
return getMeshMissions(meshId, ['active']).map(mission => summarizeMeshMission(meshId, mission));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mission summaries for the mesh_status dashboard surface: every active/paused
|
|
130
|
+
* mission plus a capped, newest-first slice of completed/abandoned history so
|
|
131
|
+
* the dashboard can render a collapsible "history" section without unbounded
|
|
132
|
+
* payload growth. Returned newest-first within each group (active/paused first,
|
|
133
|
+
* then history), so the frontend can split on `status` directly.
|
|
134
|
+
*/
|
|
135
|
+
export function getMeshStatusMissionSummaries(
|
|
136
|
+
meshId: string,
|
|
137
|
+
options?: { historyLimit?: number },
|
|
138
|
+
): MeshMissionSummary[] {
|
|
139
|
+
const historyLimit = Math.max(0, options?.historyLimit ?? 10);
|
|
140
|
+
const all = getMeshMissions(meshId);
|
|
141
|
+
const live = all.filter(m => m.status === 'active' || m.status === 'paused');
|
|
142
|
+
const history = all
|
|
143
|
+
.filter(m => m.status === 'completed' || m.status === 'abandoned')
|
|
144
|
+
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
|
|
145
|
+
.slice(0, historyLimit);
|
|
146
|
+
return [...live, ...history].map(mission => summarizeMeshMission(meshId, mission));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* M3-3: render active missions as a prompt section for {{mission}}.
|
|
151
|
+
* Empty string when no active mission — the prompt stays byte-identical to
|
|
152
|
+
* the pre-M3 output in that case (regression guarantee).
|
|
153
|
+
*/
|
|
154
|
+
export function buildMissionPromptSection(meshId: string): string {
|
|
155
|
+
const summaries = getActiveMeshMissionSummaries(meshId);
|
|
156
|
+
if (summaries.length === 0) return '';
|
|
157
|
+
const lines: string[] = ['## Active Mission' + (summaries.length > 1 ? 's' : '')];
|
|
158
|
+
for (const mission of summaries) {
|
|
159
|
+
const t = mission.tasks;
|
|
160
|
+
lines.push(
|
|
161
|
+
`- **${mission.title}** (id: \`${mission.id}\`)`
|
|
162
|
+
+ (mission.goal ? `\n Goal: ${mission.goal}` : '')
|
|
163
|
+
+ `\n Tasks: ${t.total} total — ${t.pending} pending (${t.blocked} blocked), ${t.assigned} assigned, ${t.completed} completed, ${t.failed} failed, ${t.cancelled} cancelled`
|
|
164
|
+
+ (t.lastActivityAt ? `\n Last activity: ${t.lastActivityAt}` : ''),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
lines.push(
|
|
168
|
+
'Continue this mission from its current task state. Do not re-enqueue tasks that already exist — check mesh_view_queue first. '
|
|
169
|
+
+ 'Update the mission with mesh_mission_upsert when its goal changes or it reaches a terminal state (completed/abandoned).',
|
|
170
|
+
);
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// mesh-reconcile-loop — periodic queue → live coordinator reconciliation
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Single-model replacement for the old event-based "spontaneous forward" paths
|
|
5
|
+
// (remote P2P mesh_forward_event dispatch + live-CLI PTY fire-and-forget inject).
|
|
6
|
+
// Those pushed events at the moment a worker transitioned state, and silently
|
|
7
|
+
// dropped on the network (P2P) or when the coordinator was generating.
|
|
8
|
+
//
|
|
9
|
+
// The reliable backbone has always been the pending-events queue (SQLite +
|
|
10
|
+
// JSONL): every mesh coordinator event is persisted there before anything else
|
|
11
|
+
// (see injectMeshSystemMessage). What was missing was an *active* drainer that
|
|
12
|
+
// runs on a schedule rather than only when the coordinator (an LLM) happens to
|
|
13
|
+
// call a mesh tool.
|
|
14
|
+
//
|
|
15
|
+
// This loop is that drainer. On a fixed interval it:
|
|
16
|
+
// 1. Finds live CLI coordinator sessions on THIS daemon (meshCoordinatorFor
|
|
17
|
+
// stamp). For each mesh, drains the local queue scoped to this daemon and
|
|
18
|
+
// injects pending events into the coordinator. When a coordinator is idle it
|
|
19
|
+
// receives every queued event. When ONLY generating coordinators exist (the
|
|
20
|
+
// common case while the coordinator is blocked awaiting a worker result), the
|
|
21
|
+
// loop force-drains ONLY the force-inject events (completion / approval / stop /
|
|
22
|
+
// refine·bootstrap terminal) and force-writes them into the generating PTY —
|
|
23
|
+
// the same busy-bypass send-guard escape the live-CLI inject used to use.
|
|
24
|
+
// Non-force progress events stay queued for the next idle tick (injecting them
|
|
25
|
+
// mid-generation would be noise). This is what makes a coordinator parked in
|
|
26
|
+
// `generating` while awaiting a worker's completion actually receive it.
|
|
27
|
+
// 2. In cloud mode (dispatchMeshCommand present), pulls each remote worker
|
|
28
|
+
// node daemon's queue over P2P (get_pending_mesh_events) and re-injects via
|
|
29
|
+
// handleMeshForwardEvent — the same pull the MCP drainCoordinatorPendingEvents
|
|
30
|
+
// already does, now driven by the daemon timer instead of an LLM tool call.
|
|
31
|
+
//
|
|
32
|
+
// IMPORTANT — limits of this loop:
|
|
33
|
+
// - It only delivers to *live CLI coordinator instances* on this daemon. A
|
|
34
|
+
// pure stdio MCP coordinator (an LLM with no live CLI session to inject
|
|
35
|
+
// into) has no inject target here; that case stays pull-driven — the LLM
|
|
36
|
+
// drains the queue when it calls mesh_status / mesh_read_chat. We do NOT try
|
|
37
|
+
// to "wake" an LLM from the daemon; that is structurally impossible over a
|
|
38
|
+
// stdio request/response transport. See docs/refactoring/2026-06-15-mesh-event-to-queue-polling.md §4.7.
|
|
39
|
+
// - Queue persistence (queuePendingMeshCoordinatorEvent) and the SQLite
|
|
40
|
+
// drained=1 idempotency are the trust backbone and are untouched by this loop.
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
44
|
+
import { loadConfig } from '../config/config.js';
|
|
45
|
+
import { listMeshes } from '../config/mesh-config.js';
|
|
46
|
+
import { LOG } from '../logging/logger.js';
|
|
47
|
+
import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
|
|
48
|
+
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
49
|
+
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
50
|
+
import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS } from './mesh-events-coordinator.js';
|
|
51
|
+
import { readNonEmptyString } from './mesh-events-utils.js';
|
|
52
|
+
|
|
53
|
+
// Default reconcile cadence. approval/completion notifications to a live CLI
|
|
54
|
+
// coordinator land within at most one interval. Overridable via env for tuning.
|
|
55
|
+
const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
|
|
56
|
+
|
|
57
|
+
function resolveReconcileIntervalMs(): number {
|
|
58
|
+
const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
|
|
59
|
+
if (raw) {
|
|
60
|
+
const parsed = Number.parseInt(raw, 10);
|
|
61
|
+
if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 60_000) return parsed;
|
|
62
|
+
}
|
|
63
|
+
return DEFAULT_RECONCILE_INTERVAL_MS;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface LiveCoordinator {
|
|
67
|
+
meshId: string;
|
|
68
|
+
instance: ReturnType<DaemonComponents['instanceManager']['getInstance']>;
|
|
69
|
+
idle: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The set of coordinator-daemon ids THIS daemon answers to when draining the
|
|
73
|
+
// pending-events queue. A unicast completion event is stamped with the worker's
|
|
74
|
+
// meshCoordinatorDaemonId, which can be either:
|
|
75
|
+
// - the daemon's canonical status id (`standalone_<machineId>` / `daemon_<machineId>`),
|
|
76
|
+
// stamped by the MCP layer via ctx.localDaemonId (= getStatus().status.instanceId), or
|
|
77
|
+
// - the bare machineId, stamped by the local queue-assignment path (loadConfig().machineId).
|
|
78
|
+
// Draining with only one of these silently misses events stamped with the other —
|
|
79
|
+
// the exact reason a generating coordinator never self-received local completions.
|
|
80
|
+
// We accept BOTH so the drain matches regardless of which path stamped the event.
|
|
81
|
+
function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
|
|
82
|
+
const ids = new Set<string>();
|
|
83
|
+
const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
|
|
84
|
+
if (statusInstanceId) ids.add(statusInstanceId);
|
|
85
|
+
const machineId = readNonEmptyString(loadConfig().machineId);
|
|
86
|
+
if (machineId) ids.add(machineId);
|
|
87
|
+
return [...ids];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Find live CLI coordinator instances on THIS daemon, keyed by mesh.
|
|
91
|
+
function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
|
|
92
|
+
const out: LiveCoordinator[] = [];
|
|
93
|
+
for (const inst of components.instanceManager.getByCategory('cli')) {
|
|
94
|
+
const state = inst.getState();
|
|
95
|
+
const settings = state.settings && typeof state.settings === 'object'
|
|
96
|
+
? state.settings as Record<string, unknown>
|
|
97
|
+
: {};
|
|
98
|
+
const meshId = readNonEmptyString(settings.meshCoordinatorFor);
|
|
99
|
+
if (!meshId) continue;
|
|
100
|
+
const status = readNonEmptyString(state.status).toLowerCase();
|
|
101
|
+
out.push({ meshId, instance: inst, idle: status === 'idle' });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Inject a drained pending event into a live coordinator session. Force-inject
|
|
107
|
+
// events carry force:true so they bypass the busy send-guard and land in the PTY
|
|
108
|
+
// even while the coordinator is generating (see shouldForceInjectMeshEvent).
|
|
109
|
+
function injectPendingIntoCoordinator(
|
|
110
|
+
coordinator: LiveCoordinator['instance'],
|
|
111
|
+
pending: PendingMeshCoordinatorEvent,
|
|
112
|
+
): void {
|
|
113
|
+
if (!coordinator || !pending.coordinatorMessage) return;
|
|
114
|
+
const force = shouldForceInjectMeshEvent(pending.event);
|
|
115
|
+
coordinator.onEvent('send_message', {
|
|
116
|
+
input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
|
|
117
|
+
...(force ? { force: true } : {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// One reconcile tick across every mesh that has a live CLI coordinator here.
|
|
122
|
+
export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
|
|
123
|
+
const coordinators = findLiveCoordinators(components);
|
|
124
|
+
if (coordinators.length === 0) {
|
|
125
|
+
// No live CLI coordinator on this daemon — nothing to inject into.
|
|
126
|
+
// (MCP-only LLM coordinators drain the queue via their own tool calls.)
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Group coordinators by mesh; multiple coordinator instances for one mesh is
|
|
131
|
+
// unusual but supported (each gets the same drained events).
|
|
132
|
+
const byMesh = new Map<string, LiveCoordinator[]>();
|
|
133
|
+
for (const c of coordinators) {
|
|
134
|
+
const list = byMesh.get(c.meshId);
|
|
135
|
+
if (list) list.push(c);
|
|
136
|
+
else byMesh.set(c.meshId, [c]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
|
|
140
|
+
// The id-set used to scope the local queue drain (status id + machineId). See
|
|
141
|
+
// resolveCoordinatorDaemonIds — the status id is what the MCP layer stamps and
|
|
142
|
+
// is mandatory here for a generating CLI coordinator to self-receive completions.
|
|
143
|
+
const drainDaemonIds = resolveCoordinatorDaemonIds(components);
|
|
144
|
+
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
145
|
+
const store = (() => {
|
|
146
|
+
try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
|
|
147
|
+
})();
|
|
148
|
+
|
|
149
|
+
for (const [meshId, meshCoordinators] of byMesh) {
|
|
150
|
+
// (a) Cloud-only: pull remote worker node daemons' queues over P2P and
|
|
151
|
+
// re-inject locally. This is the same cross-daemon pull the MCP
|
|
152
|
+
// drainCoordinatorPendingEvents performs, lifted to the daemon timer.
|
|
153
|
+
// On standalone (no dispatchMeshCommand) this whole block is skipped,
|
|
154
|
+
// keeping cloud/standalone identical for the local case.
|
|
155
|
+
if (dispatchMeshCommand) {
|
|
156
|
+
try {
|
|
157
|
+
await pullRemoteNodeQueues(components, meshId, localDaemonId);
|
|
158
|
+
} catch (e: any) {
|
|
159
|
+
LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${meshId}: ${e?.message || e}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// (b) Drain the local queue scoped to this coordinator daemon and inject.
|
|
164
|
+
// - If an idle coordinator exists, FULL-drain and deliver every event to it
|
|
165
|
+
// (it can receive non-force progress events without deadlocking).
|
|
166
|
+
// - If only GENERATING coordinators exist, force-drain ONLY the force-inject
|
|
167
|
+
// events (completion/approval/stop/refine·bootstrap terminal) and force-inject
|
|
168
|
+
// them so a coordinator parked in `generating` while awaiting that very event
|
|
169
|
+
// is not deadlocked. Non-force progress events stay queued for the next idle
|
|
170
|
+
// tick — injecting them would be noise mid-generation. Both drains mark the
|
|
171
|
+
// consumed rows drained=1 atomically, so the pull path can't re-deliver.
|
|
172
|
+
const idleCoordinators = meshCoordinators.filter(c => c.idle);
|
|
173
|
+
const generatingCoordinators = meshCoordinators.filter(c => !c.idle);
|
|
174
|
+
const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
|
|
175
|
+
const forceOnly = idleCoordinators.length === 0;
|
|
176
|
+
|
|
177
|
+
// O(1) guard: skip the drain entirely when the queue is empty.
|
|
178
|
+
if (store) {
|
|
179
|
+
try {
|
|
180
|
+
if (store.pendingEventCount(meshId) === 0) continue;
|
|
181
|
+
} catch { /* fall through to drain */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let pendingEvents: PendingMeshCoordinatorEvent[] = [];
|
|
185
|
+
try {
|
|
186
|
+
pendingEvents = drainPendingMeshCoordinatorEvents(
|
|
187
|
+
meshId,
|
|
188
|
+
drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
|
|
189
|
+
forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
|
|
190
|
+
);
|
|
191
|
+
} catch (e: any) {
|
|
192
|
+
LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (pendingEvents.length === 0) continue;
|
|
196
|
+
|
|
197
|
+
const mode = forceOnly ? 'force-drain → generating' : 'inject → idle';
|
|
198
|
+
LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
|
|
199
|
+
for (const pending of pendingEvents) {
|
|
200
|
+
for (const c of targetCoordinators) {
|
|
201
|
+
injectPendingIntoCoordinator(c.instance, pending);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Cloud-only: poll each remote worker node daemon for pending coordinator events
|
|
208
|
+
// and re-inject them locally via handleMeshForwardEvent (which re-queues +
|
|
209
|
+
// surfaces to the live coordinator on the next tick / immediately if idle).
|
|
210
|
+
async function pullRemoteNodeQueues(
|
|
211
|
+
components: DaemonComponents,
|
|
212
|
+
meshId: string,
|
|
213
|
+
localDaemonId: string | undefined,
|
|
214
|
+
): Promise<void> {
|
|
215
|
+
const dispatchMeshCommand = components.dispatchMeshCommand;
|
|
216
|
+
if (!dispatchMeshCommand) return;
|
|
217
|
+
const mesh = listMeshes().find(m => m.id === meshId);
|
|
218
|
+
if (!mesh) return;
|
|
219
|
+
|
|
220
|
+
const pendingEventArgs: Record<string, unknown> = {
|
|
221
|
+
meshId,
|
|
222
|
+
...(localDaemonId ? { coordinatorDaemonId: localDaemonId } : {}),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
for (const node of mesh.nodes) {
|
|
226
|
+
const nodeDaemonId = readNonEmptyString(node.daemonId);
|
|
227
|
+
// Skip nodes without a daemon, and nodes on THIS daemon (their events are
|
|
228
|
+
// already in the local queue drained in step (b)).
|
|
229
|
+
if (!nodeDaemonId) continue;
|
|
230
|
+
if (localDaemonId && nodeDaemonId === localDaemonId) continue;
|
|
231
|
+
|
|
232
|
+
let events: unknown;
|
|
233
|
+
try {
|
|
234
|
+
events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
|
|
235
|
+
} catch {
|
|
236
|
+
// Remote pull is best-effort; the node may be offline. Retry next tick.
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
|
|
240
|
+
for (const event of list) {
|
|
241
|
+
const payload = buildForwardPayloadFromPending(event);
|
|
242
|
+
if (!payload.event || !payload.meshId) continue;
|
|
243
|
+
try {
|
|
244
|
+
handleMeshForwardEvent(components, payload);
|
|
245
|
+
} catch { /* best-effort re-inject */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function extractPendingEvents(raw: unknown): any[] {
|
|
251
|
+
if (Array.isArray(raw)) return raw;
|
|
252
|
+
if (raw && typeof raw === 'object') {
|
|
253
|
+
const events = (raw as Record<string, unknown>).events;
|
|
254
|
+
if (Array.isArray(events)) return events;
|
|
255
|
+
}
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Flatten a queued PendingMeshCoordinatorEvent into the flat payload shape
|
|
260
|
+
// handleMeshForwardEvent expects (mirrors the MCP buildMeshForwardPayloadFromPendingEvent).
|
|
261
|
+
function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
|
|
262
|
+
const metadata = event?.metadataEvent && typeof event.metadataEvent === 'object'
|
|
263
|
+
? event.metadataEvent as Record<string, unknown>
|
|
264
|
+
: {};
|
|
265
|
+
return {
|
|
266
|
+
event: readNonEmptyString(event?.event),
|
|
267
|
+
meshId: readNonEmptyString(event?.meshId),
|
|
268
|
+
nodeId: readNonEmptyString(event?.nodeId) || readNonEmptyString(metadata.meshNodeId),
|
|
269
|
+
workspace: readNonEmptyString(event?.workspace) || readNonEmptyString(metadata.workspace),
|
|
270
|
+
...metadata,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface ReconcileLoopHandle {
|
|
275
|
+
stop(): void;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Start the periodic reconcile loop. Returns a handle with stop() for shutdown.
|
|
279
|
+
export function setupMeshReconcileLoop(components: DaemonComponents): ReconcileLoopHandle {
|
|
280
|
+
const intervalMs = resolveReconcileIntervalMs();
|
|
281
|
+
let running = false;
|
|
282
|
+
const timer = setInterval(() => {
|
|
283
|
+
if (running) return; // never overlap ticks
|
|
284
|
+
running = true;
|
|
285
|
+
void runMeshReconcileTick(components)
|
|
286
|
+
.catch((e: any) => LOG.warn('MeshReconcile', `Reconcile tick error: ${e?.message || e}`))
|
|
287
|
+
.finally(() => { running = false; });
|
|
288
|
+
}, intervalMs);
|
|
289
|
+
// Don't keep the process alive solely for this timer.
|
|
290
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
291
|
+
LOG.info('MeshReconcile', `Mesh reconcile loop started (interval ${intervalMs}ms)`);
|
|
292
|
+
return {
|
|
293
|
+
stop() {
|
|
294
|
+
clearInterval(timer);
|
|
295
|
+
LOG.info('MeshReconcile', 'Mesh reconcile loop stopped');
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Change-area analysis for one worktree node, used to order sibling nodes for
|
|
8
|
+
* batch refinement so that nodes least likely to conflict merge first.
|
|
9
|
+
*
|
|
10
|
+
* The heuristic is deliberately git-only and side-effect-free: it inspects the
|
|
11
|
+
* commits the node's branch adds on top of the base (`base..branch`) and records
|
|
12
|
+
* - whether any submodule gitlink path is touched (high-conflict signal: the
|
|
13
|
+
* batch must rebase later siblings onto the advanced submodule main), and
|
|
14
|
+
* - the set of changed top-level paths (so siblings touching disjoint trees can
|
|
15
|
+
* be ordered ahead of ones that overlap).
|
|
16
|
+
*/
|
|
17
|
+
export interface MeshRefineBatchNodeChangeArea {
|
|
18
|
+
nodeId: string;
|
|
19
|
+
workspace: string;
|
|
20
|
+
branch: string;
|
|
21
|
+
/** Top-level path segments changed by the branch vs. base (e.g. 'oss', 'packages'). */
|
|
22
|
+
changedTopLevelPaths: string[];
|
|
23
|
+
/** Full changed file list (bounded) for overlap detection. */
|
|
24
|
+
changedFiles: string[];
|
|
25
|
+
/** Submodule gitlink paths touched by the branch (subset of changedTopLevelPaths). */
|
|
26
|
+
touchedSubmodulePaths: string[];
|
|
27
|
+
/** True when the branch touches at least one submodule gitlink. */
|
|
28
|
+
touchesSubmodule: boolean;
|
|
29
|
+
/** Number of commits the branch is ahead of base; 0 means nothing to merge. */
|
|
30
|
+
aheadCount: number;
|
|
31
|
+
/** Non-fatal analysis error (e.g. base/branch unresolved); ordering falls back to neutral. */
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MeshRefineBatchOrderingResult {
|
|
36
|
+
/** Node IDs in the order they should be refined. */
|
|
37
|
+
order: string[];
|
|
38
|
+
/** Per-node change areas, keyed by node id, for plan transparency. */
|
|
39
|
+
changeAreas: Record<string, MeshRefineBatchNodeChangeArea>;
|
|
40
|
+
/** Human-readable explanation of why the order was chosen. */
|
|
41
|
+
rationale: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MAX_CHANGED_FILES = 500;
|
|
45
|
+
|
|
46
|
+
function topLevel(path: string): string {
|
|
47
|
+
const slash = path.indexOf('/');
|
|
48
|
+
return slash === -1 ? path : path.slice(0, slash);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the set of submodule gitlink paths declared in a repo's .gitmodules,
|
|
53
|
+
* relative to the repo root. Used to classify which changed paths are submodule
|
|
54
|
+
* pointer bumps vs. ordinary file edits.
|
|
55
|
+
*/
|
|
56
|
+
async function resolveSubmodulePaths(repoRoot: string): Promise<Set<string>> {
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await execFileAsync(
|
|
59
|
+
'git',
|
|
60
|
+
['config', '--file', '.gitmodules', '--get-regexp', 'path'],
|
|
61
|
+
{ cwd: repoRoot, encoding: 'utf8' },
|
|
62
|
+
);
|
|
63
|
+
const paths = new Set<string>();
|
|
64
|
+
for (const line of stdout.split('\n')) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed) continue;
|
|
67
|
+
// Format: "submodule.<name>.path <path>"
|
|
68
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
69
|
+
if (spaceIdx === -1) continue;
|
|
70
|
+
const value = trimmed.slice(spaceIdx + 1).trim();
|
|
71
|
+
if (value) paths.add(value);
|
|
72
|
+
}
|
|
73
|
+
return paths;
|
|
74
|
+
} catch {
|
|
75
|
+
// No .gitmodules (or git error) → repo has no submodules to classify.
|
|
76
|
+
return new Set();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Analyze one worktree node's change area against its merge base.
|
|
82
|
+
*
|
|
83
|
+
* @param baseRef ref that the node will merge into (e.g. 'origin/main' or a SHA)
|
|
84
|
+
* @param branchRef the node's branch tip
|
|
85
|
+
* @param diffCwd repo to run the diff in (the worktree itself is authoritative for
|
|
86
|
+
* `branch` resolution, but the diff `base..branch` is symmetric, so
|
|
87
|
+
* the worktree cwd works for both refs once base is a reachable SHA).
|
|
88
|
+
*/
|
|
89
|
+
export async function analyzeMeshRefineNodeChangeArea(args: {
|
|
90
|
+
nodeId: string;
|
|
91
|
+
workspace: string;
|
|
92
|
+
branch: string;
|
|
93
|
+
baseRef: string;
|
|
94
|
+
branchRef: string;
|
|
95
|
+
diffCwd: string;
|
|
96
|
+
submodulePaths: Set<string>;
|
|
97
|
+
}): Promise<MeshRefineBatchNodeChangeArea> {
|
|
98
|
+
const { nodeId, workspace, branch, baseRef, branchRef, diffCwd, submodulePaths } = args;
|
|
99
|
+
const base: MeshRefineBatchNodeChangeArea = {
|
|
100
|
+
nodeId,
|
|
101
|
+
workspace,
|
|
102
|
+
branch,
|
|
103
|
+
changedTopLevelPaths: [],
|
|
104
|
+
changedFiles: [],
|
|
105
|
+
touchedSubmodulePaths: [],
|
|
106
|
+
touchesSubmodule: false,
|
|
107
|
+
aheadCount: 0,
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
// Use the merge-base so we compare only the node's own commits, not changes
|
|
111
|
+
// that already landed on base via a sibling earlier in the batch.
|
|
112
|
+
let mergeBase = baseRef;
|
|
113
|
+
try {
|
|
114
|
+
const { stdout } = await execFileAsync('git', ['merge-base', baseRef, branchRef], { cwd: diffCwd, encoding: 'utf8' });
|
|
115
|
+
const resolved = stdout.trim();
|
|
116
|
+
if (resolved) mergeBase = resolved;
|
|
117
|
+
} catch { /* fall back to baseRef directly */ }
|
|
118
|
+
|
|
119
|
+
const { stdout: countStdout } = await execFileAsync(
|
|
120
|
+
'git',
|
|
121
|
+
['rev-list', '--count', `${mergeBase}..${branchRef}`],
|
|
122
|
+
{ cwd: diffCwd, encoding: 'utf8' },
|
|
123
|
+
);
|
|
124
|
+
base.aheadCount = Number.parseInt(countStdout.trim(), 10) || 0;
|
|
125
|
+
|
|
126
|
+
const { stdout: nameStdout } = await execFileAsync(
|
|
127
|
+
'git',
|
|
128
|
+
['diff', '--name-only', `${mergeBase}..${branchRef}`],
|
|
129
|
+
{ cwd: diffCwd, encoding: 'utf8' },
|
|
130
|
+
);
|
|
131
|
+
const files = nameStdout.split('\n').map(line => line.trim()).filter(Boolean).slice(0, MAX_CHANGED_FILES);
|
|
132
|
+
base.changedFiles = files;
|
|
133
|
+
const topSet = new Set<string>();
|
|
134
|
+
const submoduleSet = new Set<string>();
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
const top = topLevel(file);
|
|
137
|
+
topSet.add(top);
|
|
138
|
+
// A changed path is a submodule touch if the file path IS a declared
|
|
139
|
+
// submodule path (gitlink bumps surface as the submodule path itself).
|
|
140
|
+
if (submodulePaths.has(file) || submodulePaths.has(top)) {
|
|
141
|
+
submoduleSet.add(submodulePaths.has(file) ? file : top);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
base.changedTopLevelPaths = [...topSet].sort();
|
|
145
|
+
base.touchedSubmodulePaths = [...submoduleSet].sort();
|
|
146
|
+
base.touchesSubmodule = submoduleSet.size > 0;
|
|
147
|
+
return base;
|
|
148
|
+
} catch (e: any) {
|
|
149
|
+
base.error = e?.message || String(e);
|
|
150
|
+
return base;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Order nodes for batch refinement to minimize cross-sibling conflicts.
|
|
156
|
+
*
|
|
157
|
+
* Heuristic (deterministic, stable):
|
|
158
|
+
* 1. Nodes that do NOT touch any submodule come first — they cannot advance the
|
|
159
|
+
* submodule main, so they never force a later submodule rebase.
|
|
160
|
+
* 2. Within each group, fewer touched top-level paths first (smaller blast radius).
|
|
161
|
+
* 3. Tie-break by node id for determinism.
|
|
162
|
+
*
|
|
163
|
+
* Submodule-touching siblings are intrinsically serial: each one that merges
|
|
164
|
+
* advances oss main, so the next must rebase. Ordering them last keeps the
|
|
165
|
+
* non-submodule merges (which never need a submodule rebase) clean and up front.
|
|
166
|
+
*/
|
|
167
|
+
export function orderMeshRefineBatchNodes(
|
|
168
|
+
changeAreas: MeshRefineBatchNodeChangeArea[],
|
|
169
|
+
): MeshRefineBatchOrderingResult {
|
|
170
|
+
const areaById: Record<string, MeshRefineBatchNodeChangeArea> = {};
|
|
171
|
+
for (const area of changeAreas) areaById[area.nodeId] = area;
|
|
172
|
+
|
|
173
|
+
const ranked = [...changeAreas].sort((a, b) => {
|
|
174
|
+
const aSub = a.touchesSubmodule ? 1 : 0;
|
|
175
|
+
const bSub = b.touchesSubmodule ? 1 : 0;
|
|
176
|
+
if (aSub !== bSub) return aSub - bSub;
|
|
177
|
+
const aBreadth = a.changedTopLevelPaths.length;
|
|
178
|
+
const bBreadth = b.changedTopLevelPaths.length;
|
|
179
|
+
if (aBreadth !== bBreadth) return aBreadth - bBreadth;
|
|
180
|
+
return a.nodeId.localeCompare(b.nodeId);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const rationale: string[] = [];
|
|
184
|
+
const nonSub = ranked.filter(a => !a.touchesSubmodule).map(a => a.nodeId);
|
|
185
|
+
const sub = ranked.filter(a => a.touchesSubmodule).map(a => a.nodeId);
|
|
186
|
+
if (nonSub.length) {
|
|
187
|
+
rationale.push(`Non-submodule nodes first (no submodule-main advance, conflict-free ordering): ${nonSub.join(', ')}`);
|
|
188
|
+
}
|
|
189
|
+
if (sub.length) {
|
|
190
|
+
rationale.push(`Submodule-touching nodes last, serialized (each merge advances submodule main, forcing rebase of the next): ${sub.join(', ')}`);
|
|
191
|
+
}
|
|
192
|
+
for (const area of ranked) {
|
|
193
|
+
if (area.error) rationale.push(`Node ${area.nodeId}: change-area analysis degraded (${area.error}); placed with neutral priority.`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { order: ranked.map(a => a.nodeId), changeAreas: areaById, rationale };
|
|
197
|
+
}
|