@adhdev/daemon-core 0.9.82-rc.259 → 0.9.82-rc.260
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/cli-state-engine.d.ts +20 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +9 -0
- package/dist/commands/router.d.ts +14 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +467 -51
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +457 -45
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-refine-batch.d.ts +68 -0
- package/dist/mesh/mesh-refine-status.d.ts +36 -0
- package/dist/mesh/mesh-work-queue.d.ts +26 -0
- package/package.json +1 -1
- package/src/cli-adapters/cli-state-engine.ts +56 -3
- package/src/cli-adapters/provider-cli-adapter.ts +1 -0
- package/src/cli-adapters/provider-cli-shared.ts +9 -0
- package/src/commands/router.ts +255 -0
- package/src/index.ts +3 -3
- package/src/mesh/mesh-refine-batch.ts +197 -0
- package/src/mesh/mesh-refine-status.ts +87 -0
- package/src/mesh/mesh-work-queue.ts +62 -0
- package/src/providers/cli-provider-instance.ts +11 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change-area analysis for one worktree node, used to order sibling nodes for
|
|
3
|
+
* batch refinement so that nodes least likely to conflict merge first.
|
|
4
|
+
*
|
|
5
|
+
* The heuristic is deliberately git-only and side-effect-free: it inspects the
|
|
6
|
+
* commits the node's branch adds on top of the base (`base..branch`) and records
|
|
7
|
+
* - whether any submodule gitlink path is touched (high-conflict signal: the
|
|
8
|
+
* batch must rebase later siblings onto the advanced submodule main), and
|
|
9
|
+
* - the set of changed top-level paths (so siblings touching disjoint trees can
|
|
10
|
+
* be ordered ahead of ones that overlap).
|
|
11
|
+
*/
|
|
12
|
+
export interface MeshRefineBatchNodeChangeArea {
|
|
13
|
+
nodeId: string;
|
|
14
|
+
workspace: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
/** Top-level path segments changed by the branch vs. base (e.g. 'oss', 'packages'). */
|
|
17
|
+
changedTopLevelPaths: string[];
|
|
18
|
+
/** Full changed file list (bounded) for overlap detection. */
|
|
19
|
+
changedFiles: string[];
|
|
20
|
+
/** Submodule gitlink paths touched by the branch (subset of changedTopLevelPaths). */
|
|
21
|
+
touchedSubmodulePaths: string[];
|
|
22
|
+
/** True when the branch touches at least one submodule gitlink. */
|
|
23
|
+
touchesSubmodule: boolean;
|
|
24
|
+
/** Number of commits the branch is ahead of base; 0 means nothing to merge. */
|
|
25
|
+
aheadCount: number;
|
|
26
|
+
/** Non-fatal analysis error (e.g. base/branch unresolved); ordering falls back to neutral. */
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface MeshRefineBatchOrderingResult {
|
|
30
|
+
/** Node IDs in the order they should be refined. */
|
|
31
|
+
order: string[];
|
|
32
|
+
/** Per-node change areas, keyed by node id, for plan transparency. */
|
|
33
|
+
changeAreas: Record<string, MeshRefineBatchNodeChangeArea>;
|
|
34
|
+
/** Human-readable explanation of why the order was chosen. */
|
|
35
|
+
rationale: string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Analyze one worktree node's change area against its merge base.
|
|
39
|
+
*
|
|
40
|
+
* @param baseRef ref that the node will merge into (e.g. 'origin/main' or a SHA)
|
|
41
|
+
* @param branchRef the node's branch tip
|
|
42
|
+
* @param diffCwd repo to run the diff in (the worktree itself is authoritative for
|
|
43
|
+
* `branch` resolution, but the diff `base..branch` is symmetric, so
|
|
44
|
+
* the worktree cwd works for both refs once base is a reachable SHA).
|
|
45
|
+
*/
|
|
46
|
+
export declare function analyzeMeshRefineNodeChangeArea(args: {
|
|
47
|
+
nodeId: string;
|
|
48
|
+
workspace: string;
|
|
49
|
+
branch: string;
|
|
50
|
+
baseRef: string;
|
|
51
|
+
branchRef: string;
|
|
52
|
+
diffCwd: string;
|
|
53
|
+
submodulePaths: Set<string>;
|
|
54
|
+
}): Promise<MeshRefineBatchNodeChangeArea>;
|
|
55
|
+
/**
|
|
56
|
+
* Order nodes for batch refinement to minimize cross-sibling conflicts.
|
|
57
|
+
*
|
|
58
|
+
* Heuristic (deterministic, stable):
|
|
59
|
+
* 1. Nodes that do NOT touch any submodule come first — they cannot advance the
|
|
60
|
+
* submodule main, so they never force a later submodule rebase.
|
|
61
|
+
* 2. Within each group, fewer touched top-level paths first (smaller blast radius).
|
|
62
|
+
* 3. Tie-break by node id for determinism.
|
|
63
|
+
*
|
|
64
|
+
* Submodule-touching siblings are intrinsically serial: each one that merges
|
|
65
|
+
* advances oss main, so the next must rebase. Ordering them last keeps the
|
|
66
|
+
* non-submodule merges (which never need a submodule rebase) clean and up front.
|
|
67
|
+
*/
|
|
68
|
+
export declare function orderMeshRefineBatchNodes(changeAreas: MeshRefineBatchNodeChangeArea[]): MeshRefineBatchOrderingResult;
|
|
@@ -24,3 +24,39 @@ export declare function buildMeshAsyncRefineJobs(args: {
|
|
|
24
24
|
ledgerEntries?: MeshLedgerEntry[];
|
|
25
25
|
pendingEvents?: PendingMeshCoordinatorEvent[];
|
|
26
26
|
}): MeshAsyncRefineJobSummary[];
|
|
27
|
+
/** Terminal refine jobs older than this (relative to the newest job in the set) are
|
|
28
|
+
* "stale" — already-resolved historical refinery rejections/successes that should not
|
|
29
|
+
* keep inflating the status counts in mesh_status. 6h covers a long working session
|
|
30
|
+
* while still folding multi-day-old residue. */
|
|
31
|
+
export declare const STALE_TERMINAL_REFINE_WINDOW_MS: number;
|
|
32
|
+
/** Cap on how many recent terminal jobs are counted even if all fall inside the freshness
|
|
33
|
+
* window — prevents a burst of refines from dominating the summary. */
|
|
34
|
+
export declare const RECENT_TERMINAL_REFINE_CAP = 8;
|
|
35
|
+
export interface MeshAsyncRefineJobsSummary {
|
|
36
|
+
/** Count of jobs reflected in `byStatus` (active jobs + recent terminal jobs). */
|
|
37
|
+
total: number;
|
|
38
|
+
byStatus: Record<string, number>;
|
|
39
|
+
/** Terminal jobs dropped from the counts because they are stale residue. */
|
|
40
|
+
staleTerminal: number;
|
|
41
|
+
/** Non-terminal (accepted/running) jobs still in flight. */
|
|
42
|
+
activeJobs: MeshAsyncRefineJobSummary[];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Build a compact summary of refine jobs that folds stale terminal jobs.
|
|
46
|
+
*
|
|
47
|
+
* The full job list (from `buildMeshAsyncRefineJobs`) is derived from a recent ledger
|
|
48
|
+
* window and deduped by jobId, but it still includes every terminal (completed/failed)
|
|
49
|
+
* job that happens to fall in that window — including multi-day-old refinery rejections
|
|
50
|
+
* that have long since been resolved. Those stale terminals inflate `byStatus.failed`
|
|
51
|
+
* and read as "current breakage" when they are historical noise.
|
|
52
|
+
*
|
|
53
|
+
* Active (accepted/running) jobs are always counted. Terminal jobs are counted only when
|
|
54
|
+
* they are recent: within `STALE_TERMINAL_REFINE_WINDOW_MS` of the newest job's activity
|
|
55
|
+
* time AND among the `RECENT_TERMINAL_REFINE_CAP` most-recent terminals. Everything else
|
|
56
|
+
* is folded into `staleTerminal` and excluded from `byStatus`.
|
|
57
|
+
*
|
|
58
|
+
* Freshness is measured relative to the newest job in the set (not wall-clock), so the
|
|
59
|
+
* result is deterministic for a given input — important for tests and for stale-clock
|
|
60
|
+
* environments.
|
|
61
|
+
*/
|
|
62
|
+
export declare function summarizeMeshAsyncRefineJobs(jobs: MeshAsyncRefineJobSummary[]): MeshAsyncRefineJobsSummary;
|
|
@@ -100,6 +100,32 @@ export declare function enqueueTask(meshId: string, message: string, opts?: {
|
|
|
100
100
|
/** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
|
|
101
101
|
id?: string;
|
|
102
102
|
} & MeshQueueMutationOptions): MeshWorkQueueEntry;
|
|
103
|
+
/**
|
|
104
|
+
* Record a direct-dispatch task (mesh_send_task) as an already-assigned queue
|
|
105
|
+
* entry so it is attributable to a mission.
|
|
106
|
+
*
|
|
107
|
+
* Direct dispatch normally bypasses the queue entirely — the task lives only in
|
|
108
|
+
* the ledger + mesh_direct_dispatches table, neither of which carries a
|
|
109
|
+
* missionId, so {@link summarizeMissionTasks}/{@link computeMeshTaskStats}
|
|
110
|
+
* (which both scan the queue for `task.missionId`) count it as 0. When a
|
|
111
|
+
* mission is attached, we materialise the same queue entry shape an enqueued
|
|
112
|
+
* task would have, but pre-assigned to the dispatched node/session and stamped
|
|
113
|
+
* with the dispatch timestamp. The terminal event path (updateSessionTaskStatus
|
|
114
|
+
* → findAssignedBySession) then flips it to completed/failed exactly like a
|
|
115
|
+
* pulled task, so mission total + completed aggregates work with no extra wiring.
|
|
116
|
+
*
|
|
117
|
+
* Intentionally separate from {@link enqueueTask}: enqueue creates `pending`
|
|
118
|
+
* work for the queue to assign, whereas this records work already dispatched
|
|
119
|
+
* out-of-band. They share the missionId stamping rule and mode validation.
|
|
120
|
+
*/
|
|
121
|
+
export declare function recordDirectDispatchTask(meshId: string, message: string, opts: {
|
|
122
|
+
id: string;
|
|
123
|
+
missionId: string;
|
|
124
|
+
assignedNodeId?: string;
|
|
125
|
+
assignedSessionId?: string;
|
|
126
|
+
taskMode?: MeshTaskMode | string;
|
|
127
|
+
dispatchedAt?: string;
|
|
128
|
+
}): MeshWorkQueueEntry | null;
|
|
103
129
|
/**
|
|
104
130
|
* Get all tasks in the queue, optionally filtered by status.
|
|
105
131
|
*/
|
package/package.json
CHANGED
|
@@ -107,6 +107,26 @@ export class CliStateEngine {
|
|
|
107
107
|
// ── Approval ─────────────────────────────────────
|
|
108
108
|
lastApprovalResolvedAt = 0;
|
|
109
109
|
lastResolvedModalMessage = '';
|
|
110
|
+
/**
|
|
111
|
+
* Monotonic counter bumped every time the FSM *enters* waiting_approval
|
|
112
|
+
* with a freshly captured modal (see `applyWaitingApproval`). It is the
|
|
113
|
+
* single discriminator between "the same approval re-observed across TUI
|
|
114
|
+
* paint flaps" and "a genuinely new, distinct approval".
|
|
115
|
+
*
|
|
116
|
+
* The message-equality cooldown below (`lastResolvedModalMessage`) cannot
|
|
117
|
+
* tell these apart on its own: claude-cli routinely presents consecutive
|
|
118
|
+
* approvals whose modal message text is identical (e.g. two back-to-back
|
|
119
|
+
* Bash-command prompts). When that second approval arrived inside
|
|
120
|
+
* `approvalCooldown`, the message-equality guard silently swallowed the
|
|
121
|
+
* key write and the approval stuck forever — fatal under auto-approval.
|
|
122
|
+
*
|
|
123
|
+
* `approvalEntrySeq` increments on every fresh entry; `lastResolvedEntrySeq`
|
|
124
|
+
* records which entry the cooldown belongs to. We only short-circuit the
|
|
125
|
+
* write when we are still resolving *that same* entry — a new entry (new
|
|
126
|
+
* seq) is always a real, distinct approval and must be written.
|
|
127
|
+
*/
|
|
128
|
+
approvalEntrySeq = 0;
|
|
129
|
+
private lastResolvedEntrySeq = -1;
|
|
110
130
|
/**
|
|
111
131
|
* When the engine previously held a modal but the latest parse failed
|
|
112
132
|
* to extract one, we record the timestamp here and only drop the modal
|
|
@@ -239,7 +259,10 @@ export class CliStateEngine {
|
|
|
239
259
|
? parsed.activeModal : null;
|
|
240
260
|
if (parsed?.status === 'waiting_approval' && parsedModal) {
|
|
241
261
|
modal = parsedModal;
|
|
262
|
+
// No modal was held (`this.activeModal` was null above), so a
|
|
263
|
+
// freshly parsed approval here is a new entry by definition.
|
|
242
264
|
this.activeModal = parsedModal;
|
|
265
|
+
this.approvalEntrySeq++;
|
|
243
266
|
if (this.currentStatus !== 'waiting_approval') {
|
|
244
267
|
this.setStatus('waiting_approval', 'resolve_modal_parse');
|
|
245
268
|
this.callbacks.onStatusChange();
|
|
@@ -263,13 +286,26 @@ export class CliStateEngine {
|
|
|
263
286
|
const currentModalMessage = typeof modal?.message === 'string' ? modal.message.trim() : '';
|
|
264
287
|
const inCooldown = !!this.lastApprovalResolvedAt
|
|
265
288
|
&& (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
|
|
266
|
-
|
|
289
|
+
// (fix) Suppress the duplicate key-write ONLY when this is the *same*
|
|
290
|
+
// approval entry re-observed across TUI paint flaps — i.e. the FSM has
|
|
291
|
+
// not entered waiting_approval afresh since the last resolve
|
|
292
|
+
// (approvalEntrySeq unchanged). A new entry (bumped seq) is always a
|
|
293
|
+
// distinct approval and must be written, even within the cooldown
|
|
294
|
+
// window and even when its message text matches the previous one.
|
|
295
|
+
//
|
|
296
|
+
// Previously this gated on message equality alone, so consecutive
|
|
297
|
+
// claude-cli approvals that share message text (very common) had the
|
|
298
|
+
// second write swallowed — the approval stuck unresolved despite
|
|
299
|
+
// auto-approval being on.
|
|
300
|
+
const sameEntryReResolve = this.approvalEntrySeq === this.lastResolvedEntrySeq;
|
|
301
|
+
if (inCooldown && sameEntryReResolve && currentModalMessage === this.lastResolvedModalMessage) return;
|
|
267
302
|
|
|
268
303
|
this.clearIdleFinishCandidate('resolve_modal');
|
|
269
|
-
this.recordTrace('resolve_modal', { buttonIndex, activeModal: modal });
|
|
304
|
+
this.recordTrace('resolve_modal', { buttonIndex, activeModal: modal, approvalEntrySeq: this.approvalEntrySeq });
|
|
270
305
|
this.activeModal = null;
|
|
271
306
|
this.lastApprovalResolvedAt = Date.now();
|
|
272
307
|
this.lastResolvedModalMessage = currentModalMessage;
|
|
308
|
+
this.lastResolvedEntrySeq = this.approvalEntrySeq;
|
|
273
309
|
this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
|
|
274
310
|
if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
|
|
275
311
|
this.setStatus('generating', 'approval_resolved');
|
|
@@ -666,7 +702,19 @@ export class CliStateEngine {
|
|
|
666
702
|
this.callbacks.onStatusChange();
|
|
667
703
|
return;
|
|
668
704
|
}
|
|
669
|
-
|
|
705
|
+
// (fix) A *real* modal with valid buttons surfacing during the cooldown
|
|
706
|
+
// window is a genuinely new approval, not a trailing repaint of the one
|
|
707
|
+
// we just resolved — resolveModal cleared activeModal and flipped status
|
|
708
|
+
// to generating, so detectStatus only re-reports waiting_approval with a
|
|
709
|
+
// concrete modal when the CLI actually presents the next prompt.
|
|
710
|
+
// Previously this case fell through BOTH the `inCooldown && !modal`
|
|
711
|
+
// branch above and the `!inCooldown` branch below, so the FSM silently
|
|
712
|
+
// ignored the second approval — leaving it stuck unresolved under
|
|
713
|
+
// auto-approval. Capture it like any fresh entry (the modal-message
|
|
714
|
+
// cooldown still de-dupes the same approval inside resolveModal, now
|
|
715
|
+
// keyed on approvalEntrySeq). The `!modal` flap case stays gated by
|
|
716
|
+
// cooldown above; only an actionable new modal breaks through here.
|
|
717
|
+
if (!inCooldown || modal) {
|
|
670
718
|
if (!modal) {
|
|
671
719
|
LOG.warn('CLI', `[${this.provider.type}] detectStatus=waiting_approval but parseApproval returned null; ignoring`);
|
|
672
720
|
// (fix) If we previously surfaced waiting_approval but the
|
|
@@ -710,6 +758,11 @@ export class CliStateEngine {
|
|
|
710
758
|
const nextBtnCount = Array.isArray(modal.buttons) ? modal.buttons.length : 0;
|
|
711
759
|
if (!prev || prevBtnCount !== nextBtnCount) {
|
|
712
760
|
this.activeModal = modal;
|
|
761
|
+
// A fresh modal was captured (none held, or the button shape
|
|
762
|
+
// changed). This is a distinct approval entry — bump the seq so
|
|
763
|
+
// resolveModal's message-equality cooldown does not mistake it
|
|
764
|
+
// for a flap-repaint of the previously resolved approval.
|
|
765
|
+
this.approvalEntrySeq++;
|
|
713
766
|
this.callbacks.onStatusChange();
|
|
714
767
|
}
|
|
715
768
|
if (this.idleTimeout) clearTimeout(this.idleTimeout);
|
|
@@ -940,6 +940,7 @@ export class ProviderCliAdapter implements CliAdapter {
|
|
|
940
940
|
messages: [],
|
|
941
941
|
workingDir: this.workingDir,
|
|
942
942
|
activeModal: effectiveModal,
|
|
943
|
+
approvalEntrySeq: this.engine.approvalEntrySeq,
|
|
943
944
|
pendingOutboundCount: this.pendingOutboundQueue.length,
|
|
944
945
|
pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
|
|
945
946
|
id: message.id,
|
|
@@ -29,6 +29,15 @@ export interface CliSessionStatus {
|
|
|
29
29
|
messages: CliChatMessage[];
|
|
30
30
|
workingDir: string;
|
|
31
31
|
activeModal: { message: string; buttons: string[] } | null;
|
|
32
|
+
/**
|
|
33
|
+
* Monotonic counter identifying which approval *entry* the current modal
|
|
34
|
+
* belongs to. Bumped by the FSM on every fresh waiting_approval entry.
|
|
35
|
+
* Consumers (e.g. auto-approval) use it to distinguish a genuinely new
|
|
36
|
+
* approval from the same approval re-observed across TUI paint flaps —
|
|
37
|
+
* two distinct approvals can carry identical message/button text, so the
|
|
38
|
+
* seq is the only reliable discriminator.
|
|
39
|
+
*/
|
|
40
|
+
approvalEntrySeq?: number;
|
|
32
41
|
activeInteractivePrompt?: InteractivePrompt | null;
|
|
33
42
|
pendingOutboundCount?: number;
|
|
34
43
|
pendingOutboundMessages?: Array<{
|
package/src/commands/router.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { handleMeshForwardEvent, drainPendingMeshCoordinatorEvents, getPendingMe
|
|
|
43
43
|
import { getRecentUnroutableDeliveries } from '../mesh/mesh-routing.js';
|
|
44
44
|
import { buildMeshHostRequiredFailure, normalizeMeshDaemonRole, resolveMeshHostStatus } from '../mesh/mesh-host-ownership.js';
|
|
45
45
|
import { fastForwardMeshNode } from '../mesh/mesh-fast-forward.js';
|
|
46
|
+
import { analyzeMeshRefineNodeChangeArea, orderMeshRefineBatchNodes } from '../mesh/mesh-refine-batch.js';
|
|
46
47
|
import { buildPreviewFreshness } from '../mesh/preview-freshness.js';
|
|
47
48
|
import { buildMeshAsyncRefineJobs } from '../mesh/mesh-refine-status.js';
|
|
48
49
|
import {
|
|
@@ -4126,6 +4127,251 @@ export class DaemonCommandRouter {
|
|
|
4126
4127
|
}
|
|
4127
4128
|
}
|
|
4128
4129
|
|
|
4130
|
+
/**
|
|
4131
|
+
* Batch refinery: converge multiple sibling worktree nodes onto the base branch
|
|
4132
|
+
* in one sequential pipeline, absorbing the rebase + patch-equivalence churn that
|
|
4133
|
+
* arises when several siblings touch the same submodule.
|
|
4134
|
+
*
|
|
4135
|
+
* Reuses executeMeshRefineNodeSynchronously per node — every node goes through the
|
|
4136
|
+
* exact same validation / patch-equivalence / submodule-reachability / merge / cleanup
|
|
4137
|
+
* gates, including its built-in auto-rebase onto fresh origin/<base>. Because each
|
|
4138
|
+
* node fetches origin/<base> at the start of its own refine, a node merged earlier in
|
|
4139
|
+
* the batch advances the base, and the next node's refine auto-rebases onto it before
|
|
4140
|
+
* re-running patch-equivalence. No force-push, no reset — conflicting nodes are
|
|
4141
|
+
* isolated as blocked_review while the rest of the batch proceeds.
|
|
4142
|
+
*/
|
|
4143
|
+
private async batchRefineMeshNodes(meshId: string, requestedNodeIds: string[] | undefined, args: any): Promise<CommandRouterResult> {
|
|
4144
|
+
// preferInline: same membership authority as refine_mesh_node — inline-cache-only
|
|
4145
|
+
// clone nodes (created in this MCP session) must resolve.
|
|
4146
|
+
const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
4147
|
+
const mesh = meshRecord?.mesh;
|
|
4148
|
+
if (!mesh) return { success: false, error: `Mesh '${meshId}' not found` };
|
|
4149
|
+
|
|
4150
|
+
const allNodes: any[] = Array.isArray(mesh.nodes) ? mesh.nodes : [];
|
|
4151
|
+
const isConvergeable = (n: any) => n?.isLocalWorktree && typeof n.workspace === 'string' && n.workspace;
|
|
4152
|
+
|
|
4153
|
+
let targetNodes: any[];
|
|
4154
|
+
if (Array.isArray(requestedNodeIds) && requestedNodeIds.length > 0) {
|
|
4155
|
+
targetNodes = [];
|
|
4156
|
+
const missing: string[] = [];
|
|
4157
|
+
const nonWorktree: string[] = [];
|
|
4158
|
+
for (const nodeId of requestedNodeIds) {
|
|
4159
|
+
const node = allNodes.find(n => n.id === nodeId || n.nodeId === nodeId);
|
|
4160
|
+
if (!node) { missing.push(nodeId); continue; }
|
|
4161
|
+
if (!isConvergeable(node)) { nonWorktree.push(nodeId); continue; }
|
|
4162
|
+
targetNodes.push(node);
|
|
4163
|
+
}
|
|
4164
|
+
if (missing.length || nonWorktree.length) {
|
|
4165
|
+
return {
|
|
4166
|
+
success: false,
|
|
4167
|
+
error: 'One or more requested nodes are not convergeable local worktree nodes.',
|
|
4168
|
+
...(missing.length ? { missingNodeIds: missing } : {}),
|
|
4169
|
+
...(nonWorktree.length ? { nonWorktreeNodeIds: nonWorktree } : {}),
|
|
4170
|
+
};
|
|
4171
|
+
}
|
|
4172
|
+
} else {
|
|
4173
|
+
// Auto-collect: every local worktree node is a convergence candidate.
|
|
4174
|
+
targetNodes = allNodes.filter(isConvergeable);
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
if (targetNodes.length === 0) {
|
|
4178
|
+
return { success: true, batch: true, dryRun: args?.dryRun !== false, nodeCount: 0, order: [], results: [], note: 'No convergeable local worktree nodes found.' };
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
const { execFile } = await import('node:child_process');
|
|
4182
|
+
const { promisify } = await import('node:util');
|
|
4183
|
+
const execFileAsync = promisify(execFile);
|
|
4184
|
+
|
|
4185
|
+
// Resolve the base repo root and a base ref to analyze change areas against.
|
|
4186
|
+
const resolveRepoRootFor = (node: any): string | undefined => {
|
|
4187
|
+
const sourceNode = node.clonedFromNodeId
|
|
4188
|
+
? allNodes.find(n => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
|
|
4189
|
+
: allNodes.find(n => !n.isLocalWorktree);
|
|
4190
|
+
return sourceNode?.repoRoot || sourceNode?.workspace;
|
|
4191
|
+
};
|
|
4192
|
+
|
|
4193
|
+
// Analyze change areas for ordering. The repoRoot is shared across siblings of
|
|
4194
|
+
// the same source; resolve a base ref (origin/<base> preferred) once per repoRoot.
|
|
4195
|
+
const repoRootBaseRef = new Map<string, string>();
|
|
4196
|
+
const submodulePathsByRepoRoot = new Map<string, Set<string>>();
|
|
4197
|
+
const resolveBaseRef = async (repoRoot: string): Promise<string> => {
|
|
4198
|
+
const cached = repoRootBaseRef.get(repoRoot);
|
|
4199
|
+
if (cached) return cached;
|
|
4200
|
+
let baseBranch = 'main';
|
|
4201
|
+
try {
|
|
4202
|
+
const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' });
|
|
4203
|
+
if (stdout.trim()) baseBranch = stdout.trim();
|
|
4204
|
+
} catch { /* fall back to main */ }
|
|
4205
|
+
let baseRef = 'HEAD';
|
|
4206
|
+
try {
|
|
4207
|
+
await execFileAsync('git', ['fetch', 'origin', baseBranch], { cwd: repoRoot, encoding: 'utf8' });
|
|
4208
|
+
} catch { /* offline / no remote — fall through to local refs */ }
|
|
4209
|
+
try {
|
|
4210
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', `origin/${baseBranch}`], { cwd: repoRoot, encoding: 'utf8' });
|
|
4211
|
+
baseRef = stdout.trim();
|
|
4212
|
+
} catch {
|
|
4213
|
+
try {
|
|
4214
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' });
|
|
4215
|
+
baseRef = stdout.trim();
|
|
4216
|
+
} catch { /* leave HEAD */ }
|
|
4217
|
+
}
|
|
4218
|
+
repoRootBaseRef.set(repoRoot, baseRef);
|
|
4219
|
+
return baseRef;
|
|
4220
|
+
};
|
|
4221
|
+
|
|
4222
|
+
const changeAreas: Array<Awaited<ReturnType<typeof analyzeMeshRefineNodeChangeArea>>> = [];
|
|
4223
|
+
for (const node of targetNodes) {
|
|
4224
|
+
const repoRoot = resolveRepoRootFor(node);
|
|
4225
|
+
let branch = typeof node.worktreeBranch === 'string' ? node.worktreeBranch : '';
|
|
4226
|
+
try {
|
|
4227
|
+
const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: node.workspace, encoding: 'utf8' });
|
|
4228
|
+
if (stdout.trim()) branch = stdout.trim();
|
|
4229
|
+
} catch { /* use stored worktreeBranch */ }
|
|
4230
|
+
|
|
4231
|
+
if (!repoRoot || !branch) {
|
|
4232
|
+
changeAreas.push({
|
|
4233
|
+
nodeId: node.id, workspace: node.workspace, branch: branch || '(unknown)',
|
|
4234
|
+
changedTopLevelPaths: [], changedFiles: [], touchedSubmodulePaths: [],
|
|
4235
|
+
touchesSubmodule: false, aheadCount: 0,
|
|
4236
|
+
error: !repoRoot ? 'source repoRoot not found' : 'branch not resolved',
|
|
4237
|
+
});
|
|
4238
|
+
continue;
|
|
4239
|
+
}
|
|
4240
|
+
if (!submodulePathsByRepoRoot.has(repoRoot)) {
|
|
4241
|
+
// Resolve declared submodule paths once per repo root.
|
|
4242
|
+
let subPaths = new Set<string>();
|
|
4243
|
+
try {
|
|
4244
|
+
const { stdout } = await execFileAsync('git', ['config', '--file', '.gitmodules', '--get-regexp', 'path'], { cwd: repoRoot, encoding: 'utf8' });
|
|
4245
|
+
for (const line of stdout.split('\n')) {
|
|
4246
|
+
const trimmed = line.trim();
|
|
4247
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
4248
|
+
if (spaceIdx === -1) continue;
|
|
4249
|
+
const value = trimmed.slice(spaceIdx + 1).trim();
|
|
4250
|
+
if (value) subPaths.add(value);
|
|
4251
|
+
}
|
|
4252
|
+
} catch { subPaths = new Set(); }
|
|
4253
|
+
submodulePathsByRepoRoot.set(repoRoot, subPaths);
|
|
4254
|
+
}
|
|
4255
|
+
const baseRef = await resolveBaseRef(repoRoot);
|
|
4256
|
+
let branchRef = branch;
|
|
4257
|
+
try {
|
|
4258
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', branch], { cwd: node.workspace, encoding: 'utf8' });
|
|
4259
|
+
branchRef = stdout.trim() || branch;
|
|
4260
|
+
} catch { /* use branch name */ }
|
|
4261
|
+
changeAreas.push(await analyzeMeshRefineNodeChangeArea({
|
|
4262
|
+
nodeId: node.id,
|
|
4263
|
+
workspace: node.workspace,
|
|
4264
|
+
branch,
|
|
4265
|
+
baseRef,
|
|
4266
|
+
branchRef,
|
|
4267
|
+
diffCwd: node.workspace,
|
|
4268
|
+
submodulePaths: submodulePathsByRepoRoot.get(repoRoot)!,
|
|
4269
|
+
}));
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
const ordering = orderMeshRefineBatchNodes(changeAreas);
|
|
4273
|
+
const orderedNodes = ordering.order
|
|
4274
|
+
.map(nodeId => targetNodes.find(n => n.id === nodeId || n.nodeId === nodeId))
|
|
4275
|
+
.filter((n): n is any => !!n);
|
|
4276
|
+
|
|
4277
|
+
const dryRun = args?.dryRun !== false && args?.execute !== true;
|
|
4278
|
+
if (dryRun) {
|
|
4279
|
+
return {
|
|
4280
|
+
success: true,
|
|
4281
|
+
batch: true,
|
|
4282
|
+
dryRun: true,
|
|
4283
|
+
nodeCount: orderedNodes.length,
|
|
4284
|
+
order: ordering.order,
|
|
4285
|
+
orderingRationale: ordering.rationale,
|
|
4286
|
+
changeAreas: ordering.changeAreas,
|
|
4287
|
+
plan: orderedNodes.map(node => ({
|
|
4288
|
+
nodeId: node.id,
|
|
4289
|
+
workspace: node.workspace,
|
|
4290
|
+
validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
|
|
4291
|
+
mergeWillRun: false,
|
|
4292
|
+
})),
|
|
4293
|
+
note: 'Dry-run: no validation, rebase, or merge was executed. Re-run with execute=true to converge nodes in this order.',
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4296
|
+
|
|
4297
|
+
// Execute: refine each node in order. The per-node refine pipeline fetches
|
|
4298
|
+
// origin/<base> fresh, so each merged sibling advances the base before the
|
|
4299
|
+
// next node's auto-rebase + patch-equivalence re-check. A blocked/failed node
|
|
4300
|
+
// is isolated; the batch continues with the remaining nodes.
|
|
4301
|
+
type BatchNodeOutcome = {
|
|
4302
|
+
nodeId: string;
|
|
4303
|
+
workspace: string;
|
|
4304
|
+
convergence: 'merged_to_main' | 'blocked_review' | 'skipped_patch_equivalent' | 'not_mergeable';
|
|
4305
|
+
code?: string;
|
|
4306
|
+
reason?: string;
|
|
4307
|
+
stage?: string;
|
|
4308
|
+
error?: string;
|
|
4309
|
+
finalBranchConvergenceState?: Record<string, unknown>;
|
|
4310
|
+
};
|
|
4311
|
+
const results: BatchNodeOutcome[] = [];
|
|
4312
|
+
for (const node of orderedNodes) {
|
|
4313
|
+
let result: Record<string, unknown>;
|
|
4314
|
+
try {
|
|
4315
|
+
result = await this.executeMeshRefineNodeSynchronously(meshId, node.id, args) as Record<string, unknown>;
|
|
4316
|
+
} catch (e: any) {
|
|
4317
|
+
result = { success: false, error: e?.message || String(e) };
|
|
4318
|
+
}
|
|
4319
|
+
const code = typeof result.code === 'string' ? result.code : '';
|
|
4320
|
+
// already_merged (branch content already on base via another path) is a
|
|
4321
|
+
// non-error skip regardless of success flag — the worktree converges with
|
|
4322
|
+
// no new merge. A real `git merge` conflict surfaces as merge_failed →
|
|
4323
|
+
// not_mergeable. Everything else that failed is isolated as blocked_review.
|
|
4324
|
+
let convergence: BatchNodeOutcome['convergence'];
|
|
4325
|
+
if (code === 'already_merged' && result.alreadyMergedViaOtherPath) {
|
|
4326
|
+
convergence = 'skipped_patch_equivalent';
|
|
4327
|
+
} else if (result.success === true) {
|
|
4328
|
+
convergence = 'merged_to_main';
|
|
4329
|
+
} else if (code === 'merge_failed') {
|
|
4330
|
+
convergence = 'not_mergeable';
|
|
4331
|
+
} else {
|
|
4332
|
+
convergence = 'blocked_review';
|
|
4333
|
+
}
|
|
4334
|
+
const fbcs = (result.finalBranchConvergenceState && typeof result.finalBranchConvergenceState === 'object')
|
|
4335
|
+
? result.finalBranchConvergenceState as Record<string, unknown>
|
|
4336
|
+
: undefined;
|
|
4337
|
+
const stage = Array.isArray(result.refineStages)
|
|
4338
|
+
? (result.refineStages as Array<Record<string, unknown>>).filter(s => s.status === 'failed').map(s => s.stage).filter(Boolean).pop() as string | undefined
|
|
4339
|
+
: undefined;
|
|
4340
|
+
results.push({
|
|
4341
|
+
nodeId: node.id,
|
|
4342
|
+
workspace: node.workspace,
|
|
4343
|
+
convergence,
|
|
4344
|
+
...(code ? { code } : {}),
|
|
4345
|
+
...(typeof result.blockedReason === 'string' ? { reason: result.blockedReason } : {}),
|
|
4346
|
+
...(stage ? { stage } : {}),
|
|
4347
|
+
...(typeof result.error === 'string' ? { error: result.error } : {}),
|
|
4348
|
+
...(fbcs ? { finalBranchConvergenceState: fbcs } : {}),
|
|
4349
|
+
});
|
|
4350
|
+
}
|
|
4351
|
+
|
|
4352
|
+
const summary = {
|
|
4353
|
+
merged: results.filter(r => r.convergence === 'merged_to_main').length,
|
|
4354
|
+
skipped: results.filter(r => r.convergence === 'skipped_patch_equivalent').length,
|
|
4355
|
+
blocked: results.filter(r => r.convergence === 'blocked_review').length,
|
|
4356
|
+
notMergeable: results.filter(r => r.convergence === 'not_mergeable').length,
|
|
4357
|
+
};
|
|
4358
|
+
const allConverged = summary.blocked === 0 && summary.notMergeable === 0;
|
|
4359
|
+
return {
|
|
4360
|
+
success: true,
|
|
4361
|
+
batch: true,
|
|
4362
|
+
dryRun: false,
|
|
4363
|
+
nodeCount: orderedNodes.length,
|
|
4364
|
+
order: ordering.order,
|
|
4365
|
+
orderingRationale: ordering.rationale,
|
|
4366
|
+
summary,
|
|
4367
|
+
allConverged,
|
|
4368
|
+
results,
|
|
4369
|
+
...(allConverged ? {} : {
|
|
4370
|
+
nextStep: 'Resolve blocked_review / not_mergeable nodes manually (see per-node code/stage/error), then re-run mesh_refine_batch for the remaining nodes.',
|
|
4371
|
+
}),
|
|
4372
|
+
};
|
|
4373
|
+
}
|
|
4374
|
+
|
|
4129
4375
|
private async finishMeshRefineJob(handle: MeshRefineJobHandle, args: any): Promise<void> {
|
|
4130
4376
|
const key = this.buildRefineJobKey(handle.meshId, handle.targetNodeId);
|
|
4131
4377
|
let result: Record<string, unknown>;
|
|
@@ -5944,6 +6190,15 @@ export class DaemonCommandRouter {
|
|
|
5944
6190
|
return this.startMeshRefineJob(meshId, nodeId, args);
|
|
5945
6191
|
}
|
|
5946
6192
|
|
|
6193
|
+
case 'batch_refine_mesh_nodes': {
|
|
6194
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
6195
|
+
if (!meshId) return { success: false, error: 'meshId required' };
|
|
6196
|
+
const requestedNodeIds = Array.isArray(args?.nodeIds)
|
|
6197
|
+
? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
|
|
6198
|
+
: undefined;
|
|
6199
|
+
return this.batchRefineMeshNodes(meshId, requestedNodeIds, args);
|
|
6200
|
+
}
|
|
6201
|
+
|
|
5947
6202
|
case 'remove_mesh_node': {
|
|
5948
6203
|
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
5949
6204
|
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
package/src/index.ts
CHANGED
|
@@ -217,12 +217,12 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
|
|
|
217
217
|
export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
|
|
218
218
|
|
|
219
219
|
// ── Mesh Work Queue (GUPP) ──
|
|
220
|
-
export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
220
|
+
export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
|
|
221
221
|
export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
|
|
222
222
|
export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
|
|
223
223
|
export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
|
|
224
|
-
export { buildMeshAsyncRefineJobs } from './mesh/mesh-refine-status.js';
|
|
225
|
-
export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary } from './mesh/mesh-refine-status.js';
|
|
224
|
+
export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
|
|
225
|
+
export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
|
|
226
226
|
|
|
227
227
|
// ── Mesh Host Ownership ──
|
|
228
228
|
export { buildMeshHostRequiredFailure, createDefaultMeshHostMetadata, isMeshHostOwner, normalizeMeshDaemonRole, requireMeshHostQueueOwner, resolveMeshHostStatus } from './mesh/mesh-host-ownership.js';
|