@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
package/agent-pool.mjs
ADDED
|
@@ -0,0 +1,2403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-pool.mjs — Universal SDK-Aware Ephemeral Agent Pool
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS:
|
|
5
|
+
* ────────────────
|
|
6
|
+
* The primary agent in monitor.mjs is a long-lived singleton thread.
|
|
7
|
+
* Every operation that calls `execPrimaryPrompt` serialises behind that single
|
|
8
|
+
* thread — task attempts, conflict resolution, follow-ups, and health-checks
|
|
9
|
+
* all compete for the same lock. Under load (or when a single prompt is
|
|
10
|
+
* slow) this creates a bottleneck that stalls the entire monitor pipeline.
|
|
11
|
+
*
|
|
12
|
+
* This module provides **ephemeral, per-operation SDK threads** that spin up
|
|
13
|
+
* on demand and tear down after a single prompt completes. Each call gets its
|
|
14
|
+
* own isolated thread, so multiple operations can run concurrently without
|
|
15
|
+
* blocking each other.
|
|
16
|
+
*
|
|
17
|
+
* MULTI-SDK SUPPORT:
|
|
18
|
+
* ──────────────────
|
|
19
|
+
* The pool dynamically selects the correct SDK adapter (Codex, Copilot, or
|
|
20
|
+
* Claude) based on configuration. Resolution order:
|
|
21
|
+
* 1. `AGENT_POOL_SDK` env var (explicit override)
|
|
22
|
+
* 2. `PRIMARY_AGENT` env var → maps to SDK
|
|
23
|
+
* 3. `loadConfig().agentPool.sdk` from `openfleet.config.json`
|
|
24
|
+
* 4. Fallback chain through available SDKs
|
|
25
|
+
*
|
|
26
|
+
* EXPORTS:
|
|
27
|
+
* launchEphemeralThread(prompt, cwd, timeoutMs, extra?)
|
|
28
|
+
* → Low-level: spawns a fresh SDK thread, runs one prompt,
|
|
29
|
+
* returns { success, output, items, error, sdk }.
|
|
30
|
+
*
|
|
31
|
+
* execPooledPrompt(userMessage, options?)
|
|
32
|
+
* → High-level: matches the execPrimaryPrompt signature
|
|
33
|
+
* ({ finalResponse, items, usage }) so callers in monitor.mjs can
|
|
34
|
+
* swap in without changing surrounding code.
|
|
35
|
+
*
|
|
36
|
+
* getPoolSdkName() → returns current pool SDK name
|
|
37
|
+
* setPoolSdk(name) → override pool SDK at runtime
|
|
38
|
+
* resetPoolSdkCache() → force re-resolution
|
|
39
|
+
* getAvailableSdks() → returns list of non-disabled SDKs
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { resolve, dirname } from "node:path";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
44
|
+
import { loadConfig } from "./config.mjs";
|
|
45
|
+
import { resolveRepoRoot } from "./repo-root.mjs";
|
|
46
|
+
import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
|
|
47
|
+
|
|
48
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
49
|
+
const __dirname = dirname(__filename);
|
|
50
|
+
|
|
51
|
+
/** Repository root for the active workspace */
|
|
52
|
+
const REPO_ROOT = resolveRepoRoot();
|
|
53
|
+
|
|
54
|
+
/** Default timeout: 6 hours — agents should run until the stream-based watchdog detects real issues */
|
|
55
|
+
const DEFAULT_TIMEOUT_MS = 6 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hard timeout buffer: added on top of the soft timeout.
|
|
59
|
+
* If the SDK's async iterator ignores the AbortSignal, this hard timeout
|
|
60
|
+
* forcibly breaks the Promise.race to prevent infinite hangs.
|
|
61
|
+
*/
|
|
62
|
+
const HARD_TIMEOUT_BUFFER_MS = 5 * 60_000; // 5 minutes
|
|
63
|
+
|
|
64
|
+
/** Tag for console logging */
|
|
65
|
+
const TAG = "[agent-pool]";
|
|
66
|
+
|
|
67
|
+
function envFlagEnabled(value) {
|
|
68
|
+
const raw = String(value ?? "")
|
|
69
|
+
.trim()
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
return ["1", "true", "yes", "on", "y"].includes(raw);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shouldAutoApproveCopilotPermissions() {
|
|
75
|
+
const raw = process.env.COPILOT_AUTO_APPROVE_PERMISSIONS;
|
|
76
|
+
if (raw === undefined || raw === null || String(raw).trim() === "") {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return envFlagEnabled(raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildCopilotPermissionHandler() {
|
|
83
|
+
if (!shouldAutoApproveCopilotPermissions()) return undefined;
|
|
84
|
+
return async () => ({ kind: "approved" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shouldFallbackForSdkError(error) {
|
|
88
|
+
if (!error) return false;
|
|
89
|
+
const message = String(error).toLowerCase();
|
|
90
|
+
if (!message) return false;
|
|
91
|
+
if (message.includes("not available")) return true;
|
|
92
|
+
if (message.includes("missing finish_reason")) return true;
|
|
93
|
+
if (message.includes("missing") && message.includes("finish_reason")) return true;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const OPENAI_ENV_KEYS = [
|
|
98
|
+
"OPENAI_API_KEY",
|
|
99
|
+
"OPENAI_BASE_URL",
|
|
100
|
+
"OPENAI_ORGANIZATION",
|
|
101
|
+
"OPENAI_PROJECT",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
async function withSanitizedOpenAiEnv(fn) {
|
|
105
|
+
const saved = {};
|
|
106
|
+
for (const key of OPENAI_ENV_KEYS) {
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(process.env, key)) {
|
|
108
|
+
saved[key] = process.env[key];
|
|
109
|
+
delete process.env[key];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return await fn();
|
|
114
|
+
} finally {
|
|
115
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
116
|
+
if (value !== undefined) process.env[key] = value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build Codex SDK constructor options with Azure auto-detection.
|
|
123
|
+
* When OPENAI_BASE_URL points to Azure, configures the SDK with Azure
|
|
124
|
+
* provider settings via `config` and maps the API key via `env`.
|
|
125
|
+
* Otherwise strips OPENAI_BASE_URL so the SDK uses its default auth.
|
|
126
|
+
*/
|
|
127
|
+
function buildCodexSdkOptions() {
|
|
128
|
+
const { env: resolvedEnv } = resolveCodexProfileRuntime(process.env);
|
|
129
|
+
const baseUrl = resolvedEnv.OPENAI_BASE_URL || "";
|
|
130
|
+
const isAzure = baseUrl.includes(".openai.azure.com");
|
|
131
|
+
const env = { ...resolvedEnv };
|
|
132
|
+
// Always strip OPENAI_BASE_URL — for Azure we use config overrides,
|
|
133
|
+
// for non-Azure the CLI should use its built-in endpoint.
|
|
134
|
+
delete env.OPENAI_BASE_URL;
|
|
135
|
+
|
|
136
|
+
if (isAzure) {
|
|
137
|
+
// Map OPENAI_API_KEY → AZURE_OPENAI_API_KEY for Azure auth
|
|
138
|
+
if (env.OPENAI_API_KEY && !env.AZURE_OPENAI_API_KEY) {
|
|
139
|
+
env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
|
|
140
|
+
}
|
|
141
|
+
const azureModel = env.CODEX_MODEL || undefined;
|
|
142
|
+
return {
|
|
143
|
+
env,
|
|
144
|
+
config: {
|
|
145
|
+
model_provider: "azure",
|
|
146
|
+
model_providers: {
|
|
147
|
+
azure: {
|
|
148
|
+
name: "Azure OpenAI",
|
|
149
|
+
base_url: baseUrl,
|
|
150
|
+
env_key: "AZURE_OPENAI_API_KEY",
|
|
151
|
+
wire_api: "responses",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
...(azureModel ? { model: azureModel } : {}),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { env };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// SDK Adapter Registry
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @typedef {Object} SdkAdapter
|
|
167
|
+
* @property {string} name Human-readable SDK name.
|
|
168
|
+
* @property {Function} load Async loader returning the launcher fn.
|
|
169
|
+
* @property {string} envDisableKey Env var name that disables this SDK.
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Registry of supported SDK adapters.
|
|
174
|
+
* Each entry maps a canonical name to its loader and disable-check env var.
|
|
175
|
+
* @type {Record<string, SdkAdapter>}
|
|
176
|
+
*/
|
|
177
|
+
const SDK_ADAPTERS = {
|
|
178
|
+
codex: {
|
|
179
|
+
name: "codex",
|
|
180
|
+
load: loadCodexAdapter,
|
|
181
|
+
envDisableKey: "CODEX_SDK_DISABLED",
|
|
182
|
+
},
|
|
183
|
+
copilot: {
|
|
184
|
+
name: "copilot",
|
|
185
|
+
load: loadCopilotAdapter,
|
|
186
|
+
envDisableKey: "COPILOT_SDK_DISABLED",
|
|
187
|
+
},
|
|
188
|
+
claude: {
|
|
189
|
+
name: "claude",
|
|
190
|
+
load: loadClaudeAdapter,
|
|
191
|
+
envDisableKey: "CLAUDE_SDK_DISABLED",
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Ordered fallback chain for SDK resolution */
|
|
196
|
+
const SDK_FALLBACK_ORDER = ["codex", "copilot", "claude"];
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// SDK Resolution & Cache
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/** @type {string|null} Cached resolved SDK name */
|
|
203
|
+
let resolvedSdkName = null;
|
|
204
|
+
|
|
205
|
+
/** @type {boolean} Whether initial resolution has been logged */
|
|
206
|
+
let resolutionLogged = false;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check whether an SDK is disabled via its env var.
|
|
210
|
+
* @param {string} name SDK canonical name.
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
function isDisabled(name) {
|
|
214
|
+
const adapter = SDK_ADAPTERS[name];
|
|
215
|
+
if (!adapter) return true;
|
|
216
|
+
return envFlagEnabled(process.env[adapter.envDisableKey]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const MONITOR_MONITOR_TASK_KEY = "monitor-monitor";
|
|
220
|
+
let monitorMonitorTimeoutBoundsWarningKey = "";
|
|
221
|
+
let monitorMonitorTimeoutAdjustmentKey = "";
|
|
222
|
+
|
|
223
|
+
function parsePositiveTimeoutMs(value) {
|
|
224
|
+
const parsed = Number(value);
|
|
225
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
226
|
+
return Math.trunc(parsed);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function clampMonitorMonitorTimeout(timeoutMs, taskKey) {
|
|
230
|
+
if (String(taskKey || "").trim() !== MONITOR_MONITOR_TASK_KEY) {
|
|
231
|
+
return timeoutMs;
|
|
232
|
+
}
|
|
233
|
+
const baseTimeoutMs = parsePositiveTimeoutMs(timeoutMs);
|
|
234
|
+
if (baseTimeoutMs === null) return timeoutMs;
|
|
235
|
+
|
|
236
|
+
const minMs = parsePositiveTimeoutMs(
|
|
237
|
+
process.env.DEVMODE_MONITOR_MONITOR_TIMEOUT_MIN_MS,
|
|
238
|
+
);
|
|
239
|
+
const maxEnv = parsePositiveTimeoutMs(
|
|
240
|
+
process.env.DEVMODE_MONITOR_MONITOR_TIMEOUT_MAX_MS,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
let maxMs = maxEnv;
|
|
244
|
+
if (minMs !== null && maxMs !== null && maxMs < minMs) {
|
|
245
|
+
const warningKey = `${minMs}:${maxMs}`;
|
|
246
|
+
if (monitorMonitorTimeoutBoundsWarningKey !== warningKey) {
|
|
247
|
+
monitorMonitorTimeoutBoundsWarningKey = warningKey;
|
|
248
|
+
console.warn(
|
|
249
|
+
`${TAG} invalid monitor-monitor timeout bounds: DEVMODE_MONITOR_MONITOR_TIMEOUT_MAX_MS=${maxMs} is lower than DEVMODE_MONITOR_MONITOR_TIMEOUT_MIN_MS=${minMs}. Ignoring max bound.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
maxMs = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (minMs === null && maxMs === null) {
|
|
256
|
+
return baseTimeoutMs;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let bounded = baseTimeoutMs;
|
|
260
|
+
if (minMs !== null && bounded < minMs) bounded = minMs;
|
|
261
|
+
if (maxMs !== null && bounded > maxMs) bounded = maxMs;
|
|
262
|
+
|
|
263
|
+
if (bounded !== baseTimeoutMs) {
|
|
264
|
+
const adjustmentKey = `${baseTimeoutMs}:${bounded}:${minMs ?? "off"}:${maxMs ?? "off"}`;
|
|
265
|
+
if (monitorMonitorTimeoutAdjustmentKey !== adjustmentKey) {
|
|
266
|
+
monitorMonitorTimeoutAdjustmentKey = adjustmentKey;
|
|
267
|
+
console.log(
|
|
268
|
+
`${TAG} monitor-monitor timeout adjusted ${baseTimeoutMs}ms -> ${bounded}ms (min=${minMs ?? "off"}, max=${maxMs ?? "off"})`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return bounded;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Log which SDK was selected (only on first resolution).
|
|
277
|
+
* @param {string} name SDK name.
|
|
278
|
+
* @param {string} source How it was determined.
|
|
279
|
+
*/
|
|
280
|
+
function logResolution(name, source) {
|
|
281
|
+
if (!resolutionLogged) {
|
|
282
|
+
console.log(`${TAG} SDK selected: ${name} (via ${source})`);
|
|
283
|
+
resolutionLogged = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Resolve which SDK the pool should use.
|
|
289
|
+
*
|
|
290
|
+
* Resolution order:
|
|
291
|
+
* 1. Runtime override via `setPoolSdk()` (already cached)
|
|
292
|
+
* 2. `AGENT_POOL_SDK` env var
|
|
293
|
+
* 3. `PRIMARY_AGENT` env var
|
|
294
|
+
* 4. `loadConfig().agentPool.sdk` from openfleet.config.json
|
|
295
|
+
* 5. First non-disabled SDK in fallback chain
|
|
296
|
+
*
|
|
297
|
+
* @returns {string} Canonical SDK name (e.g. "codex", "copilot", "claude").
|
|
298
|
+
*/
|
|
299
|
+
function resolvePoolSdkName() {
|
|
300
|
+
if (resolvedSdkName) return resolvedSdkName;
|
|
301
|
+
|
|
302
|
+
// 1. AGENT_POOL_SDK env var (explicit override)
|
|
303
|
+
const envPoolSdk = (process.env.AGENT_POOL_SDK || "").trim().toLowerCase();
|
|
304
|
+
if (envPoolSdk && SDK_ADAPTERS[envPoolSdk] && !isDisabled(envPoolSdk)) {
|
|
305
|
+
resolvedSdkName = envPoolSdk;
|
|
306
|
+
logResolution(envPoolSdk, "AGENT_POOL_SDK env");
|
|
307
|
+
return resolvedSdkName;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 2. PRIMARY_AGENT env var
|
|
311
|
+
const envPrimaryRaw = (process.env.PRIMARY_AGENT || "").trim().toLowerCase();
|
|
312
|
+
// Normalize: "copilot-sdk" → "copilot", "codex-sdk" → "codex", etc.
|
|
313
|
+
const envPrimary = envPrimaryRaw.replace(/-sdk$/, "");
|
|
314
|
+
if (envPrimary && SDK_ADAPTERS[envPrimary] && !isDisabled(envPrimary)) {
|
|
315
|
+
resolvedSdkName = envPrimary;
|
|
316
|
+
logResolution(envPrimary, "PRIMARY_AGENT env");
|
|
317
|
+
return resolvedSdkName;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 3. openfleet.config.json → agentPool.sdk
|
|
321
|
+
try {
|
|
322
|
+
const config = loadConfig();
|
|
323
|
+
const configSdk = (
|
|
324
|
+
config?.agentPool?.sdk ||
|
|
325
|
+
config?.primaryAgent ||
|
|
326
|
+
""
|
|
327
|
+
).toLowerCase();
|
|
328
|
+
if (configSdk && SDK_ADAPTERS[configSdk] && !isDisabled(configSdk)) {
|
|
329
|
+
resolvedSdkName = configSdk;
|
|
330
|
+
logResolution(configSdk, "openfleet.config.json");
|
|
331
|
+
return resolvedSdkName;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// config.mjs not available — continue with fallback
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 4. Fallback chain: first non-disabled SDK
|
|
338
|
+
for (const name of SDK_FALLBACK_ORDER) {
|
|
339
|
+
if (!isDisabled(name)) {
|
|
340
|
+
resolvedSdkName = name;
|
|
341
|
+
logResolution(name, "fallback chain");
|
|
342
|
+
return resolvedSdkName;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// All disabled — default to codex anyway (will fail at load time)
|
|
347
|
+
resolvedSdkName = "codex";
|
|
348
|
+
logResolution("codex", "last resort (all SDKs disabled)");
|
|
349
|
+
return resolvedSdkName;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Public SDK management API
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get the name of the currently resolved pool SDK.
|
|
358
|
+
* @returns {string} SDK name ("codex", "copilot", or "claude").
|
|
359
|
+
*/
|
|
360
|
+
export function getPoolSdkName() {
|
|
361
|
+
return resolvePoolSdkName();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Override the pool SDK at runtime.
|
|
366
|
+
* @param {string} name SDK name ("codex", "copilot", or "claude").
|
|
367
|
+
* @throws {Error} If the name is not a recognised SDK.
|
|
368
|
+
*/
|
|
369
|
+
export function setPoolSdk(name) {
|
|
370
|
+
const normalised = (name || "").trim().toLowerCase();
|
|
371
|
+
if (!SDK_ADAPTERS[normalised]) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`${TAG} unknown SDK "${name}". Valid: ${Object.keys(SDK_ADAPTERS).join(", ")}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
resolvedSdkName = normalised;
|
|
377
|
+
resolutionLogged = false;
|
|
378
|
+
logResolution(normalised, "setPoolSdk() runtime override");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Force re-resolution of the pool SDK on next use.
|
|
383
|
+
* Useful after environment changes.
|
|
384
|
+
*/
|
|
385
|
+
export function resetPoolSdkCache() {
|
|
386
|
+
resolvedSdkName = null;
|
|
387
|
+
resolutionLogged = false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Returns the list of SDK names that are not disabled.
|
|
392
|
+
* @returns {string[]}
|
|
393
|
+
*/
|
|
394
|
+
export function getAvailableSdks() {
|
|
395
|
+
return Object.keys(SDK_ADAPTERS).filter((name) => !isDisabled(name));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Per-SDK Ephemeral Thread Launchers
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Launch a single ephemeral prompt via the **Codex SDK**.
|
|
404
|
+
*
|
|
405
|
+
* Creates a fresh `Codex` instance + thread, streams one turn, tears down.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} prompt Prompt text.
|
|
408
|
+
* @param {string} cwd Working directory.
|
|
409
|
+
* @param {number} timeoutMs Abort timeout in ms.
|
|
410
|
+
* @param {object} extra Optional { onEvent, abortController }.
|
|
411
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
412
|
+
*/
|
|
413
|
+
async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
414
|
+
const { onEvent, abortController: externalAC, onThreadReady = null } = extra;
|
|
415
|
+
|
|
416
|
+
let reportedThreadId = null;
|
|
417
|
+
const emitThreadReady = (threadId) => {
|
|
418
|
+
if (!threadId || threadId === reportedThreadId) return;
|
|
419
|
+
reportedThreadId = threadId;
|
|
420
|
+
if (typeof onThreadReady === "function") {
|
|
421
|
+
try {
|
|
422
|
+
onThreadReady(threadId, "codex");
|
|
423
|
+
} catch {
|
|
424
|
+
/* best effort */
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// ── 1. Load the SDK ──────────────────────────────────────────────────────
|
|
430
|
+
let CodexClass;
|
|
431
|
+
try {
|
|
432
|
+
const mod = await import("@openai/codex-sdk");
|
|
433
|
+
CodexClass = mod.Codex;
|
|
434
|
+
if (!CodexClass) throw new Error("Codex export not found in SDK module");
|
|
435
|
+
} catch (err) {
|
|
436
|
+
return {
|
|
437
|
+
success: false,
|
|
438
|
+
output: "",
|
|
439
|
+
items: [],
|
|
440
|
+
error: `Codex SDK not available: ${err.message}`,
|
|
441
|
+
sdk: "codex",
|
|
442
|
+
threadId: null,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── 2. Create an ephemeral thread ────────────────────────────────────────
|
|
447
|
+
// Sandbox policy: configurable via CODEX_SANDBOX env var.
|
|
448
|
+
// Default is workspace-write: permissive for repo tasks while avoiding full host access.
|
|
449
|
+
const sandboxPolicy = process.env.CODEX_SANDBOX || "workspace-write";
|
|
450
|
+
|
|
451
|
+
// Pass feature overrides via --config so sub-agent and memory features are
|
|
452
|
+
// available even if ~/.codex/config.toml hasn't been patched yet.
|
|
453
|
+
const codexOpts = buildCodexSdkOptions();
|
|
454
|
+
codexOpts.config = {
|
|
455
|
+
...(codexOpts.config || {}),
|
|
456
|
+
features: {
|
|
457
|
+
child_agents_md: true,
|
|
458
|
+
collab: true,
|
|
459
|
+
memory_tool: true,
|
|
460
|
+
undo: true,
|
|
461
|
+
steer: true,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
const codex = new CodexClass(codexOpts);
|
|
465
|
+
const thread = codex.startThread({
|
|
466
|
+
sandboxMode: sandboxPolicy,
|
|
467
|
+
workingDirectory: cwd,
|
|
468
|
+
skipGitRepoCheck: true,
|
|
469
|
+
approvalPolicy: "never",
|
|
470
|
+
webSearchMode: "live",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
if (!thread) {
|
|
474
|
+
return {
|
|
475
|
+
success: false,
|
|
476
|
+
output: "",
|
|
477
|
+
items: [],
|
|
478
|
+
error:
|
|
479
|
+
"Codex SDK startThread() returned null — SDK may be misconfigured or API unreachable",
|
|
480
|
+
sdk: "codex",
|
|
481
|
+
threadId: null,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
emitThreadReady(thread.id || null);
|
|
485
|
+
|
|
486
|
+
// ── 3. Timeout / abort wiring ────────────────────────────────────────────
|
|
487
|
+
const controller = externalAC || new AbortController();
|
|
488
|
+
const timer = setTimeout(() => controller.abort("timeout"), timeoutMs);
|
|
489
|
+
|
|
490
|
+
// Hard timeout: safety net if the SDK's async iterator ignores AbortSignal.
|
|
491
|
+
// Fires HARD_TIMEOUT_BUFFER_MS after the soft timeout to forcibly break the loop.
|
|
492
|
+
let hardTimer;
|
|
493
|
+
|
|
494
|
+
// ── 4. Stream the turn ───────────────────────────────────────────────────
|
|
495
|
+
try {
|
|
496
|
+
const turn = await thread.runStreamed(prompt, {
|
|
497
|
+
signal: controller.signal,
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
let finalResponse = "";
|
|
501
|
+
const allItems = [];
|
|
502
|
+
|
|
503
|
+
// Race the event iterator against a hard timeout.
|
|
504
|
+
// The soft timeout fires controller.abort() which the SDK should honor.
|
|
505
|
+
// The hard timeout is a safety net in case the SDK iterator ignores the abort.
|
|
506
|
+
const hardTimeoutPromise = new Promise((_, reject) => {
|
|
507
|
+
hardTimer = setTimeout(
|
|
508
|
+
() => reject(new Error("hard_timeout")),
|
|
509
|
+
timeoutMs + HARD_TIMEOUT_BUFFER_MS,
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const iterateEvents = async () => {
|
|
514
|
+
for await (const event of turn.events) {
|
|
515
|
+
if (controller.signal.aborted) break;
|
|
516
|
+
if (event?.type === "thread.started" && event?.thread_id) {
|
|
517
|
+
emitThreadReady(event.thread_id);
|
|
518
|
+
}
|
|
519
|
+
if (typeof onEvent === "function") {
|
|
520
|
+
try {
|
|
521
|
+
onEvent(event);
|
|
522
|
+
} catch {
|
|
523
|
+
/* caller errors must not kill stream */
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (event.type === "item.completed") {
|
|
527
|
+
allItems.push(event.item);
|
|
528
|
+
if (event.item.type === "agent_message" && event.item.text) {
|
|
529
|
+
finalResponse += event.item.text + "\n";
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
await Promise.race([iterateEvents(), hardTimeoutPromise]);
|
|
536
|
+
clearTimeout(hardTimer);
|
|
537
|
+
clearTimeout(timer);
|
|
538
|
+
|
|
539
|
+
const output =
|
|
540
|
+
finalResponse.trim() || "(Agent completed with no text output)";
|
|
541
|
+
return {
|
|
542
|
+
success: true,
|
|
543
|
+
output,
|
|
544
|
+
items: allItems,
|
|
545
|
+
error: null,
|
|
546
|
+
sdk: "codex",
|
|
547
|
+
threadId: thread.id || null,
|
|
548
|
+
};
|
|
549
|
+
} catch (err) {
|
|
550
|
+
clearTimeout(timer);
|
|
551
|
+
if (hardTimer) clearTimeout(hardTimer);
|
|
552
|
+
const isTimeout =
|
|
553
|
+
err.name === "AbortError" ||
|
|
554
|
+
String(err) === "timeout" ||
|
|
555
|
+
err.message === "hard_timeout";
|
|
556
|
+
if (isTimeout) {
|
|
557
|
+
return {
|
|
558
|
+
success: false,
|
|
559
|
+
output: "",
|
|
560
|
+
items: [],
|
|
561
|
+
error: `${TAG} codex timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout — SDK iterator unresponsive)" : ""}`,
|
|
562
|
+
sdk: "codex",
|
|
563
|
+
threadId: null,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
success: false,
|
|
568
|
+
output: "",
|
|
569
|
+
items: [],
|
|
570
|
+
error: err.message,
|
|
571
|
+
sdk: "codex",
|
|
572
|
+
threadId: null,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Build CLI arguments for ephemeral Copilot agent-pool sessions.
|
|
579
|
+
* Mirrors copilot-shell.mjs buildCliArgs() for feature parity.
|
|
580
|
+
*/
|
|
581
|
+
function buildPoolCopilotCliArgs() {
|
|
582
|
+
const args = [];
|
|
583
|
+
if (!envFlagEnabled(process.env.COPILOT_NO_EXPERIMENTAL)) {
|
|
584
|
+
args.push("--experimental");
|
|
585
|
+
}
|
|
586
|
+
if (!envFlagEnabled(process.env.COPILOT_NO_ALLOW_ALL)) {
|
|
587
|
+
args.push("--allow-all");
|
|
588
|
+
}
|
|
589
|
+
if (!envFlagEnabled(process.env.COPILOT_ENABLE_ASK_USER)) {
|
|
590
|
+
args.push("--no-ask-user");
|
|
591
|
+
}
|
|
592
|
+
args.push("--no-auto-update");
|
|
593
|
+
if (envFlagEnabled(process.env.COPILOT_ENABLE_ALL_GITHUB_MCP_TOOLS)) {
|
|
594
|
+
args.push("--enable-all-github-mcp-tools");
|
|
595
|
+
}
|
|
596
|
+
if (envFlagEnabled(process.env.COPILOT_DISABLE_BUILTIN_MCPS)) {
|
|
597
|
+
args.push("--disable-builtin-mcps");
|
|
598
|
+
}
|
|
599
|
+
const mcpConfigPath = process.env.COPILOT_ADDITIONAL_MCP_CONFIG;
|
|
600
|
+
if (mcpConfigPath) {
|
|
601
|
+
args.push("--additional-mcp-config", mcpConfigPath);
|
|
602
|
+
}
|
|
603
|
+
return args;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Auto-respond to agent user-input requests in pool sessions.
|
|
608
|
+
* Ensures ephemeral agents never block waiting for human input.
|
|
609
|
+
*/
|
|
610
|
+
function autoRespondToUserInput(request) {
|
|
611
|
+
if (request.choices && request.choices.length > 0) {
|
|
612
|
+
return { answer: request.choices[0], wasFreeform: false };
|
|
613
|
+
}
|
|
614
|
+
const question = (request.question || "").toLowerCase();
|
|
615
|
+
if (/\b(y\/n|yes\/no|confirm|proceed|continue)\b/i.test(question)) {
|
|
616
|
+
return { answer: "yes", wasFreeform: true };
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
answer: "Proceed with your best judgment. Do not wait for human input.",
|
|
620
|
+
wasFreeform: true,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Launch a single ephemeral prompt via the **Copilot SDK**.
|
|
626
|
+
*
|
|
627
|
+
* Creates a `CopilotClient` in LOCAL mode (stdio), starts it, resumes an
|
|
628
|
+
* existing session when available, otherwise creates a new one, sends the
|
|
629
|
+
* prompt, and collects the response.
|
|
630
|
+
*
|
|
631
|
+
* LOCAL mode ensures:
|
|
632
|
+
* - Full model access (gpt-5.3-codex, claude-sonnet-4.5, etc.)
|
|
633
|
+
* - MCP tool availability
|
|
634
|
+
* - Sub-agent support
|
|
635
|
+
* - No background session restrictions
|
|
636
|
+
*
|
|
637
|
+
* @param {string} prompt Prompt text.
|
|
638
|
+
* @param {string} cwd Working directory.
|
|
639
|
+
* @param {number} timeoutMs Abort timeout in ms.
|
|
640
|
+
* @param {object} extra Optional { onEvent, abortController, resumeThreadId, onThreadReady }.
|
|
641
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
642
|
+
*/
|
|
643
|
+
async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
644
|
+
const {
|
|
645
|
+
onEvent,
|
|
646
|
+
abortController: externalAC,
|
|
647
|
+
resumeThreadId = null,
|
|
648
|
+
onThreadReady = null,
|
|
649
|
+
model: requestedModel = null,
|
|
650
|
+
} = extra;
|
|
651
|
+
|
|
652
|
+
// ── 1. Load the SDK ──────────────────────────────────────────────────────
|
|
653
|
+
let CopilotClientClass;
|
|
654
|
+
try {
|
|
655
|
+
const mod = await import("@github/copilot-sdk");
|
|
656
|
+
CopilotClientClass = mod.CopilotClient || mod.default?.CopilotClient;
|
|
657
|
+
if (!CopilotClientClass) throw new Error("CopilotClient export not found");
|
|
658
|
+
} catch (err) {
|
|
659
|
+
return {
|
|
660
|
+
success: false,
|
|
661
|
+
output: "",
|
|
662
|
+
items: [],
|
|
663
|
+
error: `Copilot SDK not available: ${err.message}`,
|
|
664
|
+
sdk: "copilot",
|
|
665
|
+
threadId: null,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── 2. Detect auth token ─────────────────────────────────────────────────
|
|
670
|
+
const token =
|
|
671
|
+
process.env.COPILOT_CLI_TOKEN ||
|
|
672
|
+
process.env.GITHUB_TOKEN ||
|
|
673
|
+
process.env.GH_TOKEN ||
|
|
674
|
+
process.env.GITHUB_PAT ||
|
|
675
|
+
undefined;
|
|
676
|
+
|
|
677
|
+
// ── 3. Create & start ephemeral client (LOCAL mode) ──────────────────────
|
|
678
|
+
// Use stdio transport (local) by default for full model/tool access.
|
|
679
|
+
// Only use cliUrl (remote) if COPILOT_SESSION_MODE=remote is explicit.
|
|
680
|
+
const sessionMode = (process.env.COPILOT_SESSION_MODE || "local").trim().toLowerCase();
|
|
681
|
+
const cliUrl = process.env.COPILOT_CLI_URL || undefined;
|
|
682
|
+
|
|
683
|
+
const controller = externalAC || new AbortController();
|
|
684
|
+
const timer = setTimeout(() => controller.abort("timeout"), timeoutMs);
|
|
685
|
+
|
|
686
|
+
let client;
|
|
687
|
+
let unsubscribe = null;
|
|
688
|
+
let finalResponse = "";
|
|
689
|
+
const allItems = [];
|
|
690
|
+
const autoApprovePermissions = shouldAutoApproveCopilotPermissions();
|
|
691
|
+
const clientEnv = autoApprovePermissions
|
|
692
|
+
? {
|
|
693
|
+
...process.env,
|
|
694
|
+
COPILOT_ALLOW_ALL: process.env.COPILOT_ALLOW_ALL || "true",
|
|
695
|
+
}
|
|
696
|
+
: process.env;
|
|
697
|
+
try {
|
|
698
|
+
await withSanitizedOpenAiEnv(async () => {
|
|
699
|
+
let clientOpts;
|
|
700
|
+
if (sessionMode === "remote" && cliUrl) {
|
|
701
|
+
// Remote mode: connect to existing server (limited model/tool access)
|
|
702
|
+
clientOpts = { cliUrl, env: clientEnv };
|
|
703
|
+
} else {
|
|
704
|
+
// Local mode (default): stdio for full capability
|
|
705
|
+
const cliArgs = buildPoolCopilotCliArgs();
|
|
706
|
+
clientOpts = {
|
|
707
|
+
cwd,
|
|
708
|
+
env: clientEnv,
|
|
709
|
+
cliArgs,
|
|
710
|
+
useStdio: true,
|
|
711
|
+
};
|
|
712
|
+
if (token) {
|
|
713
|
+
clientOpts.githubToken = token;
|
|
714
|
+
clientOpts.token = token;
|
|
715
|
+
}
|
|
716
|
+
const cliPath =
|
|
717
|
+
process.env.COPILOT_CLI_PATH ||
|
|
718
|
+
process.env.GITHUB_COPILOT_CLI_PATH ||
|
|
719
|
+
undefined;
|
|
720
|
+
if (cliPath) clientOpts.cliPath = cliPath;
|
|
721
|
+
}
|
|
722
|
+
client = new CopilotClientClass(clientOpts);
|
|
723
|
+
await client.start();
|
|
724
|
+
});
|
|
725
|
+
} catch (err) {
|
|
726
|
+
clearTimeout(timer);
|
|
727
|
+
return {
|
|
728
|
+
success: false,
|
|
729
|
+
output: "",
|
|
730
|
+
items: [],
|
|
731
|
+
error: `Copilot client start failed: ${err.message}`,
|
|
732
|
+
sdk: "copilot",
|
|
733
|
+
threadId: null,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ── 4. Resume/create session ─────────────────────────────────────────────
|
|
738
|
+
try {
|
|
739
|
+
const sessionConfig = {
|
|
740
|
+
streaming: true,
|
|
741
|
+
workingDirectory: cwd,
|
|
742
|
+
systemMessage: {
|
|
743
|
+
mode: "replace",
|
|
744
|
+
content:
|
|
745
|
+
"You are an ephemeral task agent. Execute the given task immediately. " +
|
|
746
|
+
"Do NOT ask for confirmation. Produce concise, actionable output.",
|
|
747
|
+
},
|
|
748
|
+
infiniteSessions: { enabled: true },
|
|
749
|
+
// Auto-respond to user input requests — agent should never block
|
|
750
|
+
onUserInputRequest: autoRespondToUserInput,
|
|
751
|
+
};
|
|
752
|
+
const permissionHandler = buildCopilotPermissionHandler();
|
|
753
|
+
if (permissionHandler) {
|
|
754
|
+
sessionConfig.onPermissionRequest = permissionHandler;
|
|
755
|
+
}
|
|
756
|
+
const copilotModel = String(
|
|
757
|
+
requestedModel ||
|
|
758
|
+
process.env.COPILOT_MODEL ||
|
|
759
|
+
process.env.COPILOT_SDK_MODEL ||
|
|
760
|
+
"",
|
|
761
|
+
).trim();
|
|
762
|
+
if (copilotModel) sessionConfig.model = copilotModel;
|
|
763
|
+
|
|
764
|
+
// Reasoning effort: pass through if model supports it
|
|
765
|
+
const effort = (
|
|
766
|
+
process.env.COPILOT_REASONING_EFFORT ||
|
|
767
|
+
process.env.COPILOT_SDK_REASONING_EFFORT ||
|
|
768
|
+
""
|
|
769
|
+
).toLowerCase();
|
|
770
|
+
if (["low", "medium", "high", "xhigh"].includes(effort)) {
|
|
771
|
+
sessionConfig.reasoningEffort = effort;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let session = null;
|
|
775
|
+
if (resumeThreadId && typeof client.resumeSession === "function") {
|
|
776
|
+
try {
|
|
777
|
+
session = await client.resumeSession(resumeThreadId, sessionConfig);
|
|
778
|
+
} catch (resumeErr) {
|
|
779
|
+
console.warn(
|
|
780
|
+
`${TAG} copilot resume failed for session ${resumeThreadId}: ${resumeErr.message || resumeErr}. Starting fresh session.`,
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (!session) {
|
|
785
|
+
session = await client.createSession(sessionConfig);
|
|
786
|
+
}
|
|
787
|
+
const copilotSessionId =
|
|
788
|
+
session?.sessionId || session?.id || resumeThreadId || null;
|
|
789
|
+
if (copilotSessionId && typeof onThreadReady === "function") {
|
|
790
|
+
try {
|
|
791
|
+
onThreadReady(copilotSessionId, "copilot");
|
|
792
|
+
} catch {
|
|
793
|
+
/* best effort */
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── 5. Send prompt & collect response ──────────────────────────────────
|
|
798
|
+
// Wire up event listener if session supports it
|
|
799
|
+
if (typeof session.on === "function") {
|
|
800
|
+
unsubscribe = session.on((event) => {
|
|
801
|
+
if (!event) return;
|
|
802
|
+
allItems.push(event);
|
|
803
|
+
if (event.type === "assistant.message" && event.data?.content) {
|
|
804
|
+
finalResponse = event.data.content;
|
|
805
|
+
}
|
|
806
|
+
if (
|
|
807
|
+
event.type === "assistant.message_delta" &&
|
|
808
|
+
event.data?.deltaContent
|
|
809
|
+
) {
|
|
810
|
+
finalResponse += event.data.deltaContent;
|
|
811
|
+
}
|
|
812
|
+
if (typeof onEvent === "function") {
|
|
813
|
+
try {
|
|
814
|
+
onEvent(event);
|
|
815
|
+
} catch {
|
|
816
|
+
/* best effort */
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const formattedPrompt =
|
|
823
|
+
`# YOUR TASK — EXECUTE NOW\n\n${prompt}\n\n---\n` +
|
|
824
|
+
'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
|
|
825
|
+
|
|
826
|
+
const hasSend = typeof session.send === "function";
|
|
827
|
+
const hasSendAndWait = typeof session.sendAndWait === "function";
|
|
828
|
+
if (!hasSend && !hasSendAndWait) {
|
|
829
|
+
throw new Error("Copilot session does not support send");
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Prefer send()+idle when available. Some Copilot SDK builds enforce a
|
|
833
|
+
// fixed internal 300s idle timeout in sendAndWait() that ignores caller
|
|
834
|
+
// timeout, which can cause monitor-monitor failover loops.
|
|
835
|
+
const useRawSend = hasSend;
|
|
836
|
+
const sendPromise = useRawSend
|
|
837
|
+
? session.send.call(session, { prompt: formattedPrompt })
|
|
838
|
+
: session.sendAndWait.call(
|
|
839
|
+
session,
|
|
840
|
+
{ prompt: formattedPrompt },
|
|
841
|
+
timeoutMs,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
if (useRawSend && typeof session.on === "function") {
|
|
845
|
+
await new Promise((resolveP, rejectP) => {
|
|
846
|
+
let settled = false;
|
|
847
|
+
let off = null;
|
|
848
|
+
let idleTimer = null;
|
|
849
|
+
|
|
850
|
+
const finish = (cb) => {
|
|
851
|
+
if (settled) return;
|
|
852
|
+
settled = true;
|
|
853
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
854
|
+
if (typeof off === "function") off();
|
|
855
|
+
cb();
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const idleHandler = (event) => {
|
|
859
|
+
if (event?.type === "session.idle") return finish(resolveP);
|
|
860
|
+
if (event?.type === "session.error") {
|
|
861
|
+
return finish(() =>
|
|
862
|
+
rejectP(new Error(event.data?.message || "session error")),
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
off = session.on(idleHandler);
|
|
867
|
+
Promise.resolve(sendPromise).catch((err) => finish(() => rejectP(err)));
|
|
868
|
+
|
|
869
|
+
// Wire abort signal into this inner promise
|
|
870
|
+
if (controller.signal) {
|
|
871
|
+
const onAbort = () => finish(() => rejectP(new Error("timeout")));
|
|
872
|
+
if (controller.signal.aborted) {
|
|
873
|
+
onAbort();
|
|
874
|
+
} else {
|
|
875
|
+
controller.signal.addEventListener("abort", onAbort, {
|
|
876
|
+
once: true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
idleTimer = setTimeout(() => {
|
|
882
|
+
// If assistant output arrived but session.idle is missing/late, allow
|
|
883
|
+
// the run to continue rather than stalling for the full hard timeout.
|
|
884
|
+
if (finalResponse.trim()) return finish(resolveP);
|
|
885
|
+
finish(() => rejectP(new Error("timeout_waiting_for_idle")));
|
|
886
|
+
}, timeoutMs + 1000);
|
|
887
|
+
if (idleTimer && typeof idleTimer.unref === "function") {
|
|
888
|
+
idleTimer.unref();
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
} else {
|
|
892
|
+
// Hard timeout safety net for sendAndWait — mirrors the Codex SDK path.
|
|
893
|
+
// If sendAndWait ignores the abort signal, this forcibly breaks the hang.
|
|
894
|
+
const copilotHardTimeout = new Promise((_, reject) => {
|
|
895
|
+
const ht = setTimeout(
|
|
896
|
+
() => reject(new Error("hard_timeout")),
|
|
897
|
+
timeoutMs + HARD_TIMEOUT_BUFFER_MS,
|
|
898
|
+
);
|
|
899
|
+
// Don't let this timer keep the process alive
|
|
900
|
+
if (ht && typeof ht.unref === "function") ht.unref();
|
|
901
|
+
});
|
|
902
|
+
await Promise.race([sendPromise, copilotHardTimeout]);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const output =
|
|
906
|
+
finalResponse.trim() || "(Agent completed with no text output)";
|
|
907
|
+
return {
|
|
908
|
+
success: true,
|
|
909
|
+
output,
|
|
910
|
+
items: allItems,
|
|
911
|
+
error: null,
|
|
912
|
+
sdk: "copilot",
|
|
913
|
+
threadId: copilotSessionId,
|
|
914
|
+
};
|
|
915
|
+
} catch (err) {
|
|
916
|
+
const errMsg = String(err?.message || err || "");
|
|
917
|
+
const hasAssistantOutput = !!finalResponse.trim();
|
|
918
|
+
const isIdleWaitTimeout =
|
|
919
|
+
/session\.idle/i.test(errMsg) && /timeout/i.test(errMsg);
|
|
920
|
+
const isTimeout =
|
|
921
|
+
err?.name === "AbortError" ||
|
|
922
|
+
errMsg === "timeout" ||
|
|
923
|
+
errMsg === "hard_timeout" ||
|
|
924
|
+
errMsg === "timeout_waiting_for_idle" ||
|
|
925
|
+
isIdleWaitTimeout;
|
|
926
|
+
|
|
927
|
+
// Copilot SDK can occasionally emit the full assistant message but still
|
|
928
|
+
// reject sendAndWait() due to a missing/late session.idle event. In that
|
|
929
|
+
// case, keep the run progressing by accepting the captured assistant output.
|
|
930
|
+
if (isIdleWaitTimeout && hasAssistantOutput) {
|
|
931
|
+
console.warn(
|
|
932
|
+
`${TAG} copilot sendAndWait timed out waiting for session.idle, but assistant output was received; accepting response`,
|
|
933
|
+
);
|
|
934
|
+
return {
|
|
935
|
+
success: true,
|
|
936
|
+
output: finalResponse.trim(),
|
|
937
|
+
items: allItems,
|
|
938
|
+
error: null,
|
|
939
|
+
sdk: "copilot",
|
|
940
|
+
threadId: resumeThreadId,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (isTimeout) {
|
|
945
|
+
return {
|
|
946
|
+
success: false,
|
|
947
|
+
output: "",
|
|
948
|
+
items: allItems,
|
|
949
|
+
error: `${TAG} copilot timeout after ${timeoutMs}ms${isIdleWaitTimeout ? " waiting for session.idle" : ""}`,
|
|
950
|
+
sdk: "copilot",
|
|
951
|
+
threadId: resumeThreadId,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
success: false,
|
|
956
|
+
output: "",
|
|
957
|
+
items: allItems,
|
|
958
|
+
error: errMsg || "unknown copilot error",
|
|
959
|
+
sdk: "copilot",
|
|
960
|
+
threadId: resumeThreadId,
|
|
961
|
+
};
|
|
962
|
+
} finally {
|
|
963
|
+
clearTimeout(timer);
|
|
964
|
+
try {
|
|
965
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
966
|
+
} catch {
|
|
967
|
+
/* ignore */
|
|
968
|
+
}
|
|
969
|
+
// Best-effort teardown — don't let cleanup errors propagate
|
|
970
|
+
try {
|
|
971
|
+
if (client && typeof client.stop === "function") client.stop();
|
|
972
|
+
} catch {
|
|
973
|
+
/* ignore */
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Resume an existing Copilot session and run a follow-up prompt.
|
|
980
|
+
* Falls back to fresh session if resume fails.
|
|
981
|
+
*
|
|
982
|
+
* @param {string} threadId
|
|
983
|
+
* @param {string} prompt
|
|
984
|
+
* @param {string} cwd
|
|
985
|
+
* @param {number} timeoutMs
|
|
986
|
+
* @param {object} extra
|
|
987
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null }>}
|
|
988
|
+
*/
|
|
989
|
+
async function resumeCopilotThread(
|
|
990
|
+
threadId,
|
|
991
|
+
prompt,
|
|
992
|
+
cwd,
|
|
993
|
+
timeoutMs,
|
|
994
|
+
extra = {},
|
|
995
|
+
) {
|
|
996
|
+
return launchCopilotThread(prompt, cwd, timeoutMs, {
|
|
997
|
+
...extra,
|
|
998
|
+
resumeThreadId: threadId,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Launch a single ephemeral prompt via the **Claude Agent SDK**.
|
|
1004
|
+
*
|
|
1005
|
+
* Creates a fresh message queue, pushes the user message, iterates the
|
|
1006
|
+
* response stream, and collects text output. Fully ephemeral — no session
|
|
1007
|
+
* reuse.
|
|
1008
|
+
*
|
|
1009
|
+
* @param {string} prompt Prompt text.
|
|
1010
|
+
* @param {string} cwd Working directory.
|
|
1011
|
+
* @param {number} timeoutMs Abort timeout in ms.
|
|
1012
|
+
* @param {object} extra Optional { onEvent, abortController, resumeThreadId, onThreadReady }.
|
|
1013
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
1014
|
+
*/
|
|
1015
|
+
async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
1016
|
+
const {
|
|
1017
|
+
onEvent,
|
|
1018
|
+
abortController: externalAC,
|
|
1019
|
+
claudeAllowedTools = null,
|
|
1020
|
+
claudePermissionMode = null,
|
|
1021
|
+
resumeThreadId = null,
|
|
1022
|
+
onThreadReady = null,
|
|
1023
|
+
model: requestedModel = null,
|
|
1024
|
+
} = extra;
|
|
1025
|
+
|
|
1026
|
+
// ── 1. Load the SDK ──────────────────────────────────────────────────────
|
|
1027
|
+
let queryFn;
|
|
1028
|
+
try {
|
|
1029
|
+
const mod = await import("@anthropic-ai/claude-agent-sdk");
|
|
1030
|
+
queryFn = mod.query;
|
|
1031
|
+
if (!queryFn) throw new Error("query() not found in Claude SDK");
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
return {
|
|
1034
|
+
success: false,
|
|
1035
|
+
output: "",
|
|
1036
|
+
items: [],
|
|
1037
|
+
error: `Claude SDK not available: ${err.message}`,
|
|
1038
|
+
sdk: "claude",
|
|
1039
|
+
threadId: null,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// ── 2. Detect auth ──────────────────────────────────────────────────────
|
|
1044
|
+
const apiKey =
|
|
1045
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
1046
|
+
process.env.CLAUDE_API_KEY ||
|
|
1047
|
+
process.env.CLAUDE_KEY ||
|
|
1048
|
+
undefined;
|
|
1049
|
+
|
|
1050
|
+
// ── 3. Build message queue ───────────────────────────────────────────────
|
|
1051
|
+
const controller = externalAC || new AbortController();
|
|
1052
|
+
const softTimer = setTimeout(() => controller.abort("timeout"), timeoutMs);
|
|
1053
|
+
// Hard timeout: force-break Promise.race if SDK ignores abort signal
|
|
1054
|
+
const hardTimeoutMs = timeoutMs + HARD_TIMEOUT_BUFFER_MS;
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Minimal async message queue for the Claude SDK streaming interface.
|
|
1058
|
+
* @returns {{ iterator: Function, push: Function, close: Function }}
|
|
1059
|
+
*/
|
|
1060
|
+
function createMessageQueue() {
|
|
1061
|
+
const q = [];
|
|
1062
|
+
let resolver = null;
|
|
1063
|
+
let closed = false;
|
|
1064
|
+
|
|
1065
|
+
async function* iterator() {
|
|
1066
|
+
while (true) {
|
|
1067
|
+
if (q.length > 0) {
|
|
1068
|
+
yield q.shift();
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (closed) return;
|
|
1072
|
+
// Wire abort signal: if the controller fires while we're waiting
|
|
1073
|
+
// for the next message, break out of the wait instead of hanging forever.
|
|
1074
|
+
await new Promise((r) => {
|
|
1075
|
+
resolver = r;
|
|
1076
|
+
if (controller.signal) {
|
|
1077
|
+
const onAbort = () => {
|
|
1078
|
+
closed = true;
|
|
1079
|
+
r();
|
|
1080
|
+
};
|
|
1081
|
+
if (controller.signal.aborted) {
|
|
1082
|
+
closed = true;
|
|
1083
|
+
r();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
controller.signal.addEventListener("abort", onAbort, {
|
|
1087
|
+
once: true,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
resolver = null;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function push(msg) {
|
|
1095
|
+
if (closed) return false;
|
|
1096
|
+
q.push(msg);
|
|
1097
|
+
if (resolver) {
|
|
1098
|
+
resolver();
|
|
1099
|
+
resolver = null;
|
|
1100
|
+
}
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
function close() {
|
|
1104
|
+
closed = true;
|
|
1105
|
+
if (resolver) {
|
|
1106
|
+
resolver();
|
|
1107
|
+
resolver = null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return { iterator, push, close };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Build a Claude-format user message.
|
|
1115
|
+
* @param {string} text
|
|
1116
|
+
* @returns {object}
|
|
1117
|
+
*/
|
|
1118
|
+
function makeUserMessage(text) {
|
|
1119
|
+
return {
|
|
1120
|
+
type: "user",
|
|
1121
|
+
session_id: resumeThreadId || "",
|
|
1122
|
+
message: {
|
|
1123
|
+
role: "user",
|
|
1124
|
+
content: [{ type: "text", text }],
|
|
1125
|
+
},
|
|
1126
|
+
parent_tool_use_id: null,
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ── 4. Execute query ─────────────────────────────────────────────────────
|
|
1131
|
+
try {
|
|
1132
|
+
const msgQueue = createMessageQueue();
|
|
1133
|
+
|
|
1134
|
+
const formattedPrompt =
|
|
1135
|
+
`# YOUR TASK — EXECUTE NOW\n\n${prompt}\n\n---\n` +
|
|
1136
|
+
'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
|
|
1137
|
+
|
|
1138
|
+
msgQueue.push(makeUserMessage(formattedPrompt));
|
|
1139
|
+
|
|
1140
|
+
const normalizeList = (value) => {
|
|
1141
|
+
if (Array.isArray(value)) {
|
|
1142
|
+
return value.map((entry) => String(entry || "").trim()).filter(Boolean);
|
|
1143
|
+
}
|
|
1144
|
+
return String(value || "")
|
|
1145
|
+
.split(",")
|
|
1146
|
+
.map((entry) => entry.trim())
|
|
1147
|
+
.filter(Boolean);
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
/** @type {object} */
|
|
1151
|
+
const options = {
|
|
1152
|
+
cwd,
|
|
1153
|
+
settingSources: ["user", "project"],
|
|
1154
|
+
permissionMode:
|
|
1155
|
+
claudePermissionMode ||
|
|
1156
|
+
process.env.CLAUDE_PERMISSION_MODE ||
|
|
1157
|
+
"bypassPermissions",
|
|
1158
|
+
};
|
|
1159
|
+
if (apiKey) options.apiKey = apiKey;
|
|
1160
|
+
const explicitAllowedTools = normalizeList(claudeAllowedTools);
|
|
1161
|
+
const allowedTools = explicitAllowedTools.length
|
|
1162
|
+
? explicitAllowedTools
|
|
1163
|
+
: normalizeList(process.env.CLAUDE_ALLOWED_TOOLS);
|
|
1164
|
+
if (allowedTools.length) {
|
|
1165
|
+
options.allowedTools = allowedTools;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const model = String(
|
|
1169
|
+
requestedModel ||
|
|
1170
|
+
process.env.CLAUDE_MODEL ||
|
|
1171
|
+
process.env.CLAUDE_CODE_MODEL ||
|
|
1172
|
+
process.env.ANTHROPIC_MODEL ||
|
|
1173
|
+
"",
|
|
1174
|
+
).trim();
|
|
1175
|
+
if (model) options.model = model;
|
|
1176
|
+
|
|
1177
|
+
const result = queryFn({
|
|
1178
|
+
prompt: msgQueue.iterator(),
|
|
1179
|
+
options,
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
let finalResponse = "";
|
|
1183
|
+
let activeClaudeSessionId = resumeThreadId || null;
|
|
1184
|
+
const allItems = [];
|
|
1185
|
+
|
|
1186
|
+
// Wrap SDK execution in Promise.race to enforce hard timeout even if
|
|
1187
|
+
// the SDK's async iterator ignores the abort signal.
|
|
1188
|
+
const sdkExecution = (async () => {
|
|
1189
|
+
for await (const message of result) {
|
|
1190
|
+
// Check abort signal on every iteration
|
|
1191
|
+
if (controller.signal.aborted) {
|
|
1192
|
+
msgQueue.close();
|
|
1193
|
+
throw new Error("timeout");
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const messageSessionId =
|
|
1197
|
+
message?.session_id || message?.sessionId || null;
|
|
1198
|
+
if (messageSessionId && messageSessionId !== activeClaudeSessionId) {
|
|
1199
|
+
activeClaudeSessionId = messageSessionId;
|
|
1200
|
+
if (typeof onThreadReady === "function") {
|
|
1201
|
+
try {
|
|
1202
|
+
onThreadReady(messageSessionId, "claude");
|
|
1203
|
+
} catch {
|
|
1204
|
+
/* best effort */
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Extract text from assistant messages
|
|
1210
|
+
const contentBlocks =
|
|
1211
|
+
message?.message?.content || message?.content || [];
|
|
1212
|
+
|
|
1213
|
+
if (message?.type === "assistant" && Array.isArray(contentBlocks)) {
|
|
1214
|
+
for (const block of contentBlocks) {
|
|
1215
|
+
if (block?.type === "text" && block.text) {
|
|
1216
|
+
finalResponse += block.text + "\n";
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Normalise to item-style events for the onEvent callback
|
|
1222
|
+
const syntheticEvent = { type: message?.type || "unknown", message };
|
|
1223
|
+
allItems.push(syntheticEvent);
|
|
1224
|
+
if (typeof onEvent === "function") {
|
|
1225
|
+
try {
|
|
1226
|
+
onEvent(syntheticEvent);
|
|
1227
|
+
} catch {
|
|
1228
|
+
/* best effort */
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// If the SDK signals completion, close the queue
|
|
1233
|
+
if (message?.type === "result") {
|
|
1234
|
+
msgQueue.close();
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
})();
|
|
1238
|
+
|
|
1239
|
+
const hardTimeout = new Promise((_, reject) =>
|
|
1240
|
+
setTimeout(() => reject(new Error("hard-timeout")), hardTimeoutMs),
|
|
1241
|
+
);
|
|
1242
|
+
|
|
1243
|
+
await Promise.race([sdkExecution, hardTimeout]);
|
|
1244
|
+
|
|
1245
|
+
clearTimeout(softTimer);
|
|
1246
|
+
msgQueue.close();
|
|
1247
|
+
|
|
1248
|
+
const output =
|
|
1249
|
+
finalResponse.trim() || "(Agent completed with no text output)";
|
|
1250
|
+
return {
|
|
1251
|
+
success: true,
|
|
1252
|
+
output,
|
|
1253
|
+
items: allItems,
|
|
1254
|
+
error: null,
|
|
1255
|
+
sdk: "claude",
|
|
1256
|
+
threadId: activeClaudeSessionId,
|
|
1257
|
+
};
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
clearTimeout(softTimer);
|
|
1260
|
+
const isTimeout =
|
|
1261
|
+
err.name === "AbortError" ||
|
|
1262
|
+
String(err).includes("timeout") ||
|
|
1263
|
+
String(err.message).includes("timeout");
|
|
1264
|
+
if (isTimeout) {
|
|
1265
|
+
return {
|
|
1266
|
+
success: false,
|
|
1267
|
+
output: "",
|
|
1268
|
+
items: [],
|
|
1269
|
+
error: `${TAG} claude timeout after ${timeoutMs}ms`,
|
|
1270
|
+
sdk: "claude",
|
|
1271
|
+
threadId: resumeThreadId,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
success: false,
|
|
1276
|
+
output: "",
|
|
1277
|
+
items: [],
|
|
1278
|
+
error: err.message,
|
|
1279
|
+
sdk: "claude",
|
|
1280
|
+
threadId: resumeThreadId,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* Resume an existing Claude session and run a follow-up prompt.
|
|
1287
|
+
* Falls back to fresh session semantics if resume is not supported upstream.
|
|
1288
|
+
*
|
|
1289
|
+
* @param {string} threadId
|
|
1290
|
+
* @param {string} prompt
|
|
1291
|
+
* @param {string} cwd
|
|
1292
|
+
* @param {number} timeoutMs
|
|
1293
|
+
* @param {object} extra
|
|
1294
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null }>}
|
|
1295
|
+
*/
|
|
1296
|
+
async function resumeClaudeThread(
|
|
1297
|
+
threadId,
|
|
1298
|
+
prompt,
|
|
1299
|
+
cwd,
|
|
1300
|
+
timeoutMs,
|
|
1301
|
+
extra = {},
|
|
1302
|
+
) {
|
|
1303
|
+
return launchClaudeThread(prompt, cwd, timeoutMs, {
|
|
1304
|
+
...extra,
|
|
1305
|
+
resumeThreadId: threadId,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// ---------------------------------------------------------------------------
|
|
1310
|
+
// Adapter loader functions (return the per-SDK launcher)
|
|
1311
|
+
// ---------------------------------------------------------------------------
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* @returns {Promise<Function>} The Codex launcher function.
|
|
1315
|
+
*/
|
|
1316
|
+
async function loadCodexAdapter() {
|
|
1317
|
+
return launchCodexThread;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* @returns {Promise<Function>} The Copilot launcher function.
|
|
1322
|
+
*/
|
|
1323
|
+
async function loadCopilotAdapter() {
|
|
1324
|
+
return launchCopilotThread;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* @returns {Promise<Function>} The Claude launcher function.
|
|
1329
|
+
*/
|
|
1330
|
+
async function loadClaudeAdapter() {
|
|
1331
|
+
return launchClaudeThread;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ---------------------------------------------------------------------------
|
|
1335
|
+
// Unified ephemeral thread launcher
|
|
1336
|
+
// ---------------------------------------------------------------------------
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Spin up a fresh, isolated SDK thread, execute a single prompt, and return
|
|
1340
|
+
* the result. The thread is not reused — it exists only for this one
|
|
1341
|
+
* operation, which means it cannot block (or be blocked by) any other thread.
|
|
1342
|
+
*
|
|
1343
|
+
* SDK selection:
|
|
1344
|
+
* - Pass `extra.sdk` to force a specific SDK for this call.
|
|
1345
|
+
* - Otherwise uses the resolved pool SDK (env / config / fallback).
|
|
1346
|
+
* - If the primary SDK fails with "not available", tries the fallback chain.
|
|
1347
|
+
*
|
|
1348
|
+
* @param {string} prompt The prompt to send to the agent.
|
|
1349
|
+
* @param {string} [cwd] Working directory (defaults to REPO_ROOT).
|
|
1350
|
+
* @param {number} [timeoutMs] Abort after this many ms (default 90 min).
|
|
1351
|
+
* @param {object} [extra] Optional extras:
|
|
1352
|
+
* @param {string} [extra.sdk] Force a specific SDK for this call.
|
|
1353
|
+
* @param {string} [extra.model] Force model for SDKs that support it.
|
|
1354
|
+
* @param {Function} [extra.onEvent] Callback for raw SDK events.
|
|
1355
|
+
* @param {AbortController} [extra.abortController] External abort controller.
|
|
1356
|
+
* @param {string[]|string} [extra.claudeAllowedTools] Claude tool allow-list.
|
|
1357
|
+
* @param {string} [extra.claudePermissionMode] Claude permission mode override.
|
|
1358
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
1359
|
+
*/
|
|
1360
|
+
export async function launchEphemeralThread(
|
|
1361
|
+
prompt,
|
|
1362
|
+
cwd = REPO_ROOT,
|
|
1363
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1364
|
+
extra = {},
|
|
1365
|
+
) {
|
|
1366
|
+
// Determine the primary SDK to try
|
|
1367
|
+
const requestedSdk = extra.sdk
|
|
1368
|
+
? String(extra.sdk).trim().toLowerCase()
|
|
1369
|
+
: null;
|
|
1370
|
+
|
|
1371
|
+
const primaryName =
|
|
1372
|
+
requestedSdk && SDK_ADAPTERS[requestedSdk]
|
|
1373
|
+
? requestedSdk
|
|
1374
|
+
: resolvePoolSdkName();
|
|
1375
|
+
|
|
1376
|
+
const primaryAdapter = SDK_ADAPTERS[primaryName];
|
|
1377
|
+
|
|
1378
|
+
// ── Try primary SDK ──────────────────────────────────────────────────────
|
|
1379
|
+
if (primaryAdapter && !isDisabled(primaryName)) {
|
|
1380
|
+
const launcher = await primaryAdapter.load();
|
|
1381
|
+
const result = await launcher(prompt, cwd, timeoutMs, extra);
|
|
1382
|
+
|
|
1383
|
+
// If it succeeded, or if the error isn't "not available", return as-is
|
|
1384
|
+
if (result.success || !shouldFallbackForSdkError(result.error)) {
|
|
1385
|
+
return result;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Primary SDK not installed — fall through to fallback chain
|
|
1389
|
+
console.warn(
|
|
1390
|
+
`${TAG} primary SDK "${primaryName}" failed (${result.error}); trying fallback chain`,
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// ── Fallback chain ───────────────────────────────────────────────────────
|
|
1395
|
+
for (const name of SDK_FALLBACK_ORDER) {
|
|
1396
|
+
if (name === primaryName) continue; // already tried
|
|
1397
|
+
if (isDisabled(name)) continue;
|
|
1398
|
+
|
|
1399
|
+
const adapter = SDK_ADAPTERS[name];
|
|
1400
|
+
if (!adapter) continue;
|
|
1401
|
+
|
|
1402
|
+
console.log(`${TAG} trying fallback SDK: ${name}`);
|
|
1403
|
+
const launcher = await adapter.load();
|
|
1404
|
+
const result = await launcher(prompt, cwd, timeoutMs, extra);
|
|
1405
|
+
|
|
1406
|
+
if (result.success || !shouldFallbackForSdkError(result.error)) {
|
|
1407
|
+
return result;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// ── All SDKs exhausted ───────────────────────────────────────────────────
|
|
1412
|
+
const triedSdks = SDK_FALLBACK_ORDER.filter((n) => !isDisabled(n));
|
|
1413
|
+
return {
|
|
1414
|
+
success: false,
|
|
1415
|
+
output: "",
|
|
1416
|
+
items: [],
|
|
1417
|
+
error: `${TAG} no SDK available. Tried: ${triedSdks.join(", ") || "(all disabled)"}`,
|
|
1418
|
+
sdk: primaryName,
|
|
1419
|
+
threadId: null,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// ---------------------------------------------------------------------------
|
|
1424
|
+
// High-level: drop-in replacement for execPrimaryPrompt
|
|
1425
|
+
// ---------------------------------------------------------------------------
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Execute a prompt on a pooled ephemeral thread with the **same signature** as
|
|
1429
|
+
* `execPrimaryPrompt` from codex-shell.mjs. This allows callers in
|
|
1430
|
+
* monitor.mjs to swap from the singleton agent to a concurrent pool thread
|
|
1431
|
+
* without changing any surrounding code.
|
|
1432
|
+
*
|
|
1433
|
+
* @param {string} userMessage The prompt / instruction to execute.
|
|
1434
|
+
* @param {object} [options] Compatible with execPrimaryPrompt options.
|
|
1435
|
+
* @param {Function} [options.onEvent] Callback for raw SDK events.
|
|
1436
|
+
* @param {object} [options.statusData] (Unused — accepted for compat.)
|
|
1437
|
+
* @param {number} [options.timeoutMs] Override default timeout.
|
|
1438
|
+
* @param {boolean} [options.sendRawEvents] (Unused — accepted for compat.)
|
|
1439
|
+
* @param {AbortController} [options.abortController] External abort controller.
|
|
1440
|
+
* @param {string} [options.cwd] Working directory override.
|
|
1441
|
+
* @param {string} [options.sdk] Force a specific SDK.
|
|
1442
|
+
* @param {string} [options.model] Force model for SDKs that support it.
|
|
1443
|
+
* @returns {Promise<{ finalResponse: string, items: Array, usage: object|null }>}
|
|
1444
|
+
*/
|
|
1445
|
+
export async function execPooledPrompt(userMessage, options = {}) {
|
|
1446
|
+
const {
|
|
1447
|
+
onEvent,
|
|
1448
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1449
|
+
abortController,
|
|
1450
|
+
cwd = REPO_ROOT,
|
|
1451
|
+
sdk,
|
|
1452
|
+
model,
|
|
1453
|
+
// statusData and sendRawEvents are accepted but not used — keeps the
|
|
1454
|
+
// call-site compatible with execPrimaryPrompt without modification.
|
|
1455
|
+
} = options;
|
|
1456
|
+
|
|
1457
|
+
const result = await launchEphemeralThread(userMessage, cwd, timeoutMs, {
|
|
1458
|
+
onEvent,
|
|
1459
|
+
abortController,
|
|
1460
|
+
sdk,
|
|
1461
|
+
model,
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
if (!result.success) {
|
|
1465
|
+
// Match execPrimaryPrompt behaviour: always return the triple, let the
|
|
1466
|
+
// caller inspect finalResponse for error handling.
|
|
1467
|
+
return {
|
|
1468
|
+
finalResponse: result.error
|
|
1469
|
+
? `[agent-pool error] ${result.error}`
|
|
1470
|
+
: "(no output)",
|
|
1471
|
+
items: result.items || [],
|
|
1472
|
+
usage: null,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return {
|
|
1477
|
+
finalResponse: result.output,
|
|
1478
|
+
items: result.items,
|
|
1479
|
+
usage: null, // ephemeral threads don't aggregate usage today
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// ---------------------------------------------------------------------------
|
|
1484
|
+
// Thread Persistence & Resume Registry
|
|
1485
|
+
// ---------------------------------------------------------------------------
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* @typedef {Object} ThreadRecord
|
|
1489
|
+
* @property {string} threadId SDK-specific thread/session ID.
|
|
1490
|
+
* @property {string} sdk Which SDK owns this thread.
|
|
1491
|
+
* @property {string} taskKey Caller-defined key (task ID, PR#, etc.).
|
|
1492
|
+
* @property {string} cwd Working directory used.
|
|
1493
|
+
* @property {number} turnCount How many turns have been run.
|
|
1494
|
+
* @property {number} createdAt Unix ms when first created.
|
|
1495
|
+
* @property {number} lastUsedAt Unix ms of most recent run.
|
|
1496
|
+
* @property {string|null} lastError Last error message if any.
|
|
1497
|
+
* @property {boolean} alive Whether this thread is still usable.
|
|
1498
|
+
*/
|
|
1499
|
+
|
|
1500
|
+
/** @type {Map<string, ThreadRecord>} In-memory registry keyed by taskKey */
|
|
1501
|
+
const threadRegistry = new Map();
|
|
1502
|
+
|
|
1503
|
+
const THREAD_REGISTRY_FILE = resolve(__dirname, "logs", "thread-registry.json");
|
|
1504
|
+
const THREAD_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12 hours
|
|
1505
|
+
|
|
1506
|
+
/** Maximum turns before a thread is considered exhausted and must be replaced */
|
|
1507
|
+
const MAX_THREAD_TURNS = 100;
|
|
1508
|
+
|
|
1509
|
+
/** Maximum absolute age for a thread (regardless of lastUsedAt) */
|
|
1510
|
+
const THREAD_MAX_ABSOLUTE_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
1511
|
+
|
|
1512
|
+
/** SDKs that provide real resumable thread IDs */
|
|
1513
|
+
const PERSISTENT_THREAD_SDKS = new Set(["codex", "copilot", "claude"]);
|
|
1514
|
+
|
|
1515
|
+
function sdkSupportsPersistentThreads(sdkName) {
|
|
1516
|
+
return PERSISTENT_THREAD_SDKS.has(String(sdkName || "").toLowerCase());
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/** @type {Promise<void>|null} */
|
|
1520
|
+
let threadRegistryLoadPromise = null;
|
|
1521
|
+
let threadRegistryLoaded = false;
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Load thread registry from disk (best-effort).
|
|
1525
|
+
*/
|
|
1526
|
+
async function loadThreadRegistry() {
|
|
1527
|
+
try {
|
|
1528
|
+
const { readFile } = await import("node:fs/promises");
|
|
1529
|
+
const raw = await readFile(THREAD_REGISTRY_FILE, "utf8");
|
|
1530
|
+
const entries = JSON.parse(raw);
|
|
1531
|
+
const now = Date.now();
|
|
1532
|
+
let pruned = 0;
|
|
1533
|
+
for (const [key, record] of Object.entries(entries)) {
|
|
1534
|
+
const recordSdk = String(record?.sdk || "").toLowerCase();
|
|
1535
|
+
|
|
1536
|
+
// Expire old threads (by lastUsedAt)
|
|
1537
|
+
if (now - record.lastUsedAt > THREAD_MAX_AGE_MS) {
|
|
1538
|
+
pruned++;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
// Expire threads that have been alive too long (absolute age)
|
|
1542
|
+
if (now - record.createdAt > THREAD_MAX_ABSOLUTE_AGE_MS) {
|
|
1543
|
+
pruned++;
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
// Expire high-turn threads (context exhaustion)
|
|
1547
|
+
if (record.turnCount >= MAX_THREAD_TURNS) {
|
|
1548
|
+
console.log(
|
|
1549
|
+
`${TAG} expiring exhausted thread for task "${key}" (${record.turnCount} turns)`,
|
|
1550
|
+
);
|
|
1551
|
+
pruned++;
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
if (!record.alive) {
|
|
1555
|
+
pruned++;
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
threadRegistry.set(key, record);
|
|
1559
|
+
}
|
|
1560
|
+
// Persist the cleaned registry back to disk so stale entries don't linger
|
|
1561
|
+
if (pruned > 0) {
|
|
1562
|
+
saveThreadRegistry().catch(() => {});
|
|
1563
|
+
}
|
|
1564
|
+
} catch {
|
|
1565
|
+
// No registry file yet — that's fine
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* Persist thread registry to disk (best-effort).
|
|
1571
|
+
*/
|
|
1572
|
+
async function saveThreadRegistry() {
|
|
1573
|
+
try {
|
|
1574
|
+
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
1575
|
+
await mkdir(resolve(__dirname, "logs"), { recursive: true });
|
|
1576
|
+
const obj = Object.fromEntries(threadRegistry);
|
|
1577
|
+
await writeFile(THREAD_REGISTRY_FILE, JSON.stringify(obj, null, 2), "utf8");
|
|
1578
|
+
} catch {
|
|
1579
|
+
// Non-critical — registry is an optimisation, not a requirement
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Ensure thread registry has been loaded from disk before use.
|
|
1585
|
+
* This avoids a startup race where first tasks run before registry restore.
|
|
1586
|
+
*/
|
|
1587
|
+
export async function ensureThreadRegistryLoaded() {
|
|
1588
|
+
if (threadRegistryLoaded) return;
|
|
1589
|
+
if (!threadRegistryLoadPromise) {
|
|
1590
|
+
threadRegistryLoadPromise = loadThreadRegistry()
|
|
1591
|
+
.catch(() => {
|
|
1592
|
+
/* best-effort */
|
|
1593
|
+
})
|
|
1594
|
+
.finally(() => {
|
|
1595
|
+
threadRegistryLoaded = true;
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
await threadRegistryLoadPromise;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Kick off async load at module init (non-blocking), callers can await explicitly.
|
|
1602
|
+
void ensureThreadRegistryLoaded();
|
|
1603
|
+
|
|
1604
|
+
// ---------------------------------------------------------------------------
|
|
1605
|
+
// Per-SDK Resume Launchers
|
|
1606
|
+
// ---------------------------------------------------------------------------
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Detect unrecoverable Codex resume errors that indicate poisoned thread state.
|
|
1610
|
+
* These failures should force dropping cached thread metadata.
|
|
1611
|
+
*
|
|
1612
|
+
* @param {unknown} errorValue
|
|
1613
|
+
* @returns {boolean}
|
|
1614
|
+
*/
|
|
1615
|
+
function isPoisonedCodexResumeError(errorValue) {
|
|
1616
|
+
const lower = String(errorValue || "").toLowerCase();
|
|
1617
|
+
return (
|
|
1618
|
+
lower.includes("invalid_encrypted_content") ||
|
|
1619
|
+
lower.includes("encrypted content") ||
|
|
1620
|
+
lower.includes("could not be verified") ||
|
|
1621
|
+
lower.includes("state db missing rollout path") ||
|
|
1622
|
+
lower.includes("missing rollout path") ||
|
|
1623
|
+
lower.includes("tool call must have a tool call id") ||
|
|
1624
|
+
lower.includes("tool_call_id") ||
|
|
1625
|
+
(lower.includes("400") && lower.includes("tool call"))
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Resume an existing Codex thread and run a follow-up prompt.
|
|
1631
|
+
* Uses `codex.resumeThread(threadId)` from @openai/codex-sdk.
|
|
1632
|
+
*
|
|
1633
|
+
* @param {string} threadId Thread ID from a previous launchCodexThread.
|
|
1634
|
+
* @param {string} prompt Follow-up prompt.
|
|
1635
|
+
* @param {string} cwd Working directory.
|
|
1636
|
+
* @param {number} timeoutMs Abort timeout in ms.
|
|
1637
|
+
* @param {object} extra Optional { onEvent, abortController }.
|
|
1638
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null }>}
|
|
1639
|
+
*/
|
|
1640
|
+
async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
1641
|
+
const { onEvent, abortController: externalAC } = extra;
|
|
1642
|
+
|
|
1643
|
+
let CodexClass;
|
|
1644
|
+
try {
|
|
1645
|
+
const mod = await import("@openai/codex-sdk");
|
|
1646
|
+
CodexClass = mod.Codex;
|
|
1647
|
+
if (!CodexClass) throw new Error("Codex export not found");
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
return {
|
|
1650
|
+
success: false,
|
|
1651
|
+
output: "",
|
|
1652
|
+
items: [],
|
|
1653
|
+
error: `Codex SDK not available: ${err.message}`,
|
|
1654
|
+
sdk: "codex",
|
|
1655
|
+
threadId: null,
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const codex = new CodexClass(buildCodexSdkOptions());
|
|
1660
|
+
|
|
1661
|
+
let thread;
|
|
1662
|
+
try {
|
|
1663
|
+
const sandboxPolicy = process.env.CODEX_SANDBOX || "workspace-write";
|
|
1664
|
+
thread = codex.resumeThread(threadId, {
|
|
1665
|
+
sandboxMode: sandboxPolicy,
|
|
1666
|
+
workingDirectory: cwd,
|
|
1667
|
+
skipGitRepoCheck: true,
|
|
1668
|
+
approvalPolicy: "never",
|
|
1669
|
+
});
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
// Resume failed (thread expired, not found, etc.) — signal caller to start fresh
|
|
1672
|
+
return {
|
|
1673
|
+
success: false,
|
|
1674
|
+
output: "",
|
|
1675
|
+
items: [],
|
|
1676
|
+
error: `Thread resume failed: ${err.message}`,
|
|
1677
|
+
sdk: "codex",
|
|
1678
|
+
threadId: null,
|
|
1679
|
+
poisonedResumeState: isPoisonedCodexResumeError(err.message),
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (!thread) {
|
|
1684
|
+
return {
|
|
1685
|
+
success: false,
|
|
1686
|
+
output: "",
|
|
1687
|
+
items: [],
|
|
1688
|
+
error: "Codex SDK resumeThread() returned null — thread may have expired",
|
|
1689
|
+
sdk: "codex",
|
|
1690
|
+
threadId: null,
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const controller = externalAC || new AbortController();
|
|
1695
|
+
const timer = setTimeout(() => controller.abort("timeout"), timeoutMs);
|
|
1696
|
+
let hardTimer;
|
|
1697
|
+
|
|
1698
|
+
try {
|
|
1699
|
+
const turn = await thread.runStreamed(prompt, {
|
|
1700
|
+
signal: controller.signal,
|
|
1701
|
+
});
|
|
1702
|
+
let finalResponse = "";
|
|
1703
|
+
const allItems = [];
|
|
1704
|
+
|
|
1705
|
+
// Hard timeout safety net (same as launchCodexThread)
|
|
1706
|
+
const hardTimeoutPromise = new Promise((_, reject) => {
|
|
1707
|
+
hardTimer = setTimeout(
|
|
1708
|
+
() => reject(new Error("hard_timeout")),
|
|
1709
|
+
timeoutMs + HARD_TIMEOUT_BUFFER_MS,
|
|
1710
|
+
);
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
const iterateEvents = async () => {
|
|
1714
|
+
for await (const event of turn.events) {
|
|
1715
|
+
if (controller.signal.aborted) break;
|
|
1716
|
+
if (typeof onEvent === "function")
|
|
1717
|
+
try {
|
|
1718
|
+
onEvent(event);
|
|
1719
|
+
} catch {
|
|
1720
|
+
/* */
|
|
1721
|
+
}
|
|
1722
|
+
if (event.type === "item.completed") {
|
|
1723
|
+
allItems.push(event.item);
|
|
1724
|
+
if (event.item.type === "agent_message" && event.item.text) {
|
|
1725
|
+
finalResponse += event.item.text + "\n";
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
await Promise.race([iterateEvents(), hardTimeoutPromise]);
|
|
1732
|
+
clearTimeout(hardTimer);
|
|
1733
|
+
clearTimeout(timer);
|
|
1734
|
+
|
|
1735
|
+
const newThreadId = thread.id || threadId;
|
|
1736
|
+
return {
|
|
1737
|
+
success: true,
|
|
1738
|
+
output: finalResponse.trim() || "(resumed — no text output)",
|
|
1739
|
+
items: allItems,
|
|
1740
|
+
error: null,
|
|
1741
|
+
sdk: "codex",
|
|
1742
|
+
threadId: newThreadId,
|
|
1743
|
+
};
|
|
1744
|
+
} catch (err) {
|
|
1745
|
+
clearTimeout(timer);
|
|
1746
|
+
if (hardTimer) clearTimeout(hardTimer);
|
|
1747
|
+
const isTimeout =
|
|
1748
|
+
err.name === "AbortError" ||
|
|
1749
|
+
String(err) === "timeout" ||
|
|
1750
|
+
err.message === "hard_timeout";
|
|
1751
|
+
return {
|
|
1752
|
+
success: false,
|
|
1753
|
+
output: "",
|
|
1754
|
+
items: [],
|
|
1755
|
+
error: isTimeout
|
|
1756
|
+
? `${TAG} codex resume timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout)" : ""}`
|
|
1757
|
+
: `Thread resume error: ${err.message}`,
|
|
1758
|
+
sdk: "codex",
|
|
1759
|
+
threadId: null,
|
|
1760
|
+
poisonedResumeState:
|
|
1761
|
+
!isTimeout && isPoisonedCodexResumeError(err.message),
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* "Resume" for SDKs without native thread persistence.
|
|
1768
|
+
* Falls back to starting a fresh thread with a context-carrying preamble.
|
|
1769
|
+
*
|
|
1770
|
+
* @param {string} _threadId Ignored — no native resume available.
|
|
1771
|
+
* @param {string} prompt Follow-up prompt.
|
|
1772
|
+
* @param {string} cwd Working directory.
|
|
1773
|
+
* @param {number} timeoutMs Abort timeout.
|
|
1774
|
+
* @param {object} extra Optional extras.
|
|
1775
|
+
* @param {string} sdkName "copilot" or "claude".
|
|
1776
|
+
* @returns {Promise<Object>}
|
|
1777
|
+
*/
|
|
1778
|
+
async function resumeGenericThread(
|
|
1779
|
+
_threadId,
|
|
1780
|
+
prompt,
|
|
1781
|
+
cwd,
|
|
1782
|
+
timeoutMs,
|
|
1783
|
+
extra = {},
|
|
1784
|
+
sdkName = "copilot",
|
|
1785
|
+
) {
|
|
1786
|
+
// No native resume — launch fresh with context preamble
|
|
1787
|
+
const contextPrompt = `# CONTINUATION — Resuming Prior Context\n\nYou are continuing work from a previous session. Pick up where you left off.\n\n---\n\n${prompt}`;
|
|
1788
|
+
const launcher =
|
|
1789
|
+
sdkName === "claude" ? launchClaudeThread : launchCopilotThread;
|
|
1790
|
+
const result = await launcher(contextPrompt, cwd, timeoutMs, extra);
|
|
1791
|
+
return { ...result, threadId: null }; // No persistent ID available
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// ---------------------------------------------------------------------------
|
|
1795
|
+
// Thread-Persistent Launcher
|
|
1796
|
+
// ---------------------------------------------------------------------------
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Launch a new thread OR resume an existing one for the given task key.
|
|
1800
|
+
*
|
|
1801
|
+
* When a `taskKey` is provided:
|
|
1802
|
+
* 1. Check the thread registry for an existing, alive thread.
|
|
1803
|
+
* 2. If found and the same SDK — attempt resume (Codex) or context-carry (others).
|
|
1804
|
+
* 3. If resume fails or no prior thread — start fresh.
|
|
1805
|
+
* 4. Register the new thread for future resume.
|
|
1806
|
+
*
|
|
1807
|
+
* Without `taskKey`, behaves identically to `launchEphemeralThread`.
|
|
1808
|
+
*
|
|
1809
|
+
* @param {string} prompt Prompt to run.
|
|
1810
|
+
* @param {string} [cwd] Working directory.
|
|
1811
|
+
* @param {number} [timeoutMs] Timeout in ms.
|
|
1812
|
+
* @param {object} [extra] Options:
|
|
1813
|
+
* @param {string} [extra.taskKey] Key for thread registry (task ID, PR number, etc.)
|
|
1814
|
+
* @param {string} [extra.sdk] Force a specific SDK.
|
|
1815
|
+
* @param {string} [extra.model] Force model for SDKs that support it.
|
|
1816
|
+
* @param {Function} [extra.onEvent] Event callback.
|
|
1817
|
+
* @param {AbortController} [extra.abortController]
|
|
1818
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null, resumed: boolean }>}
|
|
1819
|
+
*/
|
|
1820
|
+
export async function launchOrResumeThread(
|
|
1821
|
+
prompt,
|
|
1822
|
+
cwd = REPO_ROOT,
|
|
1823
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1824
|
+
extra = {},
|
|
1825
|
+
) {
|
|
1826
|
+
await ensureThreadRegistryLoaded();
|
|
1827
|
+
const { taskKey, ...restExtra } = extra;
|
|
1828
|
+
timeoutMs = clampMonitorMonitorTimeout(timeoutMs, taskKey);
|
|
1829
|
+
|
|
1830
|
+
// No taskKey — pure ephemeral (backward compatible)
|
|
1831
|
+
if (!taskKey) {
|
|
1832
|
+
const result = await launchEphemeralThread(
|
|
1833
|
+
prompt,
|
|
1834
|
+
cwd,
|
|
1835
|
+
timeoutMs,
|
|
1836
|
+
restExtra,
|
|
1837
|
+
);
|
|
1838
|
+
return { ...result, threadId: result.threadId || null, resumed: false };
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Check registry for existing thread
|
|
1842
|
+
const existing = threadRegistry.get(taskKey);
|
|
1843
|
+
if (existing && existing.alive && existing.threadId) {
|
|
1844
|
+
// Check if thread has exceeded max turns — force fresh start
|
|
1845
|
+
if (existing.turnCount >= MAX_THREAD_TURNS) {
|
|
1846
|
+
console.warn(
|
|
1847
|
+
`${TAG} thread for task "${taskKey}" exceeded ${MAX_THREAD_TURNS} turns (has ${existing.turnCount}) — invalidating and starting fresh`,
|
|
1848
|
+
);
|
|
1849
|
+
existing.alive = false;
|
|
1850
|
+
threadRegistry.set(taskKey, existing);
|
|
1851
|
+
saveThreadRegistry().catch(() => {});
|
|
1852
|
+
// Fall through to fresh launch below
|
|
1853
|
+
} else if (Date.now() - existing.createdAt > THREAD_MAX_ABSOLUTE_AGE_MS) {
|
|
1854
|
+
console.warn(
|
|
1855
|
+
`${TAG} thread for task "${taskKey}" exceeded absolute age limit — invalidating and starting fresh`,
|
|
1856
|
+
);
|
|
1857
|
+
existing.alive = false;
|
|
1858
|
+
threadRegistry.set(taskKey, existing);
|
|
1859
|
+
saveThreadRegistry().catch(() => {});
|
|
1860
|
+
// Fall through to fresh launch below
|
|
1861
|
+
} else {
|
|
1862
|
+
const sdkName = restExtra.sdk || existing.sdk || resolvePoolSdkName();
|
|
1863
|
+
|
|
1864
|
+
// Native resume for Codex threads
|
|
1865
|
+
if (sdkName === "codex" && existing.sdk === "codex") {
|
|
1866
|
+
console.log(
|
|
1867
|
+
`${TAG} resuming Codex thread ${existing.threadId} for task "${taskKey}" (turn ${existing.turnCount + 1})`,
|
|
1868
|
+
);
|
|
1869
|
+
const result = await resumeCodexThread(
|
|
1870
|
+
existing.threadId,
|
|
1871
|
+
prompt,
|
|
1872
|
+
cwd,
|
|
1873
|
+
timeoutMs,
|
|
1874
|
+
restExtra,
|
|
1875
|
+
);
|
|
1876
|
+
|
|
1877
|
+
if (result.success) {
|
|
1878
|
+
// Update registry
|
|
1879
|
+
existing.turnCount += 1;
|
|
1880
|
+
existing.lastUsedAt = Date.now();
|
|
1881
|
+
existing.lastError = null;
|
|
1882
|
+
if (result.threadId) existing.threadId = result.threadId;
|
|
1883
|
+
threadRegistry.set(taskKey, existing);
|
|
1884
|
+
saveThreadRegistry().catch(() => {});
|
|
1885
|
+
return { ...result, resumed: true };
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Resume failed — fall through to fresh launch
|
|
1889
|
+
if (
|
|
1890
|
+
result.poisonedResumeState ||
|
|
1891
|
+
isPoisonedCodexResumeError(result.error)
|
|
1892
|
+
) {
|
|
1893
|
+
console.warn(
|
|
1894
|
+
`${TAG} resume failed for task "${taskKey}" with corrupted state: ${result.error}. Dropping cached thread metadata and starting fresh.`,
|
|
1895
|
+
);
|
|
1896
|
+
threadRegistry.delete(taskKey);
|
|
1897
|
+
} else {
|
|
1898
|
+
console.warn(
|
|
1899
|
+
`${TAG} resume failed for task "${taskKey}": ${result.error}. Starting fresh.`,
|
|
1900
|
+
);
|
|
1901
|
+
existing.alive = false;
|
|
1902
|
+
existing.lastError = result.error || existing.lastError || null;
|
|
1903
|
+
threadRegistry.set(taskKey, existing);
|
|
1904
|
+
}
|
|
1905
|
+
saveThreadRegistry().catch(() => {});
|
|
1906
|
+
} else if (sdkName === "copilot" && existing.sdk === "copilot") {
|
|
1907
|
+
console.log(
|
|
1908
|
+
`${TAG} resuming Copilot session ${existing.threadId} for task "${taskKey}" (turn ${existing.turnCount + 1})`,
|
|
1909
|
+
);
|
|
1910
|
+
const result = await resumeCopilotThread(
|
|
1911
|
+
existing.threadId,
|
|
1912
|
+
prompt,
|
|
1913
|
+
cwd,
|
|
1914
|
+
timeoutMs,
|
|
1915
|
+
restExtra,
|
|
1916
|
+
);
|
|
1917
|
+
|
|
1918
|
+
if (result.success) {
|
|
1919
|
+
existing.turnCount += 1;
|
|
1920
|
+
existing.lastUsedAt = Date.now();
|
|
1921
|
+
existing.lastError = null;
|
|
1922
|
+
if (result.threadId) existing.threadId = result.threadId;
|
|
1923
|
+
existing.alive = !!existing.threadId;
|
|
1924
|
+
threadRegistry.set(taskKey, existing);
|
|
1925
|
+
saveThreadRegistry().catch(() => {});
|
|
1926
|
+
return { ...result, resumed: true };
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
console.warn(
|
|
1930
|
+
`${TAG} resume failed for task "${taskKey}": ${result.error}. Starting fresh.`,
|
|
1931
|
+
);
|
|
1932
|
+
existing.alive = false;
|
|
1933
|
+
existing.lastError = result.error || existing.lastError || null;
|
|
1934
|
+
threadRegistry.set(taskKey, existing);
|
|
1935
|
+
saveThreadRegistry().catch(() => {});
|
|
1936
|
+
} else if (sdkName === "claude" && existing.sdk === "claude") {
|
|
1937
|
+
console.log(
|
|
1938
|
+
`${TAG} resuming Claude session ${existing.threadId} for task "${taskKey}" (turn ${existing.turnCount + 1})`,
|
|
1939
|
+
);
|
|
1940
|
+
const result = await resumeClaudeThread(
|
|
1941
|
+
existing.threadId,
|
|
1942
|
+
prompt,
|
|
1943
|
+
cwd,
|
|
1944
|
+
timeoutMs,
|
|
1945
|
+
restExtra,
|
|
1946
|
+
);
|
|
1947
|
+
|
|
1948
|
+
if (result.success) {
|
|
1949
|
+
existing.turnCount += 1;
|
|
1950
|
+
existing.lastUsedAt = Date.now();
|
|
1951
|
+
existing.lastError = null;
|
|
1952
|
+
if (result.threadId) existing.threadId = result.threadId;
|
|
1953
|
+
existing.alive = !!existing.threadId;
|
|
1954
|
+
threadRegistry.set(taskKey, existing);
|
|
1955
|
+
saveThreadRegistry().catch(() => {});
|
|
1956
|
+
return { ...result, resumed: true };
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
console.warn(
|
|
1960
|
+
`${TAG} resume failed for task "${taskKey}": ${result.error}. Starting fresh.`,
|
|
1961
|
+
);
|
|
1962
|
+
existing.alive = false;
|
|
1963
|
+
existing.lastError = result.error || existing.lastError || null;
|
|
1964
|
+
threadRegistry.set(taskKey, existing);
|
|
1965
|
+
saveThreadRegistry().catch(() => {});
|
|
1966
|
+
} else if (existing.sdk !== sdkName) {
|
|
1967
|
+
// SDK changed — invalidate old thread
|
|
1968
|
+
console.log(
|
|
1969
|
+
`${TAG} SDK changed from ${existing.sdk} to ${sdkName} for task "${taskKey}", starting fresh`,
|
|
1970
|
+
);
|
|
1971
|
+
existing.alive = false;
|
|
1972
|
+
threadRegistry.set(taskKey, existing);
|
|
1973
|
+
saveThreadRegistry().catch(() => {});
|
|
1974
|
+
} else {
|
|
1975
|
+
// Non-Codex SDK: use context-carry resume
|
|
1976
|
+
console.log(
|
|
1977
|
+
`${TAG} context-carry resume for ${sdkName} thread, task "${taskKey}"`,
|
|
1978
|
+
);
|
|
1979
|
+
const result = await resumeGenericThread(
|
|
1980
|
+
existing.threadId,
|
|
1981
|
+
prompt,
|
|
1982
|
+
cwd,
|
|
1983
|
+
timeoutMs,
|
|
1984
|
+
restExtra,
|
|
1985
|
+
sdkName,
|
|
1986
|
+
);
|
|
1987
|
+
|
|
1988
|
+
if (result.success) {
|
|
1989
|
+
existing.turnCount += 1;
|
|
1990
|
+
existing.lastUsedAt = Date.now();
|
|
1991
|
+
existing.lastError = null;
|
|
1992
|
+
threadRegistry.set(taskKey, existing);
|
|
1993
|
+
saveThreadRegistry().catch(() => {});
|
|
1994
|
+
return { ...result, resumed: true };
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
console.warn(
|
|
1998
|
+
`${TAG} context-carry resume failed for task "${taskKey}": ${result.error}`,
|
|
1999
|
+
);
|
|
2000
|
+
existing.alive = false;
|
|
2001
|
+
existing.lastError = result.error || existing.lastError || null;
|
|
2002
|
+
threadRegistry.set(taskKey, existing);
|
|
2003
|
+
saveThreadRegistry().catch(() => {});
|
|
2004
|
+
}
|
|
2005
|
+
} // close else for turn-count / absolute-age guard
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// Fresh launch — pre-register a thread as soon as the SDK exposes one.
|
|
2009
|
+
// This improves restart recovery for long-running tasks interrupted mid-turn.
|
|
2010
|
+
const callerOnThreadReady =
|
|
2011
|
+
typeof restExtra.onThreadReady === "function"
|
|
2012
|
+
? restExtra.onThreadReady
|
|
2013
|
+
: null;
|
|
2014
|
+
const launchExtra = { ...restExtra };
|
|
2015
|
+
launchExtra.onThreadReady = (threadId, sdkName = null) => {
|
|
2016
|
+
const resolvedSdk =
|
|
2017
|
+
sdkName || launchExtra.sdk || resolvePoolSdkName() || "unknown";
|
|
2018
|
+
const sdkCanPersist = sdkSupportsPersistentThreads(resolvedSdk);
|
|
2019
|
+
|
|
2020
|
+
if (threadId && sdkCanPersist) {
|
|
2021
|
+
const existing = threadRegistry.get(taskKey);
|
|
2022
|
+
const createdAt = existing?.createdAt || Date.now();
|
|
2023
|
+
const turnCount = Number(existing?.turnCount || 1);
|
|
2024
|
+
threadRegistry.set(taskKey, {
|
|
2025
|
+
threadId,
|
|
2026
|
+
sdk: resolvedSdk,
|
|
2027
|
+
taskKey,
|
|
2028
|
+
cwd,
|
|
2029
|
+
turnCount,
|
|
2030
|
+
createdAt,
|
|
2031
|
+
lastUsedAt: Date.now(),
|
|
2032
|
+
lastError: null,
|
|
2033
|
+
alive: true,
|
|
2034
|
+
});
|
|
2035
|
+
saveThreadRegistry().catch(() => {});
|
|
2036
|
+
}
|
|
2037
|
+
if (callerOnThreadReady) {
|
|
2038
|
+
try {
|
|
2039
|
+
callerOnThreadReady(threadId, sdkName);
|
|
2040
|
+
} catch {
|
|
2041
|
+
/* caller errors must not break execution */
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
const result = await launchEphemeralThread(
|
|
2047
|
+
prompt,
|
|
2048
|
+
cwd,
|
|
2049
|
+
timeoutMs,
|
|
2050
|
+
launchExtra,
|
|
2051
|
+
);
|
|
2052
|
+
|
|
2053
|
+
// Register/update thread record for future resume
|
|
2054
|
+
const existingRecord = threadRegistry.get(taskKey);
|
|
2055
|
+
const resultSdk =
|
|
2056
|
+
result.sdk ||
|
|
2057
|
+
launchExtra.sdk ||
|
|
2058
|
+
existingRecord?.sdk ||
|
|
2059
|
+
resolvePoolSdkName() ||
|
|
2060
|
+
"unknown";
|
|
2061
|
+
const sdkCanPersist = sdkSupportsPersistentThreads(resultSdk);
|
|
2062
|
+
const finalThreadId = sdkCanPersist
|
|
2063
|
+
? result.threadId ||
|
|
2064
|
+
(existingRecord?.sdk === resultSdk ? existingRecord?.threadId : null) ||
|
|
2065
|
+
null
|
|
2066
|
+
: null;
|
|
2067
|
+
const record = {
|
|
2068
|
+
threadId: finalThreadId,
|
|
2069
|
+
sdk: resultSdk,
|
|
2070
|
+
taskKey,
|
|
2071
|
+
cwd,
|
|
2072
|
+
turnCount: Number(existingRecord?.turnCount || 1),
|
|
2073
|
+
createdAt: existingRecord?.createdAt || Date.now(),
|
|
2074
|
+
lastUsedAt: Date.now(),
|
|
2075
|
+
lastError: result.success ? null : result.error,
|
|
2076
|
+
alive: result.success && sdkCanPersist && !!finalThreadId,
|
|
2077
|
+
};
|
|
2078
|
+
threadRegistry.set(taskKey, record);
|
|
2079
|
+
saveThreadRegistry().catch(() => {});
|
|
2080
|
+
|
|
2081
|
+
return { ...result, threadId: finalThreadId, resumed: false };
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// ---------------------------------------------------------------------------
|
|
2085
|
+
// Error Recovery Wrapper
|
|
2086
|
+
// ---------------------------------------------------------------------------
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Execute a prompt with automatic error recovery via thread resume.
|
|
2090
|
+
*
|
|
2091
|
+
* If the initial run fails, this will:
|
|
2092
|
+
* 1. Resume the same thread with the error context
|
|
2093
|
+
* 2. Ask the agent to diagnose and fix the issue
|
|
2094
|
+
* 3. Retry up to `maxRetries` times
|
|
2095
|
+
*
|
|
2096
|
+
* Supports mid-execution CONTINUE signals:
|
|
2097
|
+
* When the AbortController is aborted with reason "idle_continue",
|
|
2098
|
+
* the current attempt is treated as a soft failure and retried with a
|
|
2099
|
+
* CONTINUE prompt. A fresh AbortController is created for the next attempt.
|
|
2100
|
+
* Up to `maxContinues` additional attempts are allowed for idle continues.
|
|
2101
|
+
*
|
|
2102
|
+
* @param {string} prompt Initial prompt.
|
|
2103
|
+
* @param {object} options Options:
|
|
2104
|
+
* @param {string} options.taskKey Required — identifies the thread.
|
|
2105
|
+
* @param {string} [options.cwd] Working directory.
|
|
2106
|
+
* @param {number} [options.timeoutMs] Per-attempt timeout.
|
|
2107
|
+
* @param {number} [options.maxRetries] Max follow-up attempts (default: 2).
|
|
2108
|
+
* @param {number} [options.maxContinues] Max idle-continue attempts (default: 3).
|
|
2109
|
+
* @param {Function} [options.shouldRetry] Custom predicate: (result) => boolean.
|
|
2110
|
+
* @param {Function} [options.buildRetryPrompt] Custom retry prompt builder: (result, attempt) => string.
|
|
2111
|
+
* @param {Function} [options.buildContinuePrompt] Custom continue prompt builder: (result, attempt) => string.
|
|
2112
|
+
* @param {string} [options.sdk] Force SDK.
|
|
2113
|
+
* @param {string} [options.model] Force model for SDKs that support it.
|
|
2114
|
+
* @param {Function} [options.onEvent] Event callback.
|
|
2115
|
+
* @param {Function} [options.onAbortControllerReplaced] Called when AbortController is replaced after idle_continue.
|
|
2116
|
+
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, attempts: number, continues: number, resumed: boolean }>}
|
|
2117
|
+
*/
|
|
2118
|
+
export async function execWithRetry(prompt, options = {}) {
|
|
2119
|
+
const {
|
|
2120
|
+
taskKey,
|
|
2121
|
+
cwd = REPO_ROOT,
|
|
2122
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
2123
|
+
maxRetries = 2,
|
|
2124
|
+
maxContinues = 3,
|
|
2125
|
+
shouldRetry,
|
|
2126
|
+
buildRetryPrompt,
|
|
2127
|
+
buildContinuePrompt,
|
|
2128
|
+
sdk,
|
|
2129
|
+
model,
|
|
2130
|
+
onEvent,
|
|
2131
|
+
onAbortControllerReplaced,
|
|
2132
|
+
} = options;
|
|
2133
|
+
|
|
2134
|
+
// AbortController can be replaced on idle_continue, so track it mutably
|
|
2135
|
+
let abortController = options.abortController ?? null;
|
|
2136
|
+
|
|
2137
|
+
if (!taskKey) {
|
|
2138
|
+
throw new Error(
|
|
2139
|
+
`${TAG} execWithRetry requires a taskKey for thread persistence`,
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
let lastResult = null;
|
|
2144
|
+
const totalAttempts = 1 + maxRetries;
|
|
2145
|
+
let continuesUsed = 0;
|
|
2146
|
+
let attempt = 0;
|
|
2147
|
+
|
|
2148
|
+
while (attempt < totalAttempts + continuesUsed) {
|
|
2149
|
+
attempt++;
|
|
2150
|
+
const isIdleContinue =
|
|
2151
|
+
lastResult?.error === "idle_continue" ||
|
|
2152
|
+
lastResult?._idleContinue === true;
|
|
2153
|
+
|
|
2154
|
+
const currentPrompt =
|
|
2155
|
+
attempt === 1
|
|
2156
|
+
? prompt
|
|
2157
|
+
: isIdleContinue && typeof buildContinuePrompt === "function"
|
|
2158
|
+
? buildContinuePrompt(lastResult, attempt)
|
|
2159
|
+
: typeof buildRetryPrompt === "function"
|
|
2160
|
+
? buildRetryPrompt(lastResult, attempt)
|
|
2161
|
+
: `# ERROR RECOVERY — Attempt ${attempt}/${totalAttempts}\n\nYour previous attempt failed with:\n\`\`\`\n${lastResult?.error || lastResult?.output || "(unknown error)"}\n\`\`\`\n\nPlease diagnose the issue, fix it, and try again. Here was the original task:\n\n${prompt}`;
|
|
2162
|
+
|
|
2163
|
+
console.log(
|
|
2164
|
+
`${TAG} execWithRetry: attempt ${attempt}/${totalAttempts + continuesUsed} for task "${taskKey}"${attempt > 1 ? (isIdleContinue ? " (idle-continue)" : " (resume)") : ""}`,
|
|
2165
|
+
);
|
|
2166
|
+
|
|
2167
|
+
// Check if externally aborted (hard kill, not idle_continue)
|
|
2168
|
+
if (abortController?.signal?.aborted) {
|
|
2169
|
+
const reason = abortController.signal.reason;
|
|
2170
|
+
|
|
2171
|
+
if (reason === "idle_continue" && continuesUsed < maxContinues) {
|
|
2172
|
+
// Soft abort — agent went idle, send CONTINUE
|
|
2173
|
+
continuesUsed++;
|
|
2174
|
+
console.log(
|
|
2175
|
+
`${TAG} idle_continue detected for "${taskKey}" (continue ${continuesUsed}/${maxContinues}) — sending CONTINUE prompt`,
|
|
2176
|
+
);
|
|
2177
|
+
|
|
2178
|
+
// Replace the AbortController so the next attempt isn't pre-aborted
|
|
2179
|
+
abortController = new AbortController();
|
|
2180
|
+
if (typeof onAbortControllerReplaced === "function") {
|
|
2181
|
+
try {
|
|
2182
|
+
onAbortControllerReplaced(abortController);
|
|
2183
|
+
} catch {
|
|
2184
|
+
/* caller errors must not break execution */
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
lastResult = {
|
|
2189
|
+
success: false,
|
|
2190
|
+
output: lastResult?.output || "",
|
|
2191
|
+
items: lastResult?.items || [],
|
|
2192
|
+
error: "idle_continue",
|
|
2193
|
+
sdk: sdk || "unknown",
|
|
2194
|
+
threadId: lastResult?.threadId || null,
|
|
2195
|
+
_idleContinue: true,
|
|
2196
|
+
};
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Hard abort (watchdog_timeout or unknown)
|
|
2201
|
+
lastResult = {
|
|
2202
|
+
success: false,
|
|
2203
|
+
output: "",
|
|
2204
|
+
items: [],
|
|
2205
|
+
error: `Externally aborted (${reason || "watchdog or manual kill"})`,
|
|
2206
|
+
sdk: sdk || "unknown",
|
|
2207
|
+
threadId: null,
|
|
2208
|
+
};
|
|
2209
|
+
break;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
lastResult = await launchOrResumeThread(currentPrompt, cwd, timeoutMs, {
|
|
2213
|
+
taskKey,
|
|
2214
|
+
sdk,
|
|
2215
|
+
model,
|
|
2216
|
+
onEvent,
|
|
2217
|
+
abortController,
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
// Check post-launch if aborted with idle_continue (race: abort fired during execution)
|
|
2221
|
+
if (
|
|
2222
|
+
!lastResult.success &&
|
|
2223
|
+
abortController?.signal?.aborted &&
|
|
2224
|
+
abortController.signal.reason === "idle_continue" &&
|
|
2225
|
+
continuesUsed < maxContinues
|
|
2226
|
+
) {
|
|
2227
|
+
continuesUsed++;
|
|
2228
|
+
console.log(
|
|
2229
|
+
`${TAG} idle_continue (post-launch) for "${taskKey}" (continue ${continuesUsed}/${maxContinues})`,
|
|
2230
|
+
);
|
|
2231
|
+
|
|
2232
|
+
abortController = new AbortController();
|
|
2233
|
+
if (typeof onAbortControllerReplaced === "function") {
|
|
2234
|
+
try {
|
|
2235
|
+
onAbortControllerReplaced(abortController);
|
|
2236
|
+
} catch {
|
|
2237
|
+
/* best-effort */
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
lastResult._idleContinue = true;
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Check if we should retry
|
|
2246
|
+
if (lastResult.success) {
|
|
2247
|
+
// If caller has custom shouldRetry (e.g. "output must contain 'PASS'"), check it
|
|
2248
|
+
if (typeof shouldRetry === "function" && shouldRetry(lastResult)) {
|
|
2249
|
+
console.log(
|
|
2250
|
+
`${TAG} attempt ${attempt} succeeded but shouldRetry returned true`,
|
|
2251
|
+
);
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
return { ...lastResult, attempts: attempt, continues: continuesUsed };
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// Failed — should we retry?
|
|
2258
|
+
const retriesLeft = totalAttempts + continuesUsed - attempt;
|
|
2259
|
+
if (retriesLeft > 0) {
|
|
2260
|
+
if (typeof shouldRetry === "function" && !shouldRetry(lastResult)) {
|
|
2261
|
+
// Custom predicate says don't retry
|
|
2262
|
+
console.log(`${TAG} shouldRetry returned false — not retrying`);
|
|
2263
|
+
return { ...lastResult, attempts: attempt, continues: continuesUsed };
|
|
2264
|
+
}
|
|
2265
|
+
console.warn(
|
|
2266
|
+
`${TAG} attempt ${attempt} failed, will retry (${retriesLeft} left): ${lastResult.error}`,
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
return { ...lastResult, attempts: attempt, continues: continuesUsed };
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// ---------------------------------------------------------------------------
|
|
2275
|
+
// Thread Management Exports
|
|
2276
|
+
// ---------------------------------------------------------------------------
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Get the thread record for a task key.
|
|
2280
|
+
* @param {string} taskKey
|
|
2281
|
+
* @returns {ThreadRecord|null}
|
|
2282
|
+
*/
|
|
2283
|
+
export function getThreadRecord(taskKey) {
|
|
2284
|
+
return threadRegistry.get(taskKey) || null;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function markThreadRecordDead(taskKey) {
|
|
2288
|
+
const record = threadRegistry.get(taskKey);
|
|
2289
|
+
if (!record) return false;
|
|
2290
|
+
if (!record.alive) return false;
|
|
2291
|
+
record.alive = false;
|
|
2292
|
+
threadRegistry.set(taskKey, record);
|
|
2293
|
+
return true;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* Async invalidate helper that first loads persisted registry state.
|
|
2298
|
+
* Useful at process startup to avoid races with lazy registry restore.
|
|
2299
|
+
*
|
|
2300
|
+
* @param {string} taskKey
|
|
2301
|
+
* @returns {Promise<void>}
|
|
2302
|
+
*/
|
|
2303
|
+
export async function invalidateThreadAsync(taskKey) {
|
|
2304
|
+
if (!taskKey) return;
|
|
2305
|
+
await ensureThreadRegistryLoaded();
|
|
2306
|
+
if (markThreadRecordDead(taskKey)) {
|
|
2307
|
+
await saveThreadRegistry().catch(() => {});
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/**
|
|
2312
|
+
* Invalidate (kill) a thread record so it won't be resumed.
|
|
2313
|
+
* @param {string} taskKey
|
|
2314
|
+
*/
|
|
2315
|
+
export function invalidateThread(taskKey) {
|
|
2316
|
+
if (!taskKey) return;
|
|
2317
|
+
if (markThreadRecordDead(taskKey)) {
|
|
2318
|
+
saveThreadRegistry().catch(() => {});
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
// If registry hasn't loaded yet, defer invalidation until load completes.
|
|
2322
|
+
if (!threadRegistryLoaded) {
|
|
2323
|
+
void invalidateThreadAsync(taskKey);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Invalidate a thread and force a fresh start on next attempt.
|
|
2329
|
+
* Unlike invalidateThread which just sets alive=false, this also logs the reason.
|
|
2330
|
+
* @param {string} taskKey
|
|
2331
|
+
* @param {string} reason
|
|
2332
|
+
*/
|
|
2333
|
+
export function forceNewThread(taskKey, reason = "manual") {
|
|
2334
|
+
const record = threadRegistry.get(taskKey);
|
|
2335
|
+
if (record) {
|
|
2336
|
+
console.log(
|
|
2337
|
+
`${TAG} force-invalidating thread for task "${taskKey}": ${reason} (was turn ${record.turnCount})`,
|
|
2338
|
+
);
|
|
2339
|
+
}
|
|
2340
|
+
invalidateThread(taskKey);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* Clear all thread records (e.g. on monitor restart).
|
|
2345
|
+
*/
|
|
2346
|
+
export function clearThreadRegistry() {
|
|
2347
|
+
threadRegistry.clear();
|
|
2348
|
+
saveThreadRegistry().catch(() => {});
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/**
|
|
2352
|
+
* Prune all threads that have exceeded MAX_THREAD_TURNS or are older than THREAD_MAX_ABSOLUTE_AGE_MS.
|
|
2353
|
+
* Call on startup to clean up zombie threads from prior runs.
|
|
2354
|
+
* @returns {number} Number of threads pruned
|
|
2355
|
+
*/
|
|
2356
|
+
export function pruneAllExhaustedThreads() {
|
|
2357
|
+
let pruned = 0;
|
|
2358
|
+
const now = Date.now();
|
|
2359
|
+
for (const [key, record] of threadRegistry) {
|
|
2360
|
+
let reason = null;
|
|
2361
|
+
if (record.turnCount >= MAX_THREAD_TURNS) {
|
|
2362
|
+
reason = `${record.turnCount} turns (max ${MAX_THREAD_TURNS})`;
|
|
2363
|
+
} else if (now - record.createdAt > THREAD_MAX_ABSOLUTE_AGE_MS) {
|
|
2364
|
+
reason = `absolute age ${Math.round((now - record.createdAt) / 3600000)}h`;
|
|
2365
|
+
} else if (!record.alive) {
|
|
2366
|
+
reason = "already dead";
|
|
2367
|
+
}
|
|
2368
|
+
if (reason) {
|
|
2369
|
+
console.log(`${TAG} pruning thread for task "${key}": ${reason}`);
|
|
2370
|
+
record.alive = false;
|
|
2371
|
+
threadRegistry.set(key, record);
|
|
2372
|
+
pruned++;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
if (pruned > 0) {
|
|
2376
|
+
saveThreadRegistry().catch(() => {});
|
|
2377
|
+
console.log(`${TAG} pruned ${pruned} exhausted/stale threads`);
|
|
2378
|
+
}
|
|
2379
|
+
return pruned;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/**
|
|
2383
|
+
* Get summary of all active threads.
|
|
2384
|
+
* @returns {Array<{ taskKey: string, sdk: string, threadId: string|null, turnCount: number, age: number }>}
|
|
2385
|
+
*/
|
|
2386
|
+
export function getActiveThreads() {
|
|
2387
|
+
const now = Date.now();
|
|
2388
|
+
const result = [];
|
|
2389
|
+
for (const [key, record] of threadRegistry) {
|
|
2390
|
+
if (!record.alive) continue;
|
|
2391
|
+
if (now - record.lastUsedAt > THREAD_MAX_AGE_MS) continue;
|
|
2392
|
+
if (now - record.createdAt > THREAD_MAX_ABSOLUTE_AGE_MS) continue;
|
|
2393
|
+
if (record.turnCount >= MAX_THREAD_TURNS) continue;
|
|
2394
|
+
result.push({
|
|
2395
|
+
taskKey: key,
|
|
2396
|
+
sdk: record.sdk,
|
|
2397
|
+
threadId: record.threadId,
|
|
2398
|
+
turnCount: record.turnCount,
|
|
2399
|
+
age: now - record.createdAt,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
return result;
|
|
2403
|
+
}
|