@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.
- package/dist/commands/router.d.ts +1 -0
- package/dist/git/git-worktree.d.ts +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +930 -174
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +926 -175
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +1 -1
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +10 -0
- package/dist/mesh/p2p-relay-failure.d.ts +35 -0
- package/package.json +1 -1
- package/src/commands/router.ts +446 -11
- package/src/git/git-worktree.ts +35 -1
- package/src/index.d.ts +3 -0
- package/src/index.ts +14 -0
- package/src/mesh/mesh-events.ts +292 -5
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-work-queue.ts +27 -0
- package/src/mesh/p2p-relay-failure.ts +152 -0
|
@@ -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
package/src/commands/router.ts
CHANGED
|
@@ -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, {
|
|
502
|
-
|
|
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
|
|
509
|
-
|
|
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
|
-
:
|
|
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 {
|
|
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 {
|
|
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 */ }
|
package/src/git/git-worktree.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|