@magnusekdahl/parallix 1.0.0
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/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
const fmt = require('../core/fmt');
|
|
5
|
+
const { startCodexDraftAgent, resolveCodexCommand } = require('./codex');
|
|
6
|
+
const { startClaudeAgent, resolveClaudeCommand } = require('./claude');
|
|
7
|
+
const { startMistralAgent, resolveMistralCommand } = require('./mistral');
|
|
8
|
+
const { startOpencodeAgent, resolveOpencodeCommand } = require('./opencode');
|
|
9
|
+
const { detectLimitHit } = require('./limit-hit');
|
|
10
|
+
const sessions = require('../tools/sessions');
|
|
11
|
+
const storage = require('../core/storage');
|
|
12
|
+
const { resolveAgentModel } = require('../core/product-config');
|
|
13
|
+
const { migrateAgentBlocklists } = require('../core/persistent-data-migration');
|
|
14
|
+
|
|
15
|
+
// Launchers whose CLI accepts a per-call resume flag threaded by startAgent.
|
|
16
|
+
// Each launcher outputs a session resume hint at the end of its run (e.g.
|
|
17
|
+
// "codex resume <id>", "opencode -s ses_<id>",
|
|
18
|
+
// "claude --resume <id>"). The resume flag is only used when the caller
|
|
19
|
+
// passes slug+role+worktree and the session marker matches the chosen agent.
|
|
20
|
+
// qwen (opencode) always uses --continue; claude uses --continue; codex uses
|
|
21
|
+
// `exec resume --last`.
|
|
22
|
+
const RESUME_CAPABLE = new Set(['claude', 'codex', 'qwen']);
|
|
23
|
+
|
|
24
|
+
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config', 'agents.json');
|
|
25
|
+
|
|
26
|
+
// Test hook: when set, used instead of spawning `command -v` to check PATH.
|
|
27
|
+
let _commandPathProbe = null;
|
|
28
|
+
|
|
29
|
+
const LAUNCHERS = {
|
|
30
|
+
codex: startCodexDraftAgent,
|
|
31
|
+
claude: startClaudeAgent,
|
|
32
|
+
mistral: startMistralAgent,
|
|
33
|
+
qwen: startOpencodeAgent
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const RESOLVERS = {
|
|
37
|
+
codex: resolveCodexCommand,
|
|
38
|
+
claude: resolveClaudeCommand,
|
|
39
|
+
mistral: resolveMistralCommand,
|
|
40
|
+
qwen: resolveOpencodeCommand
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const HEALTH_PROBE_ARGS = Object.freeze({
|
|
44
|
+
codex: ['--help'],
|
|
45
|
+
claude: ['--help'],
|
|
46
|
+
mistral: ['--help'],
|
|
47
|
+
qwen: ['--help']
|
|
48
|
+
});
|
|
49
|
+
const LAUNCHER_HEALTH_TIMEOUT_MS = 3000;
|
|
50
|
+
const DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS = 60_000;
|
|
51
|
+
const DEFAULT_NO_OUTPUT_INTERVAL_MS = 60_000;
|
|
52
|
+
const DRAFT_NO_OUTPUT_INITIAL_DELAY_MS = 15_000;
|
|
53
|
+
const DRAFT_NO_OUTPUT_INTERVAL_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
const WORKFLOW_AGENT_NAMES = Object.freeze(Object.keys(LAUNCHERS));
|
|
56
|
+
const KNOWN_AGENT_NAMES = Object.freeze([
|
|
57
|
+
...WORKFLOW_AGENT_NAMES,
|
|
58
|
+
'human'
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function workflowLauncherStatus(agent) {
|
|
62
|
+
const resolver = RESOLVERS[agent];
|
|
63
|
+
if (!resolver) {
|
|
64
|
+
return { agent, supported: false, detail: `unknown agent: ${agent}` };
|
|
65
|
+
}
|
|
66
|
+
const command = resolver();
|
|
67
|
+
const exists = command.includes('/') ? fs.existsSync(command) : commandInPath(command);
|
|
68
|
+
if (!exists) {
|
|
69
|
+
return { agent, supported: false, detail: command, health: 'missing' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const probeArgs = HEALTH_PROBE_ARGS[agent] || ['--help'];
|
|
73
|
+
const probe = spawnSync(command, probeArgs, {
|
|
74
|
+
encoding: 'utf8',
|
|
75
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
|
+
timeout: LAUNCHER_HEALTH_TIMEOUT_MS
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (probe.error || probe.status !== 0) {
|
|
80
|
+
const reason = probe.error
|
|
81
|
+
? probe.error.code || probe.error.message
|
|
82
|
+
: `exit ${probe.status}`;
|
|
83
|
+
return {
|
|
84
|
+
agent,
|
|
85
|
+
supported: false,
|
|
86
|
+
detail: `${command} ${probeArgs.join(' ')}`.trim(),
|
|
87
|
+
health: 'probe-failed',
|
|
88
|
+
reason
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { agent, supported: true, detail: `${command} ${probeArgs.join(' ')}`.trim(), health: 'ok' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function commandInPath(name) {
|
|
96
|
+
if (_commandPathProbe) {
|
|
97
|
+
return _commandPathProbe(name) || false;
|
|
98
|
+
}
|
|
99
|
+
const result = spawnSync('bash', ['-c', `command -v ${name}`], {
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
102
|
+
});
|
|
103
|
+
return result.status === 0 && result.stdout.trim().length > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildInvalidAgentConfigError(configPath, scope, originalError) {
|
|
107
|
+
const location = path.resolve(configPath);
|
|
108
|
+
const detail = originalError && originalError.message ? originalError.message : 'invalid JSON';
|
|
109
|
+
const error = new Error(
|
|
110
|
+
`Invalid ${scope} agent config at ${location}: ${detail}. ` +
|
|
111
|
+
'Fix or remove the malformed file before running workflow commands so agent blocking is applied deterministically.'
|
|
112
|
+
);
|
|
113
|
+
error.code = 'WORKFLOW_AGENT_CONFIG_INVALID';
|
|
114
|
+
error.configPath = location;
|
|
115
|
+
error.configScope = scope;
|
|
116
|
+
return error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isInvalidAgentConfigError(error) {
|
|
120
|
+
return Boolean(error && error.code === 'WORKFLOW_AGENT_CONFIG_INVALID');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readAgentConfigOrExit(configPath = CONFIG_PATH, options = {}) {
|
|
124
|
+
try {
|
|
125
|
+
return readAgentConfig(configPath, options);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (isInvalidAgentConfigError(error)) {
|
|
128
|
+
fmt.log.fail(error.message);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseAgentConfigFile(configPath, scope) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw buildInvalidAgentConfigError(configPath, scope, err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readAgentConfig(configPath = CONFIG_PATH, options = {}) {
|
|
144
|
+
const {
|
|
145
|
+
mergeLocal = path.resolve(configPath) === path.resolve(CONFIG_PATH),
|
|
146
|
+
mainWorktreePath,
|
|
147
|
+
warn = fmt.log.warn
|
|
148
|
+
} = options;
|
|
149
|
+
let config = null;
|
|
150
|
+
if (fs.existsSync(configPath)) {
|
|
151
|
+
config = parseAgentConfigFile(configPath, 'workflow');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (mergeLocal) {
|
|
155
|
+
config = config || {};
|
|
156
|
+
const projectRoot = path.resolve(path.dirname(configPath), '..', '..');
|
|
157
|
+
const mainWorktree = mainWorktreePath !== undefined
|
|
158
|
+
? mainWorktreePath
|
|
159
|
+
: getMainWorktreePath({ cwd: projectRoot, warn });
|
|
160
|
+
const legacyPaths = [
|
|
161
|
+
path.join(path.dirname(configPath), 'agents.local.json'),
|
|
162
|
+
path.join(projectRoot, 'agents.local.json'),
|
|
163
|
+
mainWorktree ? path.join(mainWorktree, 'agents.local.json') : null
|
|
164
|
+
].filter(Boolean);
|
|
165
|
+
const targetPath = options.targetPath || storage.resolveAgentsLocalPath({ ensureDir: true });
|
|
166
|
+
if (!fs.existsSync(targetPath)) {
|
|
167
|
+
try {
|
|
168
|
+
migrateAgentBlocklists({
|
|
169
|
+
sourcePaths: legacyPaths,
|
|
170
|
+
destinationPath: targetPath,
|
|
171
|
+
warn
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
throw buildInvalidAgentConfigError(targetPath, 'local', error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (fs.existsSync(targetPath)) {
|
|
178
|
+
const localConfig = parseAgentConfigFile(targetPath, 'local');
|
|
179
|
+
if (localConfig && localConfig.blocklist) {
|
|
180
|
+
config.blocklist = Object.assign(config.blocklist || {}, localConfig.blocklist);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return config;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getMainWorktreePath(options = {}) {
|
|
189
|
+
const { cwd = process.cwd(), warn = fmt.log.warn } = options;
|
|
190
|
+
try {
|
|
191
|
+
const commonDir = getGitPath(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
192
|
+
if (commonDir && MainWorktreeDetector.byCommonDir.has(commonDir)) {
|
|
193
|
+
return MainWorktreeDetector.byCommonDir.get(commonDir);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
199
|
+
timeout: 1000
|
|
200
|
+
});
|
|
201
|
+
if (result.status !== 0) {
|
|
202
|
+
warn(
|
|
203
|
+
`Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
|
|
204
|
+
`skipping that lookup (git exited with status ${result.status}).`
|
|
205
|
+
);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const lines = result.stdout.split('\n');
|
|
210
|
+
const mainWorktreePath = detectMainWorktreePath(lines, cwd, commonDir);
|
|
211
|
+
if (mainWorktreePath) {
|
|
212
|
+
if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, mainWorktreePath);
|
|
213
|
+
return mainWorktreePath;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fallback: pick the first worktree whose HEAD points to main
|
|
217
|
+
let i = 0;
|
|
218
|
+
while (i < lines.length) {
|
|
219
|
+
if (lines[i].startsWith('worktree ')) {
|
|
220
|
+
const wt = lines[i].slice('worktree '.length).trim();
|
|
221
|
+
const branchLineIdx = i + 1;
|
|
222
|
+
if (branchLineIdx < lines.length && lines[branchLineIdx].startsWith('branch refs/heads/main')) {
|
|
223
|
+
if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, wt);
|
|
224
|
+
return wt;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
i++;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Last resort: the first worktree in the list that isn't the current cwd
|
|
231
|
+
for (i = 0; i < lines.length; i++) {
|
|
232
|
+
if (lines[i].startsWith('worktree ')) {
|
|
233
|
+
const wt = lines[i].slice('worktree '.length).trim();
|
|
234
|
+
if (wt !== cwd) {
|
|
235
|
+
if (commonDir) MainWorktreeDetector.byCommonDir.set(commonDir, wt);
|
|
236
|
+
return wt;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const detail = err && (err.code || err.message) ? (err.code || err.message) : 'unknown error';
|
|
242
|
+
warn(
|
|
243
|
+
`Could not inspect git worktrees while looking for main-worktree agents.local.json; ` +
|
|
244
|
+
`skipping that lookup (${detail}).`
|
|
245
|
+
);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
warn(
|
|
250
|
+
'Could not determine the main worktree from `git worktree list --porcelain`; ' +
|
|
251
|
+
'skipping main-worktree agents.local.json lookup.'
|
|
252
|
+
);
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Extract the known main worktree path from the repo metadata so worktree
|
|
257
|
+
// iteration doesn't accidentally pick the current (non-main) worktree.
|
|
258
|
+
// Cached per git common directory to avoid repeated subprocess calls without
|
|
259
|
+
// leaking a temp-repo answer into later tests or nested workflow invocations.
|
|
260
|
+
const MainWorktreeDetector = {
|
|
261
|
+
byCommonDir: new Map()
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
function getGitPath(cwd, args) {
|
|
265
|
+
const result = spawnSync('git', ['-C', cwd, ...args], {
|
|
266
|
+
encoding: 'utf8',
|
|
267
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
268
|
+
timeout: 1000
|
|
269
|
+
});
|
|
270
|
+
if (result.status !== 0) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
return result.stdout.trim() || null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseWorktreePaths(lines) {
|
|
277
|
+
return lines
|
|
278
|
+
.filter(line => line.startsWith('worktree '))
|
|
279
|
+
.map(line => line.slice('worktree '.length).trim())
|
|
280
|
+
.filter(Boolean);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function detectMainWorktreePath(lines, cwd, commonDir) {
|
|
284
|
+
const worktrees = parseWorktreePaths(lines);
|
|
285
|
+
if (worktrees.length === 0) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const resolvedCommonDir = commonDir ? path.resolve(commonDir) : null;
|
|
290
|
+
for (const wt of worktrees) {
|
|
291
|
+
const gitDir = getGitPath(wt, ['rev-parse', '--absolute-git-dir']);
|
|
292
|
+
const wtCommonDir = getGitPath(wt, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
293
|
+
if (
|
|
294
|
+
gitDir &&
|
|
295
|
+
wtCommonDir &&
|
|
296
|
+
path.resolve(gitDir) === path.resolve(wtCommonDir) &&
|
|
297
|
+
(!resolvedCommonDir || path.resolve(wtCommonDir) === resolvedCommonDir)
|
|
298
|
+
) {
|
|
299
|
+
return wt;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const currentTopLevel = getGitPath(cwd, ['rev-parse', '--show-toplevel']);
|
|
304
|
+
if (currentTopLevel && worktrees.length === 1 && path.resolve(worktrees[0]) === path.resolve(currentTopLevel)) {
|
|
305
|
+
return worktrees[0];
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parseBlockUntil(value) {
|
|
311
|
+
if (typeof value !== 'string') {
|
|
312
|
+
return NaN;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2})$/);
|
|
316
|
+
if (!match) {
|
|
317
|
+
return NaN;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const [, yearStr, monthStr, dayStr, hourStr] = match;
|
|
321
|
+
const year = Number(yearStr);
|
|
322
|
+
const month = Number(monthStr);
|
|
323
|
+
const day = Number(dayStr);
|
|
324
|
+
const hour = Number(hourStr);
|
|
325
|
+
const parsed = new Date(year, month - 1, day, hour, 0, 0, 0);
|
|
326
|
+
|
|
327
|
+
if (
|
|
328
|
+
parsed.getFullYear() !== year ||
|
|
329
|
+
parsed.getMonth() !== month - 1 ||
|
|
330
|
+
parsed.getDate() !== day ||
|
|
331
|
+
parsed.getHours() !== hour
|
|
332
|
+
) {
|
|
333
|
+
return NaN;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return parsed.getTime();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isAgentBlocked(agent, config) {
|
|
340
|
+
if (!config || !config.blocklist || config.blocklist[agent] === undefined) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
const entry = config.blocklist[agent];
|
|
344
|
+
if (entry === true) return true;
|
|
345
|
+
if (entry === false) return false;
|
|
346
|
+
if (entry && typeof entry === 'object') {
|
|
347
|
+
if (entry.blocked === true) return true;
|
|
348
|
+
if (entry.blocked === false) return false;
|
|
349
|
+
if (entry.until) {
|
|
350
|
+
const until = parseBlockUntil(entry.until);
|
|
351
|
+
if (!isNaN(until) && until > Date.now()) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function eligibleAgentsForStep(step, options = {}) {
|
|
360
|
+
const config = options.config !== undefined
|
|
361
|
+
? options.config
|
|
362
|
+
: readAgentConfig(options.configPath || CONFIG_PATH, options);
|
|
363
|
+
let eligible;
|
|
364
|
+
if (!config || !config.steps || !config.steps[step]) {
|
|
365
|
+
eligible = Object.keys(LAUNCHERS);
|
|
366
|
+
} else {
|
|
367
|
+
eligible = config.steps[step].eligible || Object.keys(LAUNCHERS);
|
|
368
|
+
}
|
|
369
|
+
return eligible.filter(agent => !isAgentBlocked(agent, config));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function weightedRandom(agents, weights) {
|
|
373
|
+
const total = agents.reduce((sum, a) => sum + (weights[a] || 1), 0);
|
|
374
|
+
let r = Math.random() * total;
|
|
375
|
+
for (const agent of agents) {
|
|
376
|
+
r -= weights[agent] || 1;
|
|
377
|
+
if (r <= 0) return agent;
|
|
378
|
+
}
|
|
379
|
+
return agents[agents.length - 1];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function selectAgent(step, options = {}) {
|
|
383
|
+
const envOverride = process.env.WORKFLOW_AGENT;
|
|
384
|
+
const excluded = options.exclude instanceof Set ? options.exclude : new Set();
|
|
385
|
+
const eligible = eligibleAgentsForStep(step, options);
|
|
386
|
+
// Honor the env override only when it is in the current eligible-and-unblocked
|
|
387
|
+
// pool and not already excluded (a previous limit-hit attempt in the same
|
|
388
|
+
// startAgent retry loop). A pinned agent that is hard-blocked in
|
|
389
|
+
// agents.local.json or excluded by step eligibility falls through to normal
|
|
390
|
+
// selection — matches parallix/docs/agents.md, which documents that
|
|
391
|
+
// WORKFLOW_AGENT is honored alongside the eligibility config and blocklist.
|
|
392
|
+
if (envOverride && !excluded.has(envOverride) && eligible.includes(envOverride)) {
|
|
393
|
+
return envOverride;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const pool = eligible.filter(agent => !excluded.has(agent));
|
|
397
|
+
if (eligible.length === 0) {
|
|
398
|
+
throw new Error(`No agents are eligible for workflow step: ${step}`);
|
|
399
|
+
}
|
|
400
|
+
if (pool.length === 0) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`All eligible agents for step "${step}" are exhausted (limit-hit or excluded). ` +
|
|
403
|
+
`Tried: ${[...excluded].join(', ')}.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Filter to agents that are both eligible (per config) and supported (launcher present).
|
|
408
|
+
const statuses = new Map(
|
|
409
|
+
pool
|
|
410
|
+
.filter(agent => LAUNCHERS[agent])
|
|
411
|
+
.map(agent => [agent, workflowLauncherStatus(agent)])
|
|
412
|
+
);
|
|
413
|
+
const available = pool.filter(agent => {
|
|
414
|
+
const status = statuses.get(agent);
|
|
415
|
+
return Boolean(status && status.supported);
|
|
416
|
+
});
|
|
417
|
+
if (available.length === 0) {
|
|
418
|
+
const blockers = pool.map(agent => {
|
|
419
|
+
const status = statuses.get(agent) || { detail: agent, reason: 'unsupported-agent' };
|
|
420
|
+
const suffix = status.reason ? `; ${status.reason}` : '';
|
|
421
|
+
return `${agent} (looked for: ${status.detail}${suffix})`;
|
|
422
|
+
});
|
|
423
|
+
throw new Error(
|
|
424
|
+
`No eligible agents have a working launcher for step "${step}". ` +
|
|
425
|
+
`Eligible but blocked: ${blockers.join(', ')}. ` +
|
|
426
|
+
`Set WORKFLOW_AGENT=<name> to override or install a supported agent.`
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const config = options.config !== undefined
|
|
431
|
+
? options.config
|
|
432
|
+
: readAgentConfig(options.configPath || CONFIG_PATH, options);
|
|
433
|
+
const stepConfig = config && config.steps && config.steps[step] ? config.steps[step] : {};
|
|
434
|
+
const selection = stepConfig.selection || 'random';
|
|
435
|
+
|
|
436
|
+
if (selection === 'weighted') {
|
|
437
|
+
const weights = stepConfig.weights || {};
|
|
438
|
+
return weightedRandom(available, weights);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (selection === 'random') {
|
|
442
|
+
return available[Math.floor(Math.random() * available.length)];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return available[0];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function assertAgentSupported(agent) {
|
|
449
|
+
if (!LAUNCHERS[agent]) {
|
|
450
|
+
const error = new Error(
|
|
451
|
+
`Unknown agent: "${fmt.agent(agent)}". Supported agents: ${Object.keys(LAUNCHERS).join(', ')}.`
|
|
452
|
+
);
|
|
453
|
+
error.code = 'UNKNOWN_AGENT';
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const status = workflowLauncherStatus(agent);
|
|
458
|
+
if (!status.supported) {
|
|
459
|
+
const health = status.health ? ` (${status.health})` : '';
|
|
460
|
+
const reason = status.reason ? `; reason: ${status.reason}` : '';
|
|
461
|
+
const error = new Error(
|
|
462
|
+
`Agent "${fmt.agent(agent)}" launcher is not available on this workstation${health}. ` +
|
|
463
|
+
`Looked for: ${fmt.path(status.detail)}${reason}. ` +
|
|
464
|
+
`Ensure ${fmt.agent(agent)} is on your PATH and retry.`
|
|
465
|
+
);
|
|
466
|
+
error.code = 'LAUNCHER_UNAVAILABLE';
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function resolveBlocklistTargetPath(options = {}) {
|
|
472
|
+
if (options.targetPath) return options.targetPath;
|
|
473
|
+
return storage.resolveAgentsLocalPath({ ensureDir: true });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function updateAgentBlock(agent, until, options = {}) {
|
|
477
|
+
if (!agent || typeof agent !== 'string') {
|
|
478
|
+
throw new Error('updateAgentBlock requires an agent name');
|
|
479
|
+
}
|
|
480
|
+
if (!until || typeof until !== 'string' || !/^\d{4}-\d{2}-\d{2} \d{2}$/.test(until)) {
|
|
481
|
+
throw new Error(`updateAgentBlock requires an "YYYY-MM-DD HH" timestamp; got: ${until}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const targetPath = resolveBlocklistTargetPath(options);
|
|
485
|
+
|
|
486
|
+
let payload = {};
|
|
487
|
+
if (fs.existsSync(targetPath)) {
|
|
488
|
+
// Match the read-path contract (parseAgentConfigFile): malformed local agent
|
|
489
|
+
// JSON is a hard failure, not a silent overwrite. Otherwise a limit hit on a
|
|
490
|
+
// corrupted agents.local.json would destroy whatever was on disk.
|
|
491
|
+
try {
|
|
492
|
+
payload = JSON.parse(fs.readFileSync(targetPath, 'utf8')) || {};
|
|
493
|
+
} catch (err) {
|
|
494
|
+
throw buildInvalidAgentConfigError(targetPath, 'local', err);
|
|
495
|
+
}
|
|
496
|
+
if (typeof payload !== 'object' || Array.isArray(payload)) {
|
|
497
|
+
throw buildInvalidAgentConfigError(
|
|
498
|
+
targetPath,
|
|
499
|
+
'local',
|
|
500
|
+
new Error('expected a JSON object at the file root')
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (!payload.blocklist || typeof payload.blocklist !== 'object' || Array.isArray(payload.blocklist)) {
|
|
505
|
+
payload.blocklist = {};
|
|
506
|
+
}
|
|
507
|
+
payload.blocklist[agent] = { until };
|
|
508
|
+
|
|
509
|
+
storage.writeJson(targetPath, payload);
|
|
510
|
+
return { path: targetPath, blocklist: payload.blocklist };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function defaultIsAgentBlockedNow(agent) {
|
|
514
|
+
try {
|
|
515
|
+
const config = readAgentConfig(CONFIG_PATH, {});
|
|
516
|
+
return isAgentBlocked(agent, config);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
// If the config is malformed, surface that through the launcher path
|
|
519
|
+
// (assertAgentSupported / launch) instead of silently rerouting. Treat as
|
|
520
|
+
// not-blocked here so the existing error path runs.
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function readPositiveMsEnv(name) {
|
|
526
|
+
const raw = process.env[name];
|
|
527
|
+
if (raw === undefined || raw === '') return null;
|
|
528
|
+
const value = Number(raw);
|
|
529
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveNoOutputWatchdogConfig(config = {}, step = null) {
|
|
533
|
+
if (config === false || process.env.WORKFLOW_AGENT_NO_OUTPUT_WATCHDOG === '0') {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
const explicit = config && typeof config === 'object' ? config : {};
|
|
537
|
+
// Draft gets a shorter default watchdog to surface agent-launch visibility
|
|
538
|
+
// quickly; the generic default (60s) is too slow for the draft entrypoint
|
|
539
|
+
// where an operator cannot tell launch from hang.
|
|
540
|
+
let initialDelayMs;
|
|
541
|
+
let intervalMs;
|
|
542
|
+
if (step === 'draft') {
|
|
543
|
+
initialDelayMs = explicit.initialDelayMs ??
|
|
544
|
+
readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INITIAL_MS') ??
|
|
545
|
+
DRAFT_NO_OUTPUT_INITIAL_DELAY_MS;
|
|
546
|
+
intervalMs = explicit.intervalMs ??
|
|
547
|
+
readPositiveMsEnv('WORKFLOW_DRAFT_AGENT_NO_OUTPUT_INTERVAL_MS') ??
|
|
548
|
+
DRAFT_NO_OUTPUT_INTERVAL_MS;
|
|
549
|
+
} else {
|
|
550
|
+
initialDelayMs = explicit.initialDelayMs ??
|
|
551
|
+
readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INITIAL_MS') ??
|
|
552
|
+
DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS;
|
|
553
|
+
intervalMs = explicit.intervalMs ??
|
|
554
|
+
readPositiveMsEnv('WORKFLOW_AGENT_NO_OUTPUT_INTERVAL_MS') ??
|
|
555
|
+
DEFAULT_NO_OUTPUT_INTERVAL_MS;
|
|
556
|
+
}
|
|
557
|
+
return { initialDelayMs, intervalMs };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function formatElapsed(elapsedMs) {
|
|
561
|
+
const seconds = Math.max(0, Math.round(elapsedMs / 1000));
|
|
562
|
+
if (seconds < 60) return `${seconds}s`;
|
|
563
|
+
const minutes = Math.floor(seconds / 60);
|
|
564
|
+
const remainder = seconds % 60;
|
|
565
|
+
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function startAgent(step, opts = {}) {
|
|
569
|
+
const {
|
|
570
|
+
prompt,
|
|
571
|
+
worktree,
|
|
572
|
+
agent: agentOverride,
|
|
573
|
+
env = {},
|
|
574
|
+
exclude = [],
|
|
575
|
+
onLimitHit,
|
|
576
|
+
onLaunch,
|
|
577
|
+
slug = null,
|
|
578
|
+
role = null,
|
|
579
|
+
detectLimitHitFn = detectLimitHit,
|
|
580
|
+
updateAgentBlockFn = updateAgentBlock,
|
|
581
|
+
selectAgentFn = selectAgent,
|
|
582
|
+
resolveAgentModelFn = resolveAgentModel,
|
|
583
|
+
isAgentBlockedFn = defaultIsAgentBlockedNow,
|
|
584
|
+
sessionsModule = sessions,
|
|
585
|
+
log = fmt.log.plain,
|
|
586
|
+
noOutputWatchdog = {}
|
|
587
|
+
} = opts;
|
|
588
|
+
|
|
589
|
+
// `exclude` seeds the tried-set so callers can reserve agents (e.g. exclude
|
|
590
|
+
// the current implementer from reviewer fallback to preserve family separation).
|
|
591
|
+
const excludeIterable = exclude instanceof Set ? exclude : exclude;
|
|
592
|
+
const tried = new Set(excludeIterable);
|
|
593
|
+
// Track per-agent failure details for accurate exhaustion diagnostics (SC 3)
|
|
594
|
+
const agentErrors = new Map();
|
|
595
|
+
// Track agents actually launched (not just pre-excluded) for accurate reporting
|
|
596
|
+
const launched = new Set();
|
|
597
|
+
let iteration = 0;
|
|
598
|
+
let chosen = agentOverride;
|
|
599
|
+
|
|
600
|
+
while (true) {
|
|
601
|
+
iteration += 1;
|
|
602
|
+
if (!chosen) {
|
|
603
|
+
try {
|
|
604
|
+
chosen = selectAgentFn(step, { exclude: tried });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
// Only catch pool exhaustion errors from selectAgent.
|
|
607
|
+
// Configuration errors (no eligible agents, no working launcher) must
|
|
608
|
+
// propagate unchanged to preserve diagnostics (SC 3).
|
|
609
|
+
// Exhaustion is indicated by:
|
|
610
|
+
// - "exhausted" from real selectAgent pool exhaustion ("are exhausted")
|
|
611
|
+
// - "No agents available" from test mocks simulating exhaustion
|
|
612
|
+
if (!err.message || !(
|
|
613
|
+
err.message.includes('exhausted') ||
|
|
614
|
+
err.message.includes('No agents available')
|
|
615
|
+
)) {
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
// Pool exhausted; build clear exhaustion diagnostics with per-agent errors (SC 3)
|
|
619
|
+
const errorDetails = [...agentErrors.entries()].map(([agent, details]) => {
|
|
620
|
+
const status = details.exitInfo === 'stalled'
|
|
621
|
+
? 'stalled (no output)'
|
|
622
|
+
: (details.status !== undefined && details.status !== null
|
|
623
|
+
? `exit ${details.status}`
|
|
624
|
+
: (details.signal ? `signal ${details.signal}` : 'unknown'));
|
|
625
|
+
const stderrSnippet = details.stderr ? ` (${details.stderr.trim().split('\n')[0]})` : '';
|
|
626
|
+
return `${agent}: ${status}${stderrSnippet}`;
|
|
627
|
+
}).join('; ');
|
|
628
|
+
const launchedList = [...launched].join(', ');
|
|
629
|
+
throw new Error(
|
|
630
|
+
`All eligible agents exhausted for step "${step}". ` +
|
|
631
|
+
`Tried: ${launchedList}. Errors: ${errorDetails}.`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
} else if (isAgentBlockedFn(chosen)) {
|
|
635
|
+
// Pre-launch blocklist gate. An explicit `agent:` override (e.g. a pinned
|
|
636
|
+
// reviewer/implementer carried over from review-state.json) bypasses
|
|
637
|
+
// selectAgent's blocklist filter. Without this check, a known-blocked
|
|
638
|
+
// family is relaunched immediately and the harness wastes a retry hitting
|
|
639
|
+
// the same limit. Reroute through normal selection on the next iteration.
|
|
640
|
+
log(fmt.status('WARN', `Pinned agent "${fmt.agent(chosen)}" is currently blocked in agents.local.json; rerouting via selectAgent for step "${step}".`));
|
|
641
|
+
tried.add(chosen);
|
|
642
|
+
chosen = null;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
assertAgentSupported(chosen);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
if (err.code !== 'LAUNCHER_UNAVAILABLE') {
|
|
650
|
+
throw err;
|
|
651
|
+
}
|
|
652
|
+
log(fmt.status('WARN', err.message));
|
|
653
|
+
// Only reroute for launcher-availability failures (missing or probe-failed).
|
|
654
|
+
tried.add(chosen);
|
|
655
|
+
// If the caller pinned a specific agent, allow one retry that ignores
|
|
656
|
+
// the override and falls back to normal selection (matches limit-hit logic).
|
|
657
|
+
if (agentOverride && agentOverride === chosen && iteration === 1) {
|
|
658
|
+
chosen = null;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
chosen = null;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
tried.add(chosen);
|
|
665
|
+
launched.add(chosen);
|
|
666
|
+
|
|
667
|
+
const launcher = LAUNCHERS[chosen];
|
|
668
|
+
log(fmt.status('INFO', `Selected agent for step "${step}": ${fmt.agent(chosen)}${iteration > 1 ? ` (attempt ${iteration})` : ''}`));
|
|
669
|
+
|
|
670
|
+
// Enforce the agent family as the Forgejo identity (ADR 0029 / task-095).
|
|
671
|
+
// FORGEJO_USER is set last so the harness-selected identity always wins;
|
|
672
|
+
// a caller-supplied env.FORGEJO_USER cannot override it.
|
|
673
|
+
const agentEnv = { ...env, FORGEJO_USER: chosen };
|
|
674
|
+
|
|
675
|
+
// Decide whether to resume the agent's prior session for this (slug, role).
|
|
676
|
+
// Only honored when the caller passed slug+role+worktree AND the previous
|
|
677
|
+
// marker matches the chosen agent family (a fallback to a different family
|
|
678
|
+
// invalidates the prior session).
|
|
679
|
+
const resume = Boolean(
|
|
680
|
+
worktree && slug && role &&
|
|
681
|
+
RESUME_CAPABLE.has(chosen) &&
|
|
682
|
+
sessionsModule.shouldResume(worktree, slug, role, chosen)
|
|
683
|
+
);
|
|
684
|
+
const sessionId = sessionsModule.getSessionId(worktree, slug, role);
|
|
685
|
+
if (slug && role) {
|
|
686
|
+
if (resume) {
|
|
687
|
+
log(fmt.status('INFO', `Resuming ${fmt.agent(chosen)} session for ${fmt.slug(slug)} (${role}).${sessionId ? ` Session: ${sessionId}` : ''}`));
|
|
688
|
+
} else if (RESUME_CAPABLE.has(chosen)) {
|
|
689
|
+
log(fmt.status('INFO', `No prior ${fmt.agent(chosen)} session for ${fmt.slug(slug)} (${role}); launching fresh.`));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Resolve the prompt string. If a function was provided, call it with the
|
|
694
|
+
// currently chosen agent name (TASK-1051). This ensures that if startAgent
|
|
695
|
+
// falls back to a different family after a limit hit, the fallback agent
|
|
696
|
+
// receives a prompt tailored to its own identity.
|
|
697
|
+
const actualPrompt = typeof prompt === 'function' ? prompt(chosen) : prompt;
|
|
698
|
+
|
|
699
|
+
// Resolve the per-family model override (adapters.agents.models[chosen]).
|
|
700
|
+
// null when the family is not configured, in which case the launcher omits
|
|
701
|
+
// the model flag entirely and the agent uses its own default.
|
|
702
|
+
const model = resolveAgentModelFn(chosen, worktree || process.cwd());
|
|
703
|
+
if (model) {
|
|
704
|
+
log(fmt.status('INFO', `Using configured model for ${fmt.agent(chosen)}: ${model}`));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const watchdogConfig = resolveNoOutputWatchdogConfig(noOutputWatchdog, step);
|
|
708
|
+
const launchResult = launcher({
|
|
709
|
+
prompt: actualPrompt,
|
|
710
|
+
worktree,
|
|
711
|
+
env: agentEnv,
|
|
712
|
+
resume,
|
|
713
|
+
sessionId,
|
|
714
|
+
model,
|
|
715
|
+
slug,
|
|
716
|
+
role,
|
|
717
|
+
teeOptions: watchdogConfig ? {
|
|
718
|
+
noOutputWatchdog: {
|
|
719
|
+
...watchdogConfig,
|
|
720
|
+
onNoOutput: ({ pid, elapsedMs }) => {
|
|
721
|
+
const stage = elapsedMs < (step === 'draft' ? DRAFT_NO_OUTPUT_INITIAL_DELAY_MS : DEFAULT_NO_OUTPUT_INITIAL_DELAY_MS)
|
|
722
|
+
? 'starting up'
|
|
723
|
+
: 'running';
|
|
724
|
+
log(fmt.status(
|
|
725
|
+
'INFO',
|
|
726
|
+
`No output yet from ${fmt.agent(chosen)} for step "${step}" after ${formatElapsed(elapsedMs)} ` +
|
|
727
|
+
`(pid ${pid || 'unknown'}, agent ${stage}). ` +
|
|
728
|
+
`Launcher is still running; stdout/stderr have not produced visible output.`
|
|
729
|
+
));
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} : {}
|
|
733
|
+
});
|
|
734
|
+
const { invocation, resultPromise } = launchResult;
|
|
735
|
+
if (invocation) {
|
|
736
|
+
log(fmt.status('INFO', `Launching: ${fmt.command(`${invocation.command} ${invocation.args.join(' ')}`)}`));
|
|
737
|
+
if (invocation.options && invocation.options.cwd) {
|
|
738
|
+
log(fmt.status('INFO', `Working directory: ${fmt.path(invocation.options.cwd)}`));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (onLaunch) {
|
|
743
|
+
await onLaunch({ agent: chosen, invocation });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const result = resultPromise ? await resultPromise : launchResult.result;
|
|
747
|
+
|
|
748
|
+
// Pass exit metadata so detectLimitHit only treats matching transcript text
|
|
749
|
+
// as a real limit hit when the launcher actually failed. A successful run
|
|
750
|
+
// (status === 0) that happens to contain limit-hit phrases — for example,
|
|
751
|
+
// an agent reviewing code or logs that quote those phrases — must not block
|
|
752
|
+
// the healthy agent.
|
|
753
|
+
const limitHit = detectLimitHitFn({
|
|
754
|
+
agent: chosen,
|
|
755
|
+
stdout: result && result.stdout,
|
|
756
|
+
stderr: result && result.stderr,
|
|
757
|
+
status: result && result.status,
|
|
758
|
+
signal: result && result.signal,
|
|
759
|
+
error: result && result.error
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
if (limitHit) {
|
|
763
|
+
log(fmt.status('WARN', `Limit hit detected for ${fmt.agent(chosen)}; reset estimate "${limitHit.until}" (${limitHit.source}). Blocking and retrying.`));
|
|
764
|
+
try {
|
|
765
|
+
const blockResult = updateAgentBlockFn(chosen, limitHit.until);
|
|
766
|
+
log(fmt.status('INFO', `Wrote blocklist entry for ${fmt.agent(chosen)} -> ${fmt.path(blockResult.path)}`));
|
|
767
|
+
} catch (err) {
|
|
768
|
+
log(fmt.status('WARN', `Could not persist blocklist entry for ${fmt.agent(chosen)}: ${err.message}`));
|
|
769
|
+
}
|
|
770
|
+
if (typeof onLimitHit === 'function') {
|
|
771
|
+
onLimitHit({ agent: chosen, until: limitHit.until, source: limitHit.source });
|
|
772
|
+
}
|
|
773
|
+
// Reset chosen so next iteration reselects, but only when no explicit override.
|
|
774
|
+
// If the caller pinned a specific agent, fail loudly — there is no fallback.
|
|
775
|
+
if (agentOverride && agentOverride === chosen && iteration === 1) {
|
|
776
|
+
// Allow one retry that ignores the override.
|
|
777
|
+
chosen = null;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
chosen = null;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Reroute if the launcher binary could not be started (ENOENT = not found, EACCES = not executable).
|
|
785
|
+
if (result && result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {
|
|
786
|
+
log(fmt.status('WARN', `Launcher for "${chosen}" could not be started (${result.error.code}); rerouting.`));
|
|
787
|
+
tried.add(chosen);
|
|
788
|
+
if (agentOverride && agentOverride === chosen && iteration === 1) {
|
|
789
|
+
chosen = null;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
chosen = null;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Detect launch failure: agent started but exited with non-zero status and
|
|
797
|
+
// no limit-hit was detected. This catches errors like "Model not found" in
|
|
798
|
+
// opencode that cause the launcher to exit immediately with an error code.
|
|
799
|
+
// Retry with the next eligible agent instead of returning the failure.
|
|
800
|
+
// Only treat `status !== null && status !== 0` or `signal` (with no spawn
|
|
801
|
+
// error) as a launch failure; `status: null` without signal is ambiguous
|
|
802
|
+
// (spawn-tee close event can emit null code) and should not trigger a retry.
|
|
803
|
+
const launchFailed = result &&
|
|
804
|
+
((result.status !== null && result.status !== 0) || (result.signal && !result.error)) &&
|
|
805
|
+
!limitHit;
|
|
806
|
+
if (launchFailed) {
|
|
807
|
+
const exitInfo = result.signal
|
|
808
|
+
? `signal ${result.signal}`
|
|
809
|
+
: `exit ${result.status}`;
|
|
810
|
+
const stderrSnippet = result && result.stderr
|
|
811
|
+
? ` (${result.stderr.trim().split('\n')[0]})`
|
|
812
|
+
: '';
|
|
813
|
+
log(fmt.status('WARN', `Agent ${fmt.agent(chosen)} failed to complete (${exitInfo}${stderrSnippet}); retrying with next eligible agent.`));
|
|
814
|
+
agentErrors.set(chosen, {
|
|
815
|
+
exitInfo,
|
|
816
|
+
stderr: result.stderr,
|
|
817
|
+
stdout: result.stdout,
|
|
818
|
+
signal: result.signal,
|
|
819
|
+
status: result.status,
|
|
820
|
+
});
|
|
821
|
+
tried.add(chosen);
|
|
822
|
+
launched.add(chosen);
|
|
823
|
+
chosen = null;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
// Record the marker so a subsequent same-(slug, role) launch knows which
|
|
827
|
+
// family last ran here. We only persist when the run exited cleanly
|
|
828
|
+
// (status 0 and no spawn error); a failed launch should not overwrite
|
|
829
|
+
// the canonical session marker with a stale transcript.
|
|
830
|
+
if (worktree && slug && role && result && result.status === 0 && !result.error) {
|
|
831
|
+
try {
|
|
832
|
+
const sessionId = result && result.sessionId ? result.sessionId : null;
|
|
833
|
+
sessionsModule.writeSession(worktree, slug, role, { agent: chosen, sessionId });
|
|
834
|
+
} catch (err) {
|
|
835
|
+
log(fmt.status('WARN', `Could not persist session marker for ${fmt.slug(slug)} (${role}): ${err.message}`));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return { agent: chosen, invocation, result };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Legacy alias kept for backwards compatibility — draft.js calls this directly.
|
|
844
|
+
// Returns { agent, invocation, result } so callers can log which agent ran.
|
|
845
|
+
async function startDraftAgent(opts = {}) {
|
|
846
|
+
return startAgent('draft', opts);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
module.exports = {
|
|
850
|
+
KNOWN_AGENT_NAMES,
|
|
851
|
+
WORKFLOW_AGENT_NAMES,
|
|
852
|
+
startAgent,
|
|
853
|
+
startDraftAgent,
|
|
854
|
+
selectAgent,
|
|
855
|
+
eligibleAgentsForStep,
|
|
856
|
+
readAgentConfig,
|
|
857
|
+
readAgentConfigOrExit,
|
|
858
|
+
assertAgentSupported,
|
|
859
|
+
workflowLauncherStatus,
|
|
860
|
+
setCommandPathProbe: (fn) => { _commandPathProbe = fn; },
|
|
861
|
+
isAgentBlocked,
|
|
862
|
+
parseBlockUntil,
|
|
863
|
+
isInvalidAgentConfigError,
|
|
864
|
+
updateAgentBlock,
|
|
865
|
+
resolveBlocklistTargetPath,
|
|
866
|
+
resolveNoOutputWatchdogConfig
|
|
867
|
+
};
|