@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.259",
3
+ "version": "0.9.82-rc.260",
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",
@@ -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
- if (inCooldown && currentModalMessage === this.lastResolvedModalMessage) return;
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
- if (!inCooldown) {
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<{
@@ -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';