@yemi33/minions 0.1.2382 → 0.1.2384
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/bin/minions.js +1 -0
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +37 -19
- package/docs/completion-reports.md +20 -1
- package/docs/keep-processes.md +7 -2
- package/docs/live-checkout-mode.md +7 -7
- package/docs/managed-spawn.md +14 -1
- package/docs/runtime-adapters.md +61 -26
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +36 -0
- package/engine/acp-transport.js +273 -58
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +278 -54
- package/engine/cc-worker-pool.js +12 -3
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +56 -28
- package/engine/comment-format.js +51 -14
- package/engine/create-pr-worktree.js +57 -79
- package/engine/dispatch.js +7 -2
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +126 -45
- package/engine/live-checkout.js +4 -2
- package/engine/llm.js +59 -83
- package/engine/managed-spawn.js +112 -5
- package/engine/memory-store.js +3 -1
- package/engine/playbook.js +4 -6
- package/engine/pooled-agent-process.js +512 -45
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/claude.js +94 -25
- package/engine/runtimes/codex.js +1 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +97 -9
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +349 -82
- package/engine/spawn-agent.js +85 -116
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +418 -241
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/spawn-agent.js
CHANGED
|
@@ -32,12 +32,18 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
const fs = require('fs');
|
|
35
|
-
const os = require('os');
|
|
36
35
|
const path = require('path');
|
|
37
36
|
const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, readFileNoFollow } = require('./shared');
|
|
38
|
-
const {
|
|
37
|
+
const {
|
|
38
|
+
resolveRuntime,
|
|
39
|
+
RUNTIME_OPTIONS_FLAG,
|
|
40
|
+
RUNTIME_IMAGES_FILE_OPTION,
|
|
41
|
+
decodeRuntimeOptions,
|
|
42
|
+
prepareWorkspace: prepareRuntimeWorkspace,
|
|
43
|
+
} = require('./runtimes');
|
|
39
44
|
const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
|
|
40
45
|
const keepProcessSweep = require('./keep-process-sweep');
|
|
46
|
+
const MAX_RUNTIME_IMAGES_FILE_BYTES = 32 * 1024 * 1024;
|
|
41
47
|
|
|
42
48
|
// ─── Pure helpers (exported for tests) ──────────────────────────────────────
|
|
43
49
|
|
|
@@ -76,17 +82,7 @@ function parseSpawnArgs(argv) {
|
|
|
76
82
|
case '--disable-builtin-mcps': opts.disableBuiltinMcps = true; break;
|
|
77
83
|
case '--no-custom-instructions': opts.suppressAgentsMd = true; break;
|
|
78
84
|
case '--enable-reasoning-summaries': opts.reasoningSummaries = true; break;
|
|
79
|
-
case
|
|
80
|
-
const v = peek();
|
|
81
|
-
if (typeof v === 'string' && v) projectHarnessDirs.push(v);
|
|
82
|
-
break;
|
|
83
|
-
}
|
|
84
|
-
// P-49e1c8b7 — hermetic harness opt-out. When set, computeAddDirs in
|
|
85
|
-
// main() returns [minionsDir] only (user-scope asset dirs from
|
|
86
|
-
// runtime.getUserAssetDirs AND any forwarded --project-harness-dir
|
|
87
|
-
// entries are dropped). Engine.js gates this flag via
|
|
88
|
-
// shared.resolveAgentHermeticHarness(agent, engine).
|
|
89
|
-
case '--hermetic-harness': hermetic = true; break;
|
|
85
|
+
case RUNTIME_OPTIONS_FLAG: Object.assign(opts, decodeRuntimeOptions(peek())); break;
|
|
90
86
|
// LEGACY: dropped — the runtime adapter emits its own permission flag.
|
|
91
87
|
// Pre-P-2a6d9c4f engine.js still passes `--permission-mode bypassPermissions`;
|
|
92
88
|
// letting it through would duplicate the permission flag for Claude.
|
|
@@ -168,6 +164,41 @@ function injectAdoTokenEnvForRepoHost(env, opts) {
|
|
|
168
164
|
return injectAdoTokenEnv(env, opts);
|
|
169
165
|
}
|
|
170
166
|
|
|
167
|
+
function hydrateRuntimeOptionFiles(opts, {
|
|
168
|
+
promptFile,
|
|
169
|
+
readFile = readFileNoFollow,
|
|
170
|
+
lstatSync = fs.lstatSync,
|
|
171
|
+
unlinkSync = fs.unlinkSync,
|
|
172
|
+
} = {}) {
|
|
173
|
+
const imagesFile = opts?.[RUNTIME_IMAGES_FILE_OPTION];
|
|
174
|
+
if (!imagesFile) return opts;
|
|
175
|
+
if (typeof imagesFile !== 'string' || !promptFile) {
|
|
176
|
+
throw new Error('Invalid runtime images sidecar path');
|
|
177
|
+
}
|
|
178
|
+
const resolvedFile = path.resolve(imagesFile);
|
|
179
|
+
const optionsDir = path.resolve(path.dirname(promptFile));
|
|
180
|
+
if (path.dirname(resolvedFile) !== optionsDir) {
|
|
181
|
+
throw new Error('Runtime images sidecar must be beside the prompt file');
|
|
182
|
+
}
|
|
183
|
+
const stat = lstatSync(resolvedFile);
|
|
184
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_RUNTIME_IMAGES_FILE_BYTES) {
|
|
185
|
+
throw new Error('Invalid runtime images sidecar file');
|
|
186
|
+
}
|
|
187
|
+
let images;
|
|
188
|
+
try {
|
|
189
|
+
images = JSON.parse(readFile(resolvedFile, 'utf8'));
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw new Error(`Invalid runtime images sidecar payload: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
if (!Array.isArray(images)) {
|
|
194
|
+
throw new Error('Invalid runtime images sidecar payload: expected an array');
|
|
195
|
+
}
|
|
196
|
+
opts.images = images;
|
|
197
|
+
delete opts[RUNTIME_IMAGES_FILE_OPTION];
|
|
198
|
+
try { unlinkSync(resolvedFile); } catch {}
|
|
199
|
+
return opts;
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
function formatProcessExitSentinel(exitCode, signal) {
|
|
172
203
|
return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
|
|
173
204
|
}
|
|
@@ -307,105 +338,16 @@ function createParentPipeForwarder(stream, outputPath, prefix = '') {
|
|
|
307
338
|
}
|
|
308
339
|
|
|
309
340
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* silently suppresses → the agent runs without the workspace MCPs).
|
|
314
|
-
*
|
|
315
|
-
* Writes to `projects[worktreePath].enabledMcpjsonServers` (the field Claude
|
|
316
|
-
* Code actually reads — confirmed against the live `~/.claude.json` schema:
|
|
317
|
-
* `enabledMcpjsonServers` / `disabledMcpjsonServers` are siblings of
|
|
318
|
-
* `mcpServers`, indexed by absolute project path). Notes on the PRD wording
|
|
319
|
-
* "mcpServers.approved": that's the intent label; `enabledMcpjsonServers` is
|
|
320
|
-
* the on-disk reality.
|
|
321
|
-
*
|
|
322
|
-
* Pure-ish: all I/O (fs reads, mutator) is injected for tests. Best-effort —
|
|
323
|
-
* callers MUST swallow errors and never block dispatch on this.
|
|
324
|
-
*
|
|
325
|
-
* Returns `{ wrote: boolean, reason: string, servers: string[] }`:
|
|
326
|
-
* - reason ∈ { 'ok', 'flag-off', 'not-claude', 'no-worktree',
|
|
327
|
-
* 'no-workspace-mcp', 'invalid-workspace-mcp', 'no-servers' }
|
|
328
|
-
* - servers: keys discovered in the workspace `.mcp.json` (regardless of write)
|
|
329
|
-
*
|
|
330
|
-
* Args:
|
|
331
|
-
* runtimeName — `runtime.name` from `resolveRuntime(...)`. Helper no-ops if !== 'claude'.
|
|
332
|
-
* worktreePath — absolute path to the agent's cwd (the worktree). Used as both the file location
|
|
333
|
-
* (`<worktreePath>/.mcp.json`) and the `projects[...]` key in `~/.claude.json`.
|
|
334
|
-
* homeDir — `os.homedir()`. Injected for test isolation; helper does NOT call os.homedir directly.
|
|
335
|
-
* engineConfig — config.engine object; reads `claudePreApproveWorkspaceMcps` (default true via ENGINE_DEFAULTS).
|
|
336
|
-
* mutateJsonFileLocked — injected file-locked mutator (engine/shared.js#mutateJsonFileLocked).
|
|
337
|
-
* existsSync, readFileSync — injectable fs primitives (default to node:fs).
|
|
341
|
+
* Backward-compatible wrapper for the old helper name. Runtime-owned workspace
|
|
342
|
+
* preparation now lives behind the adapter contract; the spawn wrapper does
|
|
343
|
+
* not know which harness needs which config file mutation.
|
|
338
344
|
*/
|
|
339
|
-
function preApproveWorkspaceMcps({
|
|
340
|
-
runtimeName,
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
engineConfig,
|
|
344
|
-
mutateJsonFileLocked,
|
|
345
|
-
existsSync = fs.existsSync,
|
|
346
|
-
readFileSync = fs.readFileSync,
|
|
347
|
-
} = {}) {
|
|
348
|
-
if (engineConfig && engineConfig.claudePreApproveWorkspaceMcps === false) {
|
|
349
|
-
return { wrote: false, reason: 'flag-off', servers: [] };
|
|
350
|
-
}
|
|
351
|
-
if (runtimeName !== 'claude') {
|
|
352
|
-
return { wrote: false, reason: 'not-claude', servers: [] };
|
|
353
|
-
}
|
|
354
|
-
if (!worktreePath || typeof worktreePath !== 'string') {
|
|
355
|
-
return { wrote: false, reason: 'no-worktree', servers: [] };
|
|
345
|
+
function preApproveWorkspaceMcps({ runtimeName, ...context } = {}) {
|
|
346
|
+
const result = prepareRuntimeWorkspace(resolveRuntime(runtimeName), context);
|
|
347
|
+
if (result?.reason === 'unsupported-runtime') {
|
|
348
|
+
return { ...result, reason: 'not-claude' };
|
|
356
349
|
}
|
|
357
|
-
|
|
358
|
-
if (!existsSync(workspaceMcpPath)) {
|
|
359
|
-
return { wrote: false, reason: 'no-workspace-mcp', servers: [] };
|
|
360
|
-
}
|
|
361
|
-
let workspaceMcp;
|
|
362
|
-
try {
|
|
363
|
-
workspaceMcp = JSON.parse(readFileSync(workspaceMcpPath, 'utf8'));
|
|
364
|
-
} catch {
|
|
365
|
-
return { wrote: false, reason: 'invalid-workspace-mcp', servers: [] };
|
|
366
|
-
}
|
|
367
|
-
const serversObj = workspaceMcp && typeof workspaceMcp === 'object' ? workspaceMcp.mcpServers : null;
|
|
368
|
-
const servers = serversObj && typeof serversObj === 'object' && !Array.isArray(serversObj)
|
|
369
|
-
? Object.keys(serversObj).filter((k) => typeof k === 'string' && k.length > 0)
|
|
370
|
-
: [];
|
|
371
|
-
if (servers.length === 0) {
|
|
372
|
-
return { wrote: false, reason: 'no-servers', servers: [] };
|
|
373
|
-
}
|
|
374
|
-
if (!homeDir || typeof mutateJsonFileLocked !== 'function') {
|
|
375
|
-
return { wrote: false, reason: 'no-home-or-mutator', servers };
|
|
376
|
-
}
|
|
377
|
-
const claudeJsonPath = path.join(homeDir, '.claude.json');
|
|
378
|
-
let didWrite = false;
|
|
379
|
-
mutateJsonFileLocked(claudeJsonPath, (data) => {
|
|
380
|
-
const root = data && typeof data === 'object' ? data : {};
|
|
381
|
-
if (!root.projects || typeof root.projects !== 'object') root.projects = {};
|
|
382
|
-
const projectKey = worktreePath;
|
|
383
|
-
const proj = root.projects[projectKey] && typeof root.projects[projectKey] === 'object'
|
|
384
|
-
? root.projects[projectKey]
|
|
385
|
-
: {};
|
|
386
|
-
// enabledMcpjsonServers is Claude's actual on-disk approval field
|
|
387
|
-
const enabled = Array.isArray(proj.enabledMcpjsonServers) ? proj.enabledMcpjsonServers.slice() : [];
|
|
388
|
-
const disabled = Array.isArray(proj.disabledMcpjsonServers) ? proj.disabledMcpjsonServers.slice() : [];
|
|
389
|
-
const enabledSet = new Set(enabled.filter((s) => typeof s === 'string'));
|
|
390
|
-
let mutated = false;
|
|
391
|
-
for (const name of servers) {
|
|
392
|
-
if (!enabledSet.has(name)) {
|
|
393
|
-
enabledSet.add(name);
|
|
394
|
-
mutated = true;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// If a server was previously explicitly disabled, lift the disabled mark when we
|
|
398
|
-
// re-approve it — otherwise Claude treats the disabled entry as authoritative.
|
|
399
|
-
const nextDisabled = disabled.filter((s) => typeof s === 'string' && !servers.includes(s));
|
|
400
|
-
if (nextDisabled.length !== disabled.length) mutated = true;
|
|
401
|
-
if (!mutated) return data;
|
|
402
|
-
proj.enabledMcpjsonServers = [...enabledSet];
|
|
403
|
-
proj.disabledMcpjsonServers = nextDisabled;
|
|
404
|
-
root.projects[projectKey] = proj;
|
|
405
|
-
didWrite = true;
|
|
406
|
-
return root;
|
|
407
|
-
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
408
|
-
return { wrote: didWrite, reason: 'ok', servers };
|
|
350
|
+
return result;
|
|
409
351
|
}
|
|
410
352
|
|
|
411
353
|
// ─── Main script execution ──────────────────────────────────────────────────
|
|
@@ -421,7 +363,13 @@ function _installHint(name, runtime) {
|
|
|
421
363
|
}
|
|
422
364
|
|
|
423
365
|
function main() {
|
|
424
|
-
|
|
366
|
+
let parsed;
|
|
367
|
+
try {
|
|
368
|
+
parsed = parseSpawnArgs(process.argv);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.error(`FATAL: ${err.message}`);
|
|
371
|
+
process.exit(78);
|
|
372
|
+
}
|
|
425
373
|
if (!parsed) {
|
|
426
374
|
console.error('Usage: node spawn-agent.js <prompt-file> <sysprompt-file> [--runtime <name>] [args...]');
|
|
427
375
|
process.exit(1);
|
|
@@ -445,12 +393,18 @@ function main() {
|
|
|
445
393
|
// (Windows non-junction reads don't follow symlinks the same way POSIX does).
|
|
446
394
|
const promptText = readFileNoFollow(promptFile, 'utf8');
|
|
447
395
|
const sysPromptText = readFileNoFollow(sysPromptFile, 'utf8');
|
|
396
|
+
try {
|
|
397
|
+
hydrateRuntimeOptionFiles(opts, { promptFile });
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.error(`FATAL: ${err.message}`);
|
|
400
|
+
process.exit(78);
|
|
401
|
+
}
|
|
448
402
|
|
|
449
403
|
// Sys prompt tmp file — only when (a) NOT resuming and (b) the adapter
|
|
450
404
|
// accepts a system prompt as a separate file. For runtimes that bake the
|
|
451
405
|
// system prompt into the user prompt (e.g. Copilot), sysPromptText is
|
|
452
406
|
// already merged inside `runtime.buildPrompt(prompt, sys)`.
|
|
453
|
-
const isResume = opts.sessionId
|
|
407
|
+
const isResume = typeof opts.sessionId === 'string' && opts.sessionId.trim() !== '';
|
|
454
408
|
const sysTmpPath = sysPromptFile + '.tmp';
|
|
455
409
|
const wantsSystemPromptFile = typeof runtime.usesSystemPromptFile === 'function'
|
|
456
410
|
? runtime.usesSystemPromptFile({ isResume, opts })
|
|
@@ -672,9 +626,12 @@ function main() {
|
|
|
672
626
|
// meta.keep_processes_skip_workdir_check is true) bypasses the
|
|
673
627
|
// requireGitWorkdir check so legitimate non-git keep_processes
|
|
674
628
|
// use cases (e.g., a daemon under /tmp) still anchor.
|
|
675
|
-
const reapOpts =
|
|
676
|
-
|
|
677
|
-
|
|
629
|
+
const reapOpts = {
|
|
630
|
+
requireGitWorkdir: process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK !== '1',
|
|
631
|
+
};
|
|
632
|
+
if (process.env.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT) {
|
|
633
|
+
reapOpts.allowedCwdRoot = process.env.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT;
|
|
634
|
+
}
|
|
678
635
|
const plan = keepProcessSweep.computeReapPlan(toKillPids, agentId, reapOpts);
|
|
679
636
|
toKillPids = plan.toKill;
|
|
680
637
|
kept = plan.kept;
|
|
@@ -714,6 +671,18 @@ function main() {
|
|
|
714
671
|
});
|
|
715
672
|
}
|
|
716
673
|
|
|
717
|
-
module.exports = {
|
|
674
|
+
module.exports = {
|
|
675
|
+
parseSpawnArgs,
|
|
676
|
+
buildSpawnInvocation,
|
|
677
|
+
normalizeRuntimeExit,
|
|
678
|
+
shouldInjectAdoTokenEnv,
|
|
679
|
+
injectAdoTokenEnv,
|
|
680
|
+
injectAdoTokenEnvForRepoHost,
|
|
681
|
+
writeProcessExitSentinel,
|
|
682
|
+
preApproveWorkspaceMcps,
|
|
683
|
+
createParentPipeForwarder,
|
|
684
|
+
assertStaleHeadOk,
|
|
685
|
+
hydrateRuntimeOptionFiles, // exported for testing
|
|
686
|
+
};
|
|
718
687
|
|
|
719
688
|
if (require.main === module) main();
|
|
@@ -36,6 +36,7 @@ const fs = require('fs');
|
|
|
36
36
|
const path = require('path');
|
|
37
37
|
const shared = require('./shared');
|
|
38
38
|
const queries = require('./queries');
|
|
39
|
+
const { resolveRuntime, isSpawnStartupEvent } = require('./runtimes');
|
|
39
40
|
const { ENGINE_DEFAULTS, FAILURE_CLASS } = shared;
|
|
40
41
|
const AGENTS_DIR = queries.AGENTS_DIR;
|
|
41
42
|
const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[${level}] ${msg}`);
|
|
@@ -43,24 +44,6 @@ const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[
|
|
|
43
44
|
// Lazy require to break circular dep with dispatch.js (which lazy-requires engine.js).
|
|
44
45
|
function dispatch() { return require('./dispatch'); }
|
|
45
46
|
|
|
46
|
-
// ── Per-runtime startup-only event filters ──────────────────────────────────
|
|
47
|
-
//
|
|
48
|
-
// Anything NOT in these sets is treated as "real task activity" and the
|
|
49
|
-
// watchdog stands down. These match the runtimes/*.js KNOWN_EVENT_TYPES
|
|
50
|
-
// startup section — keep in sync.
|
|
51
|
-
|
|
52
|
-
const COPILOT_STARTUP_TYPES = new Set([
|
|
53
|
-
'session.mcp_server_status_changed',
|
|
54
|
-
'session.mcp_servers_loaded',
|
|
55
|
-
'session.skills_loaded',
|
|
56
|
-
'session.tools_updated',
|
|
57
|
-
'session.info',
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
// Claude stream-json prints {"type":"system","subtype":"init|hook_started|hook_response"}
|
|
61
|
-
// during startup before any assistant/user/tool_use/result events fire.
|
|
62
|
-
const CLAUDE_STARTUP_SUBTYPES = new Set(['init', 'hook_started', 'hook_response']);
|
|
63
|
-
|
|
64
47
|
// Header line that engine.js writes at spawn: "[<iso>] pid: <n>"
|
|
65
48
|
const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;
|
|
66
49
|
|
|
@@ -79,6 +62,12 @@ const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;
|
|
|
79
62
|
*/
|
|
80
63
|
function hasRealTaskActivity(tailText, runtimeName) {
|
|
81
64
|
if (!tailText) return false;
|
|
65
|
+
let runtime;
|
|
66
|
+
try {
|
|
67
|
+
runtime = resolveRuntime(runtimeName);
|
|
68
|
+
} catch {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
82
71
|
const lines = String(tailText).split(/\r?\n/);
|
|
83
72
|
for (const rawLine of lines) {
|
|
84
73
|
const line = rawLine.trim();
|
|
@@ -100,23 +89,7 @@ function hasRealTaskActivity(tailText, runtimeName) {
|
|
|
100
89
|
// Object with no `type` — unusual; treat as activity to stay safe.
|
|
101
90
|
return true;
|
|
102
91
|
}
|
|
103
|
-
if (
|
|
104
|
-
if (COPILOT_STARTUP_TYPES.has(type)) continue;
|
|
105
|
-
// Anything else from Copilot is real (assistant.*, tool.*, result,
|
|
106
|
-
// session.task_complete, user.message, function).
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
if (runtimeName === 'claude' || !runtimeName) {
|
|
110
|
-
// Claude stream-json: only {type:'system', subtype:'init|hook_*'} is startup.
|
|
111
|
-
if (type === 'system') {
|
|
112
|
-
const sub = typeof evt.subtype === 'string' ? evt.subtype : '';
|
|
113
|
-
if (CLAUDE_STARTUP_SUBTYPES.has(sub)) continue;
|
|
114
|
-
// Other `system` subtypes (e.g. errors, banners) — treat as activity.
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
// Unknown runtime — be conservative.
|
|
92
|
+
if (isSpawnStartupEvent(runtime, evt)) continue;
|
|
120
93
|
return true;
|
|
121
94
|
}
|
|
122
95
|
return false;
|
|
@@ -266,6 +239,4 @@ function _defaultTailReader(filePath) {
|
|
|
266
239
|
module.exports = {
|
|
267
240
|
checkSpawnPhaseStalls,
|
|
268
241
|
hasRealTaskActivity, // exported for testing
|
|
269
|
-
COPILOT_STARTUP_TYPES, // exported for testing
|
|
270
|
-
CLAUDE_STARTUP_SUBTYPES, // exported for testing
|
|
271
242
|
};
|
package/engine/timeout.js
CHANGED
|
@@ -25,14 +25,16 @@ let _engine = null;
|
|
|
25
25
|
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
26
26
|
const _pendingBranchPreservations = new Set();
|
|
27
27
|
|
|
28
|
-
function resolveDispatchBranchPath(item) {
|
|
28
|
+
function resolveDispatchBranchPath(item, config) {
|
|
29
29
|
if (item?.worktreePath) return item.worktreePath;
|
|
30
|
-
if (item
|
|
30
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config) && item.meta?.project?.localPath) {
|
|
31
|
+
return item.meta.project.localPath;
|
|
32
|
+
}
|
|
31
33
|
return null;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
function preserveDispatchBranch(item, config) {
|
|
35
|
-
const branchPath = resolveDispatchBranchPath(item);
|
|
37
|
+
const branchPath = resolveDispatchBranchPath(item, config);
|
|
36
38
|
if (!branchPath || !item.meta?.branch) {
|
|
37
39
|
return Promise.resolve({ pushed: false, reason: 'invalid-worktree' });
|
|
38
40
|
}
|
|
@@ -55,17 +57,18 @@ async function _finalizeDeadDispatch(item, reason, failureClass, config, deps =
|
|
|
55
57
|
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord;
|
|
56
58
|
const complete = deps.complete || dispatch().completeDispatch;
|
|
57
59
|
|
|
58
|
-
if (resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
60
|
+
if (resolveDispatchBranchPath(item, config) && item.meta?.branch) {
|
|
59
61
|
try {
|
|
60
62
|
await preserveBranch(item, config);
|
|
61
63
|
} catch (err) {
|
|
62
64
|
log('warn', `Orphan branch preservation failed for ${item.id}: ${err.message}`);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
|
-
if (
|
|
67
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config)) {
|
|
66
68
|
try {
|
|
67
69
|
await restoreLiveCheckout({
|
|
68
70
|
item,
|
|
71
|
+
config,
|
|
69
72
|
isTerminalFailure: true,
|
|
70
73
|
resultLabel: 'orphaned',
|
|
71
74
|
log: (lvl, msg) => log(lvl, msg),
|
|
@@ -719,7 +722,7 @@ function checkTimeouts(config) {
|
|
|
719
722
|
const legacyAnnotationClears = new Set();
|
|
720
723
|
|
|
721
724
|
function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess, preservationDone = false, expectedProcInfo = activeProcesses.get(item.id)) {
|
|
722
|
-
if (!preservationDone && resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
725
|
+
if (!preservationDone && resolveDispatchBranchPath(item, config) && item.meta?.branch) {
|
|
723
726
|
if (_pendingBranchPreservations.has(item.id)) return;
|
|
724
727
|
_pendingBranchPreservations.add(item.id);
|
|
725
728
|
preserveDispatchBranch(item, config)
|
|
@@ -776,12 +779,13 @@ function checkTimeouts(config) {
|
|
|
776
779
|
// for a live-mode dispatch that finished AFTER an engine restart. This path
|
|
777
780
|
// (output-detected completion of a re-attached process) has no in-memory
|
|
778
781
|
// onAgentClose closure, so without this the tree is stranded on the agent
|
|
779
|
-
// branch.
|
|
780
|
-
//
|
|
781
|
-
if (
|
|
782
|
+
// branch. Explicit dispatch provenance plus current config are the live-mode
|
|
783
|
+
// signal; worktree-mode records fail closed.
|
|
784
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config)) {
|
|
782
785
|
try {
|
|
783
786
|
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
|
|
784
787
|
item,
|
|
788
|
+
config,
|
|
785
789
|
isTerminalFailure: _completedAsError,
|
|
786
790
|
resultLabel: _completedAsError ? 'error' : 'success',
|
|
787
791
|
log: (lvl, msg) => log(lvl, msg),
|
|
@@ -1033,7 +1037,7 @@ function checkTimeouts(config) {
|
|
|
1033
1037
|
// #853 — stale orphans have no process close event, so preserve their
|
|
1034
1038
|
// clean committed branch explicitly before completeDispatch makes the
|
|
1035
1039
|
// worktree eligible for teardown/quarantine.
|
|
1036
|
-
if (resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
1040
|
+
if (resolveDispatchBranchPath(item, config) && item.meta?.branch) {
|
|
1037
1041
|
if (_pendingBranchPreservations.has(item.id)) continue;
|
|
1038
1042
|
_pendingBranchPreservations.add(item.id);
|
|
1039
1043
|
_finalizeDeadDispatch(item, reason, failureClass, config, { complete: completeDispatch })
|
package/engine/worktree-gc.js
CHANGED
|
@@ -41,6 +41,32 @@ function _managedSpawn() {
|
|
|
41
41
|
|
|
42
42
|
const _noopLog = () => {};
|
|
43
43
|
|
|
44
|
+
function _collectAnchoredCwds(listManagedSpecs, listKeepProcessCwds) {
|
|
45
|
+
const out = [];
|
|
46
|
+
try {
|
|
47
|
+
for (const rec of (listManagedSpecs() || [])) {
|
|
48
|
+
if (!rec || typeof rec.cwd !== 'string' || !rec.cwd) continue;
|
|
49
|
+
try { out.push(shared.realPathForComparison(rec.cwd)); } catch {}
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
try {
|
|
53
|
+
for (const cwd of (listKeepProcessCwds() || [])) {
|
|
54
|
+
if (typeof cwd !== 'string' || !cwd) continue;
|
|
55
|
+
try { out.push(shared.realPathForComparison(cwd)); } catch {}
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _isWorktreeCwdAnchored(worktreePath, anchoredCwds) {
|
|
62
|
+
for (const cwd of anchoredCwds) {
|
|
63
|
+
try {
|
|
64
|
+
if (shared.isPathInsideOrEqual(cwd, worktreePath)) return true;
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
// ── Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191) ─────────────────────
|
|
45
71
|
// In-memory ring of paths that have failed removal N consecutive times. After
|
|
46
72
|
// escalation we (a) write a dedup'd inbox note, (b) suppress repeat per-tick
|
|
@@ -608,13 +634,11 @@ function shouldGcDispatchWorktree(opts) {
|
|
|
608
634
|
? listManagedSpecs
|
|
609
635
|
: _managedSpawn().listManagedSpecs;
|
|
610
636
|
const specs = fn() || [];
|
|
611
|
-
const wtAbs = path.resolve(worktreePath);
|
|
612
|
-
const wtPrefix = wtAbs + path.sep;
|
|
613
637
|
for (const rec of specs) {
|
|
614
638
|
if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
|
|
615
|
-
let
|
|
616
|
-
try {
|
|
617
|
-
if (
|
|
639
|
+
let anchored = false;
|
|
640
|
+
try { anchored = shared.isPathInsideOrEqual(rec.cwd, worktreePath); } catch {}
|
|
641
|
+
if (anchored) {
|
|
618
642
|
return { gc: false, reason: 'managed-spawn-anchored' };
|
|
619
643
|
}
|
|
620
644
|
}
|
|
@@ -744,6 +768,12 @@ function pruneOrphanWorktrees(opts) {
|
|
|
744
768
|
try { return _managedSpawn().listManagedSpecs(); }
|
|
745
769
|
catch (_e) { return []; }
|
|
746
770
|
});
|
|
771
|
+
const _listKeepProcessCwds = typeof opts.listKeepProcessCwds === 'function'
|
|
772
|
+
? opts.listKeepProcessCwds
|
|
773
|
+
: (() => {
|
|
774
|
+
try { return _keepProcessSweep().getActiveKeepProcessCwds(); }
|
|
775
|
+
catch (_e) { return []; }
|
|
776
|
+
});
|
|
747
777
|
|
|
748
778
|
// Pool-known paths (idle + borrowed + any stale entry still on disk). The
|
|
749
779
|
// worktree-pool's own pruneStale() runs FIRST in cli boot so this snapshot
|
|
@@ -768,7 +798,11 @@ function pruneOrphanWorktrees(opts) {
|
|
|
768
798
|
// review — Issue 2)
|
|
769
799
|
const globalLiveDirNames = new Set();
|
|
770
800
|
for (const d of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
|
|
771
|
-
if (!d || !d.id
|
|
801
|
+
if (!d || !d.id) continue;
|
|
802
|
+
if (d.worktreePath) {
|
|
803
|
+
try { globalLiveDirNames.add(path.basename(path.resolve(d.worktreePath))); } catch {}
|
|
804
|
+
}
|
|
805
|
+
if (!d.meta || !d.meta.branch) continue;
|
|
772
806
|
const dispatchProject = typeof d.meta.project === 'string'
|
|
773
807
|
? d.meta.project
|
|
774
808
|
: (d.meta.project?.name || 'default');
|
|
@@ -781,17 +815,7 @@ function pruneOrphanWorktrees(opts) {
|
|
|
781
815
|
} catch (_e) { /* defensive */ }
|
|
782
816
|
}
|
|
783
817
|
|
|
784
|
-
|
|
785
|
-
// reject GC of any dir that contains (or equals) a live service's cwd.
|
|
786
|
-
// (PR #2627 review — Issue 1)
|
|
787
|
-
const managedSpawnCwds = [];
|
|
788
|
-
try {
|
|
789
|
-
for (const rec of (_listManagedSpecs() || [])) {
|
|
790
|
-
if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
|
|
791
|
-
try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
|
|
792
|
-
catch (_e) { /* malformed cwd — skip */ }
|
|
793
|
-
}
|
|
794
|
-
} catch (_e) { /* optional */ }
|
|
818
|
+
const anchoredCwds = _collectAnchoredCwds(_listManagedSpecs, _listKeepProcessCwds);
|
|
795
819
|
|
|
796
820
|
const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, perProject: {} };
|
|
797
821
|
|
|
@@ -836,18 +860,10 @@ function pruneOrphanWorktrees(opts) {
|
|
|
836
860
|
projStats.kept++; result.kept++;
|
|
837
861
|
continue;
|
|
838
862
|
}
|
|
839
|
-
// 3. managed_spawn cwd anchor protection.
|
|
840
|
-
if (
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
let anchored = false;
|
|
844
|
-
for (const cwd of managedSpawnCwds) {
|
|
845
|
-
if (cwd === wtPathNorm || cwd.startsWith(wtPrefix)) { anchored = true; break; }
|
|
846
|
-
}
|
|
847
|
-
if (anchored) {
|
|
848
|
-
projStats.kept++; result.kept++;
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
863
|
+
// 3. managed_spawn / keep_processes cwd anchor protection.
|
|
864
|
+
if (_isWorktreeCwdAnchored(wtPath, anchoredCwds)) {
|
|
865
|
+
projStats.kept++; result.kept++;
|
|
866
|
+
continue;
|
|
851
867
|
}
|
|
852
868
|
|
|
853
869
|
// P-c7e2b405 — route the slow-retry gate, ownership-marker gate (now
|
|
@@ -938,6 +954,12 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
|
|
|
938
954
|
try { return _managedSpawn().listManagedSpecs(); }
|
|
939
955
|
catch (_e) { return []; }
|
|
940
956
|
});
|
|
957
|
+
const _listKeepProcessCwds = typeof opts.listKeepProcessCwds === 'function'
|
|
958
|
+
? opts.listKeepProcessCwds
|
|
959
|
+
: (() => {
|
|
960
|
+
try { return _keepProcessSweep().getActiveKeepProcessCwds(); }
|
|
961
|
+
catch (_e) { return []; }
|
|
962
|
+
});
|
|
941
963
|
const _execSilent = typeof opts.execSilent === 'function'
|
|
942
964
|
? opts.execSilent
|
|
943
965
|
: shared.execSilent;
|
|
@@ -986,15 +1008,7 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
|
|
|
986
1008
|
for (const p of opts.extraPoolPaths) poolPaths.add(worktreePool._normalizePath(p));
|
|
987
1009
|
}
|
|
988
1010
|
|
|
989
|
-
|
|
990
|
-
const managedSpawnCwds = [];
|
|
991
|
-
try {
|
|
992
|
-
for (const rec of (_listManagedSpecs() || [])) {
|
|
993
|
-
if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
|
|
994
|
-
try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
|
|
995
|
-
catch (_e) { /* malformed cwd — skip */ }
|
|
996
|
-
}
|
|
997
|
-
} catch (_e) { /* optional */ }
|
|
1011
|
+
const anchoredCwds = _collectAnchoredCwds(_listManagedSpecs, _listKeepProcessCwds);
|
|
998
1012
|
|
|
999
1013
|
const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0, perProject: {} };
|
|
1000
1014
|
const _seenAbs = new Set(); // dedup across projects sharing a parent
|
|
@@ -1058,14 +1072,9 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
|
|
|
1058
1072
|
if (poolPaths.has(normPath)) {
|
|
1059
1073
|
projStats.kept++; result.kept++; continue;
|
|
1060
1074
|
}
|
|
1061
|
-
// managed_spawn anchor protection
|
|
1062
|
-
if (
|
|
1063
|
-
|
|
1064
|
-
let anchored = false;
|
|
1065
|
-
for (const cwd of managedSpawnCwds) {
|
|
1066
|
-
if (cwd === wtAbs || cwd.startsWith(wtPrefix)) { anchored = true; break; }
|
|
1067
|
-
}
|
|
1068
|
-
if (anchored) { projStats.kept++; result.kept++; continue; }
|
|
1075
|
+
// managed_spawn / keep_processes cwd anchor protection.
|
|
1076
|
+
if (_isWorktreeCwdAnchored(wtAbs, anchoredCwds)) {
|
|
1077
|
+
projStats.kept++; result.kept++; continue;
|
|
1069
1078
|
}
|
|
1070
1079
|
|
|
1071
1080
|
// P-c7e2b405 — ownership-marker gate, slow-retry gate, remove, escalate,
|