@adhdev/daemon-core 0.9.77-rc.44 → 0.9.77-rc.45

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.
@@ -13,7 +13,7 @@ export declare function tryAssignQueueTask(components: DaemonComponents, meshId:
13
13
  * Triggers a queue check for all nodes in the mesh.
14
14
  * Called when a new task is enqueued, in case nodes are already idle.
15
15
  */
16
- export declare function triggerMeshQueue(components: DaemonComponents, meshId: string): void;
16
+ export declare function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<void>;
17
17
  export declare function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>): {
18
18
  success: boolean;
19
19
  forwarded: number;
@@ -13,7 +13,7 @@
13
13
  * Safety: mode 0o600, atomic append via appendFileSync
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'session_launched' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_removed' | 'coordinator_started' | 'recovery_attempted';
16
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_removed' | 'coordinator_started' | 'recovery_attempted';
17
17
  export interface MeshLedgerEntry {
18
18
  id: string;
19
19
  meshId: string;
@@ -19,6 +19,15 @@ export interface MeshWorkQueueEntry {
19
19
  requeueReason?: string;
20
20
  requeuedAt?: string;
21
21
  requeueCount?: number;
22
+ /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
23
+ autoLaunch?: {
24
+ status: 'skipped' | 'started' | 'failed' | 'completed';
25
+ reason?: string;
26
+ nodeId?: string;
27
+ providerType?: string;
28
+ sessionId?: string;
29
+ updatedAt: string;
30
+ };
22
31
  createdAt: string;
23
32
  updatedAt: string;
24
33
  }
@@ -44,6 +53,7 @@ export declare function claimNextTask(meshId: string, nodeId: string, sessionId:
44
53
  * Used when a session completes, fails, or stalls.
45
54
  */
46
55
  export declare function updateTaskStatus(meshId: string, taskId: string, status: MeshTaskStatus): MeshWorkQueueEntry | null;
56
+ export declare function recordTaskAutoLaunch(meshId: string, taskId: string, autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>): MeshWorkQueueEntry | null;
47
57
  /**
48
58
  * Mark a queue task as manually cancelled without deleting audit history.
49
59
  */
@@ -0,0 +1,35 @@
1
+ export type P2pRelayFailureCode = 'p2p_unavailable' | 'p2p_timeout' | 'p2p_not_connected' | 'p2p_datachannel_closed' | 'p2p_no_route' | 'p2p_daemon_offline' | 'mesh_logic_or_provider_failure';
2
+ export interface P2pRelayFailureContext {
3
+ command?: string;
4
+ targetDaemonId?: string;
5
+ }
6
+ export interface P2pRelayFailureClassification {
7
+ code: P2pRelayFailureCode;
8
+ reason: string;
9
+ transport: 'p2p' | 'unknown';
10
+ recoverable: boolean;
11
+ retryRecommended: boolean;
12
+ nextAction: string;
13
+ noFallbackReason: string;
14
+ }
15
+ export interface P2pRelayFailurePayload extends P2pRelayFailureClassification {
16
+ success: false;
17
+ error: string;
18
+ command?: string;
19
+ targetDaemonId?: string;
20
+ }
21
+ export declare function classifyP2pRelayFailure(error: unknown, _context?: P2pRelayFailureContext): P2pRelayFailureClassification;
22
+ export declare function isP2pRelayTransportFailure(error: unknown): boolean;
23
+ export declare function buildP2pRelayFailurePayload(error: unknown, context?: P2pRelayFailureContext): P2pRelayFailurePayload;
24
+ export declare class P2pRelayFailureError extends Error {
25
+ code: P2pRelayFailureCode;
26
+ reason: string;
27
+ transport: 'p2p' | 'unknown';
28
+ recoverable: boolean;
29
+ retryRecommended: boolean;
30
+ nextAction: string;
31
+ noFallbackReason: string;
32
+ command?: string;
33
+ targetDaemonId?: string;
34
+ constructor(message: string, context?: P2pRelayFailureContext);
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.77-rc.44",
3
+ "version": "0.9.77-rc.45",
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",
@@ -43,6 +43,7 @@ import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDa
43
43
  import type { RepoMeshSessionCleanupMode } from '../repo-mesh-types.js';
44
44
  import { homedir } from 'os';
45
45
  import { join as pathJoin, resolve as pathResolve } from 'path';
46
+ import * as fs from 'fs';
46
47
 
47
48
  type ReleaseChannel = 'stable' | 'preview';
48
49
  const CHANNEL_NPM_TAG: Record<ReleaseChannel, 'latest' | 'next'> = { stable: 'latest', preview: 'next' };
@@ -114,9 +115,271 @@ async function resolveProviderTypeFromPriority(args: {
114
115
 
115
116
  return { error: `No usable provider detected for node '${args.nodeId}' from providerPriority: ${failed.join('; ')}` };
116
117
  }
117
- import * as fs from 'fs';
118
-
119
118
  type MeshCoordinatorConfigFormat = 'claude_mcp_json' | 'hermes_config_yaml';
119
+ type MeshRefineValidationStatus = 'passed' | 'failed' | 'skipped';
120
+ type MeshRefineValidationCommand = {
121
+ command: string;
122
+ args: string[];
123
+ displayCommand: string;
124
+ category: string;
125
+ source: string;
126
+ };
127
+
128
+ type MeshRefineValidationSummary = {
129
+ status: MeshRefineValidationStatus;
130
+ required: true;
131
+ commandsRun: Array<Record<string, unknown>>;
132
+ rejectedCommands: Array<Record<string, unknown>>;
133
+ skippedReason?: string;
134
+ timeoutMs: number;
135
+ outputLimitBytes: number;
136
+ };
137
+
138
+ const REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
139
+ const REFINE_VALIDATION_TIMEOUT_MS = 120_000;
140
+ const REFINE_VALIDATION_OUTPUT_LIMIT_BYTES = 128 * 1024;
141
+ const REFINE_VALIDATION_SUMMARY_CHARS = 2_000;
142
+ const REFINE_VALIDATION_MAX_COMMANDS = 4;
143
+
144
+ function truncateValidationOutput(value: unknown): string {
145
+ const text = typeof value === 'string' ? value : value == null ? '' : String(value);
146
+ if (text.length <= REFINE_VALIDATION_SUMMARY_CHARS) return text;
147
+ return `${text.slice(0, REFINE_VALIDATION_SUMMARY_CHARS)}\n[truncated ${text.length - REFINE_VALIDATION_SUMMARY_CHARS} chars]`;
148
+ }
149
+
150
+ function readPackageScripts(workspace: string): Record<string, string> {
151
+ try {
152
+ const packageJsonPath = pathJoin(workspace, 'package.json');
153
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
154
+ return parsed?.scripts && typeof parsed.scripts === 'object' && !Array.isArray(parsed.scripts)
155
+ ? parsed.scripts as Record<string, string>
156
+ : {};
157
+ } catch {
158
+ return {};
159
+ }
160
+ }
161
+
162
+ function tokenizeValidationCommand(command: string): string[] | null {
163
+ const trimmed = command.trim();
164
+ if (!trimmed) return null;
165
+ // Fail closed: the gate never hands shell syntax to a shell. Package-manager
166
+ // scripts are invoked via execFile(binary, args), and metacharacters/quotes are
167
+ // rejected before tokenization so `npm run test && rm -rf` cannot be smuggled in.
168
+ if (/[;&|<>`$\\\n\r'\"]/.test(trimmed)) return null;
169
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
170
+ if (!tokens.length) return null;
171
+ if (tokens.some(token => !/^[A-Za-z0-9_@./:=+-]+$/.test(token))) return null;
172
+ return tokens;
173
+ }
174
+
175
+ function scriptMatchesValidationCategory(scriptName: string, category: string): boolean {
176
+ return scriptName === category || scriptName.startsWith(`${category}:`);
177
+ }
178
+
179
+ function parsePackageManagerValidationCommand(
180
+ rawCommand: string,
181
+ category: string,
182
+ scripts: Record<string, string>,
183
+ source: string,
184
+ ): { command?: MeshRefineValidationCommand; rejected?: Record<string, unknown> } {
185
+ const tokens = tokenizeValidationCommand(rawCommand);
186
+ if (!tokens) {
187
+ return { rejected: { command: rawCommand, category, source, reason: 'unsafe command string is not allowlisted' } };
188
+ }
189
+
190
+ const [binary, second, third, ...rest] = tokens;
191
+ let scriptName = '';
192
+ let command = binary;
193
+ let args: string[] = [];
194
+
195
+ if ((binary === 'npm' || binary === 'pnpm' || binary === 'bun') && second === 'run' && third) {
196
+ scriptName = third;
197
+ args = ['run', scriptName, ...rest];
198
+ } else if (binary === 'npm' && second === 'test' && !third) {
199
+ scriptName = 'test';
200
+ args = ['test'];
201
+ } else if (binary === 'yarn' && second === 'run' && third) {
202
+ scriptName = third;
203
+ args = ['run', scriptName, ...rest];
204
+ } else if (binary === 'yarn' && second && !third) {
205
+ scriptName = second;
206
+ args = [scriptName];
207
+ } else {
208
+ return { rejected: { command: rawCommand, category, source, reason: 'command is not a supported package-manager script invocation' } };
209
+ }
210
+
211
+ if (!scriptName || !Object.prototype.hasOwnProperty.call(scripts, scriptName)) {
212
+ return { rejected: { command: rawCommand, category, source, script: scriptName, reason: 'script is not declared in package.json' } };
213
+ }
214
+ if (!scriptMatchesValidationCategory(scriptName, category)) {
215
+ return { rejected: { command: rawCommand, category, source, script: scriptName, reason: 'script name is outside the validation category allowlist' } };
216
+ }
217
+
218
+ return {
219
+ command: {
220
+ command,
221
+ args,
222
+ displayCommand: [command, ...args].join(' '),
223
+ category,
224
+ source,
225
+ },
226
+ };
227
+ }
228
+
229
+ function collectProjectContextValidationCandidates(mesh: any): Array<{ command: string; category: string; source: string; confidence?: string }> {
230
+ const commands = mesh?.projectContext?.commands;
231
+ if (!commands || typeof commands !== 'object' || Array.isArray(commands)) return [];
232
+ const candidates: Array<{ command: string; category: string; source: string; confidence?: string }> = [];
233
+ for (const category of REFINE_VALIDATION_CATEGORIES) {
234
+ const entries = Array.isArray(commands[category]) ? commands[category] : [];
235
+ for (const entry of entries) {
236
+ if (typeof entry?.command !== 'string') continue;
237
+ candidates.push({
238
+ command: entry.command,
239
+ category,
240
+ source: typeof entry.sourcePath === 'string' ? entry.sourcePath : 'projectContext.commands',
241
+ confidence: typeof entry.confidence === 'string' ? entry.confidence : undefined,
242
+ });
243
+ }
244
+ }
245
+ return candidates.sort((a, b) => {
246
+ const rank = (value?: string) => value === 'high' ? 0 : value === 'medium' ? 1 : 2;
247
+ return rank(a.confidence) - rank(b.confidence);
248
+ });
249
+ }
250
+
251
+ function collectPolicyValidationCandidates(mesh: any): Array<{ command: string; category: string; source: string }> {
252
+ const policy = mesh?.policy && typeof mesh.policy === 'object' && !Array.isArray(mesh.policy) ? mesh.policy : {};
253
+ const configured = Array.isArray(policy.validationCommands)
254
+ ? policy.validationCommands
255
+ : Array.isArray(policy.validationGate?.commands)
256
+ ? policy.validationGate.commands
257
+ : [];
258
+ return configured
259
+ .map((entry: any) => typeof entry === 'string' ? { command: entry, category: '', source: 'mesh.policy.validationCommands' } : entry)
260
+ .filter((entry: any) => entry && typeof entry.command === 'string')
261
+ .map((entry: any) => {
262
+ const commandText = entry.command.trim();
263
+ const category = REFINE_VALIDATION_CATEGORIES.find(cat => commandText.includes(` ${cat}`)) ?? '';
264
+ return { command: commandText, category, source: 'mesh.policy.validationCommands' };
265
+ })
266
+ .filter((entry: any) => !!entry.category);
267
+ }
268
+
269
+ function selectMeshRefineValidationCommands(mesh: any, workspace: string): { commands: MeshRefineValidationCommand[]; rejectedCommands: Array<Record<string, unknown>>; source: string } {
270
+ const scripts = readPackageScripts(workspace);
271
+ const rejectedCommands: Array<Record<string, unknown>> = [];
272
+ const selected: MeshRefineValidationCommand[] = [];
273
+ const seen = new Set<string>();
274
+ const candidates = [
275
+ ...collectPolicyValidationCandidates(mesh),
276
+ ...collectProjectContextValidationCandidates(mesh),
277
+ ];
278
+
279
+ for (const candidate of candidates) {
280
+ const parsed = parsePackageManagerValidationCommand(candidate.command, candidate.category, scripts, candidate.source);
281
+ if (parsed.rejected) {
282
+ rejectedCommands.push(parsed.rejected);
283
+ continue;
284
+ }
285
+ if (!parsed.command || seen.has(parsed.command.displayCommand)) continue;
286
+ selected.push(parsed.command);
287
+ seen.add(parsed.command.displayCommand);
288
+ if (selected.length >= REFINE_VALIDATION_MAX_COMMANDS) break;
289
+ }
290
+
291
+ if (!selected.length && candidates.length === 0) {
292
+ for (const category of REFINE_VALIDATION_CATEGORIES) {
293
+ if (!Object.prototype.hasOwnProperty.call(scripts, category)) continue;
294
+ const fallback = parsePackageManagerValidationCommand(`npm run ${category}`, category, scripts, 'package.json:scripts');
295
+ if (fallback.command && !seen.has(fallback.command.displayCommand)) {
296
+ selected.push(fallback.command);
297
+ seen.add(fallback.command.displayCommand);
298
+ } else if (fallback.rejected) {
299
+ rejectedCommands.push(fallback.rejected);
300
+ }
301
+ if (selected.length >= 2) break;
302
+ }
303
+ }
304
+
305
+ return {
306
+ commands: selected,
307
+ rejectedCommands,
308
+ source: selected.some(command => command.source === 'mesh.policy.validationCommands')
309
+ ? 'mesh_policy'
310
+ : selected.some(command => command.source !== 'package.json:scripts')
311
+ ? 'project_context'
312
+ : selected.length
313
+ ? 'package_json_scripts'
314
+ : 'unavailable',
315
+ };
316
+ }
317
+
318
+ async function runMeshRefineValidationGate(mesh: any, workspace: string): Promise<MeshRefineValidationSummary> {
319
+ const { execFile } = await import('node:child_process');
320
+ const { promisify } = await import('node:util');
321
+ const execFileAsync = promisify(execFile);
322
+ const selection = selectMeshRefineValidationCommands(mesh, workspace);
323
+ const summary: MeshRefineValidationSummary = {
324
+ status: 'skipped',
325
+ required: true,
326
+ commandsRun: [],
327
+ rejectedCommands: selection.rejectedCommands,
328
+ skippedReason: undefined,
329
+ timeoutMs: REFINE_VALIDATION_TIMEOUT_MS,
330
+ outputLimitBytes: REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
331
+ };
332
+
333
+ if (!selection.commands.length) {
334
+ summary.skippedReason = 'validation_unavailable: no allowlisted projectContext, mesh policy, or package.json build/test/typecheck/lint command was available';
335
+ return summary;
336
+ }
337
+
338
+ for (const candidate of selection.commands) {
339
+ const startedAt = Date.now();
340
+ try {
341
+ const result = await execFileAsync(candidate.command, candidate.args, {
342
+ cwd: workspace,
343
+ encoding: 'utf8',
344
+ timeout: REFINE_VALIDATION_TIMEOUT_MS,
345
+ maxBuffer: REFINE_VALIDATION_OUTPUT_LIMIT_BYTES,
346
+ env: { ...process.env, CI: process.env.CI || '1' },
347
+ });
348
+ summary.commandsRun.push({
349
+ command: candidate.command,
350
+ args: candidate.args,
351
+ displayCommand: candidate.displayCommand,
352
+ category: candidate.category,
353
+ source: candidate.source,
354
+ passed: true,
355
+ exitCode: 0,
356
+ durationMs: Date.now() - startedAt,
357
+ stdout: truncateValidationOutput(result.stdout),
358
+ stderr: truncateValidationOutput(result.stderr),
359
+ });
360
+ } catch (error: any) {
361
+ summary.commandsRun.push({
362
+ command: candidate.command,
363
+ args: candidate.args,
364
+ displayCommand: candidate.displayCommand,
365
+ category: candidate.category,
366
+ source: candidate.source,
367
+ passed: false,
368
+ exitCode: typeof error?.code === 'number' ? error.code : null,
369
+ signal: typeof error?.signal === 'string' ? error.signal : null,
370
+ timedOut: error?.killed === true || /timed out/i.test(String(error?.message || '')),
371
+ durationMs: Date.now() - startedAt,
372
+ stdout: truncateValidationOutput(error?.stdout),
373
+ stderr: truncateValidationOutput(error?.stderr || error?.message),
374
+ });
375
+ summary.status = 'failed';
376
+ return summary;
377
+ }
378
+ }
379
+
380
+ summary.status = 'passed';
381
+ return summary;
382
+ }
120
383
 
121
384
  function loadYamlModule(): { load: (input: string) => any; dump: (input: any, options?: Record<string, any>) => string } {
122
385
  return yaml as { load: (input: string) => any; dump: (input: any, options?: Record<string, any>) => string };
@@ -421,7 +684,7 @@ export class DaemonCommandRouter {
421
684
  mesh: any;
422
685
  node: any;
423
686
  nodeId: string;
424
- }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string } | { success: false; code: string; error: string; recoveryHint: string }> {
687
+ }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown> } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
425
688
  const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
426
689
  if (!workspace) {
427
690
  return {
@@ -497,23 +760,124 @@ export class DaemonCommandRouter {
497
760
  };
498
761
  }
499
762
 
763
+ const forceFallbackConvergence = await this.getWorktreeForceCleanupConvergence({
764
+ repoRoot,
765
+ workspace,
766
+ node: args.node,
767
+ });
768
+
500
769
  try {
501
- const result = await removeWorktree(repoRoot, workspace, { requireClean: true });
502
- return { success: true, removedPath: result.removedPath, repoRoot };
770
+ const result = await removeWorktree(repoRoot, workspace, {
771
+ requireClean: true,
772
+ allowSubmoduleForceFallback: forceFallbackConvergence.allow,
773
+ });
774
+ return {
775
+ success: true,
776
+ removedPath: result.removedPath,
777
+ repoRoot,
778
+ ...(result.fallback ? {
779
+ fallback: result.fallback,
780
+ forced: result.forced,
781
+ reason: result.reason,
782
+ convergence: forceFallbackConvergence,
783
+ } : {}),
784
+ };
503
785
  } catch (e: any) {
504
786
  const message = String(e?.message || e || 'worktree cleanup failed');
505
787
  const dirty = message.includes('dirty worktree') || message.includes('local changes');
788
+ const submoduleForceBlocked = /working trees containing submodules cannot be moved or removed/i.test(message) && !forceFallbackConvergence.allow;
506
789
  return {
507
790
  success: false,
508
- code: dirty ? 'mesh_worktree_cleanup_dirty' : 'mesh_worktree_cleanup_failed',
509
- error: message,
791
+ code: dirty
792
+ ? 'mesh_worktree_cleanup_dirty'
793
+ : submoduleForceBlocked
794
+ ? 'mesh_worktree_cleanup_force_fallback_blocked'
795
+ : 'mesh_worktree_cleanup_failed',
796
+ error: submoduleForceBlocked
797
+ ? `${message}; refusing --force fallback because convergence could not be verified: ${forceFallbackConvergence.error || 'unknown convergence state'}`
798
+ : message,
510
799
  recoveryHint: dirty
511
800
  ? 'Commit, stash, or intentionally discard the worktree changes before retrying mesh_remove_node. The mesh registry entry is preserved until cleanup is safe.'
512
- : 'Inspect git worktree status/list from the source repo and retry after resolving the reported cleanup failure.',
801
+ : submoduleForceBlocked
802
+ ? 'Verify the worktree branch is merged/contained in the source default branch (for example origin/main) or mark the node with a safe branchConvergence final state before retrying. The mesh registry entry is preserved.'
803
+ : 'Inspect git worktree status/list from the source repo and retry after resolving the reported cleanup failure.',
804
+ ...(submoduleForceBlocked ? { convergence: forceFallbackConvergence } : {}),
513
805
  };
514
806
  }
515
807
  }
516
808
 
809
+ private async getWorktreeForceCleanupConvergence(args: {
810
+ repoRoot: string;
811
+ workspace: string;
812
+ node: any;
813
+ }): Promise<{ allow: boolean; status?: string; source?: string; ref?: string; error?: string }> {
814
+ const metadataStatus = typeof args.node?.branchConvergence?.status === 'string'
815
+ ? args.node.branchConvergence.status
816
+ : '';
817
+ if (metadataStatus === 'merged_to_main' || metadataStatus === 'cleanup_candidate') {
818
+ return { allow: true, status: metadataStatus, source: 'node_branch_convergence' };
819
+ }
820
+
821
+ const { execFile } = await import('node:child_process');
822
+ const { promisify } = await import('node:util');
823
+ const execFileAsync = promisify(execFile);
824
+ const runGit = async (gitArgs: string[], cwd: string): Promise<string> => {
825
+ const { stdout } = await execFileAsync('git', gitArgs, {
826
+ cwd,
827
+ encoding: 'utf8',
828
+ timeout: 30_000,
829
+ maxBuffer: 4 * 1024 * 1024,
830
+ windowsHide: true,
831
+ });
832
+ return String(stdout || '').trim();
833
+ };
834
+
835
+ let head = '';
836
+ try {
837
+ head = await runGit(['rev-parse', 'HEAD'], args.workspace);
838
+ } catch (e: any) {
839
+ return { allow: false, error: `could not resolve worktree HEAD: ${e?.message || e}` };
840
+ }
841
+ if (!head) return { allow: false, error: 'worktree HEAD is empty' };
842
+
843
+ const candidateRefs: string[] = [];
844
+ try {
845
+ const defaultBranch = await runGit(['branch', '--show-current'], args.repoRoot);
846
+ if (defaultBranch) {
847
+ candidateRefs.push(defaultBranch, `origin/${defaultBranch}`);
848
+ }
849
+ } catch { /* fall through to common refs */ }
850
+ candidateRefs.push('origin/main', 'origin/master', 'main', 'master');
851
+
852
+ const seen = new Set<string>();
853
+ const checkedRefs: string[] = [];
854
+ for (const ref of candidateRefs) {
855
+ if (!ref || seen.has(ref)) continue;
856
+ seen.add(ref);
857
+ let commit = '';
858
+ try {
859
+ commit = await runGit(['rev-parse', '--verify', `${ref}^{commit}`], args.repoRoot);
860
+ } catch {
861
+ continue;
862
+ }
863
+ checkedRefs.push(ref);
864
+ try {
865
+ await runGit(['merge-base', '--is-ancestor', head, commit], args.repoRoot);
866
+ return { allow: true, status: 'merged_to_default_ref', source: 'git_merge_base', ref };
867
+ } catch {
868
+ // Not contained in this candidate ref; keep checking other safe refs.
869
+ }
870
+ }
871
+
872
+ return {
873
+ allow: false,
874
+ status: metadataStatus || undefined,
875
+ error: checkedRefs.length
876
+ ? `worktree HEAD is not contained in checked refs: ${checkedRefs.join(', ')}`
877
+ : 'no default/main refs were available for convergence verification',
878
+ };
879
+ }
880
+
517
881
  private isCompletedHostedSession(record: any): boolean {
518
882
  return record?.lifecycle === 'stopped' || record?.lifecycle === 'failed' || record?.lifecycle === 'interrupted';
519
883
  }
@@ -1617,10 +1981,62 @@ export class DaemonCommandRouter {
1617
1981
  const { stdout: baseBranchStdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' });
1618
1982
  const baseBranch = baseBranchStdout.trim();
1619
1983
 
1984
+ const validationSummary = await runMeshRefineValidationGate(mesh, node.workspace);
1985
+ if (validationSummary.status === 'failed') {
1986
+ return {
1987
+ success: false,
1988
+ code: 'validation_failed',
1989
+ convergenceStatus: 'blocked_review',
1990
+ error: 'Refinery validation gate failed; merge/refine was not attempted.',
1991
+ branch,
1992
+ into: baseBranch,
1993
+ validationSummary,
1994
+ finalBranchConvergenceState: {
1995
+ branch,
1996
+ baseBranch,
1997
+ merged: false,
1998
+ removed: false,
1999
+ validation: 'failed',
2000
+ status: 'blocked_review',
2001
+ },
2002
+ };
2003
+ }
2004
+ if (validationSummary.status === 'skipped') {
2005
+ return {
2006
+ success: false,
2007
+ code: 'validation_unavailable',
2008
+ convergenceStatus: 'blocked_review',
2009
+ error: 'Refinery validation gate is required but no allowlisted validation command was available; merge/refine was not attempted.',
2010
+ branch,
2011
+ into: baseBranch,
2012
+ validationSummary,
2013
+ finalBranchConvergenceState: {
2014
+ branch,
2015
+ baseBranch,
2016
+ merged: false,
2017
+ removed: false,
2018
+ validation: 'unavailable',
2019
+ status: 'blocked_review',
2020
+ },
2021
+ };
2022
+ }
2023
+
1620
2024
  try {
1621
2025
  await execFileAsync('git', ['merge', '--no-ff', branch, '-m', `Auto-merge branch '${branch}' via Refinery`], { cwd: repoRoot, encoding: 'utf8' });
1622
2026
  } catch (e: any) {
1623
- return { success: false, error: `Merge failed (conflicts?): ${e.message}` };
2027
+ return {
2028
+ success: false,
2029
+ error: `Merge failed (conflicts?): ${e.message}`,
2030
+ validationSummary,
2031
+ finalBranchConvergenceState: {
2032
+ branch,
2033
+ baseBranch,
2034
+ merged: false,
2035
+ removed: false,
2036
+ validation: 'passed',
2037
+ status: 'not_mergeable',
2038
+ },
2039
+ };
1624
2040
  }
1625
2041
 
1626
2042
  const removeResult = await this.execute('remove_mesh_node', {
@@ -1635,11 +2051,27 @@ export class DaemonCommandRouter {
1635
2051
  appendLedgerEntry(meshId, {
1636
2052
  kind: 'node_removed',
1637
2053
  nodeId,
1638
- payload: { refined: true, mergedBranch: branch, into: baseBranch },
2054
+ payload: { refined: true, mergedBranch: branch, into: baseBranch, validationSummary },
1639
2055
  });
1640
2056
  } catch {}
1641
2057
 
1642
- return { success: true, merged: true, branch, into: baseBranch, removeResult };
2058
+ return {
2059
+ success: true,
2060
+ merged: true,
2061
+ branch,
2062
+ into: baseBranch,
2063
+ removeResult,
2064
+ validationSummary,
2065
+ finalBranchConvergenceState: {
2066
+ branch: baseBranch,
2067
+ mergedBranch: branch,
2068
+ baseBranch,
2069
+ merged: true,
2070
+ removed: removeResult?.success !== false,
2071
+ validation: 'passed',
2072
+ status: removeResult?.success === false ? 'merged_cleanup_failed' : 'merged',
2073
+ },
2074
+ };
1643
2075
  } catch (e: any) {
1644
2076
  return { success: false, error: e.message };
1645
2077
  }
@@ -1701,6 +2133,9 @@ export class DaemonCommandRouter {
1701
2133
  workspace: typeof node?.workspace === 'string' ? node.workspace : undefined,
1702
2134
  daemonId: typeof node?.daemonId === 'string' ? node.daemonId : undefined,
1703
2135
  worktreeBranch: typeof node?.worktreeBranch === 'string' ? node.worktreeBranch : undefined,
2136
+ worktreeCleanupFallback: typeof worktreeCleanup?.fallback === 'string' ? worktreeCleanup.fallback : undefined,
2137
+ forced: worktreeCleanup?.forced === true ? true : undefined,
2138
+ forceFallbackReason: typeof worktreeCleanup?.reason === 'string' ? worktreeCleanup.reason : undefined,
1704
2139
  },
1705
2140
  });
1706
2141
  } catch { /* ledger append is best-effort */ }
@@ -20,6 +20,7 @@ const execFileAsync = promisify(execFile);
20
20
  const WORKTREE_DIR_NAME = '.adhdev-worktrees';
21
21
  const GIT_TIMEOUT_MS = 30_000;
22
22
  const GIT_MAX_BUFFER = 4 * 1024 * 1024;
23
+ const SUBMODULE_WORKTREE_REMOVE_RE = /working trees containing submodules cannot be moved or removed/i;
23
24
 
24
25
  // ─── Types ──────────────────────────────────────
25
26
 
@@ -52,11 +53,20 @@ export interface WorktreeEntry {
52
53
  export interface WorktreeRemoveOptions {
53
54
  /** Refuse to remove a worktree with uncommitted or untracked changes. */
54
55
  requireClean?: boolean;
56
+ /**
57
+ * If normal removal fails with Git's submodule-worktree guard, retry with
58
+ * `git worktree remove --force`. Callers must perform their own
59
+ * higher-level managed-path/convergence checks before enabling this.
60
+ */
61
+ allowSubmoduleForceFallback?: boolean;
55
62
  }
56
63
 
57
64
  export interface WorktreeRemoveResult {
58
65
  success: true;
59
66
  removedPath: string;
67
+ fallback?: 'git_worktree_remove_force_submodule';
68
+ forced?: boolean;
69
+ reason?: 'working_trees_containing_submodules';
60
70
  }
61
71
 
62
72
  // ─── Path Resolution ────────────────────────────
@@ -157,7 +167,31 @@ export async function removeWorktree(repoRoot: string, worktreePath: string, opt
157
167
  });
158
168
  } catch (error: any) {
159
169
  const stderr = typeof error.stderr === 'string' ? error.stderr : '';
160
- throw new Error(`git worktree remove failed: ${stderr.trim() || error.message}`);
170
+ const stdout = typeof error.stdout === 'string' ? error.stdout : '';
171
+ const detail = `${stderr}\n${stdout}\n${error.message || ''}`;
172
+ if (opts.allowSubmoduleForceFallback && SUBMODULE_WORKTREE_REMOVE_RE.test(detail)) {
173
+ try {
174
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], {
175
+ cwd: repoRoot,
176
+ encoding: 'utf8',
177
+ timeout: GIT_TIMEOUT_MS,
178
+ maxBuffer: GIT_MAX_BUFFER,
179
+ windowsHide: true,
180
+ });
181
+ } catch (forceError: any) {
182
+ const forceStderr = typeof forceError.stderr === 'string' ? forceError.stderr : '';
183
+ const forceStdout = typeof forceError.stdout === 'string' ? forceError.stdout : '';
184
+ throw new Error(`git worktree remove --force fallback failed: ${forceStderr.trim() || forceStdout.trim() || forceError.message}`);
185
+ }
186
+ return {
187
+ success: true,
188
+ removedPath: worktreePath,
189
+ fallback: 'git_worktree_remove_force_submodule',
190
+ forced: true,
191
+ reason: 'working_trees_containing_submodules',
192
+ };
193
+ }
194
+ throw new Error(`git worktree remove failed: ${stderr.trim() || stdout.trim() || error.message}`);
161
195
  }
162
196
 
163
197
  return { success: true, removedPath: worktreePath };