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