ai-task-board-bridge 0.9.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/README.md +253 -0
- package/dist/activity-sanitizer.d.ts +5 -0
- package/dist/activity-sanitizer.d.ts.map +1 -0
- package/dist/activity-sanitizer.js +55 -0
- package/dist/activity-sanitizer.js.map +1 -0
- package/dist/app-server-client.d.ts +328 -0
- package/dist/app-server-client.d.ts.map +1 -0
- package/dist/app-server-client.js +524 -0
- package/dist/app-server-client.js.map +1 -0
- package/dist/bridge.d.ts +139 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +2769 -0
- package/dist/bridge.js.map +1 -0
- package/dist/claim-retry.d.ts +10 -0
- package/dist/claim-retry.d.ts.map +1 -0
- package/dist/claim-retry.js +19 -0
- package/dist/claim-retry.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +92 -0
- package/dist/cli.js.map +1 -0
- package/dist/history-sync.d.ts +120 -0
- package/dist/history-sync.d.ts.map +1 -0
- package/dist/history-sync.js +718 -0
- package/dist/history-sync.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/setup.d.ts +32 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +822 -0
- package/dist/setup.js.map +1 -0
- package/dist/wake-client.d.ts +35 -0
- package/dist/wake-client.d.ts.map +1 -0
- package/dist/wake-client.js +219 -0
- package/dist/wake-client.js.map +1 -0
- package/dist/working-directories.d.ts +24 -0
- package/dist/working-directories.d.ts.map +1 -0
- package/dist/working-directories.js +158 -0
- package/dist/working-directories.js.map +1 -0
- package/package.json +51 -0
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,2769 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AppServerRpcError, CodexAppServerClient, } from "./app-server-client.js";
|
|
4
|
+
import { HistorySynchronizer, isInteractiveHistoryThread, } from "./history-sync.js";
|
|
5
|
+
import { boundActivityData, redactHarnessText, sanitizeHarnessValue, } from "./activity-sanitizer.js";
|
|
6
|
+
import { isSessionActiveClaimConflict, nextClaimAction, } from "./claim-retry.js";
|
|
7
|
+
import { adaptiveIdlePollDelay, runSessionWakeListener, WakeLatch, } from "./wake-client.js";
|
|
8
|
+
import { managedDirectoryForWorkingDirectory, parseRemoteWorkingDirectories, parseWorkingDirectories, remoteWorkingDirectories, workingDirectoryForThreadCreate, } from "./working-directories.js";
|
|
9
|
+
export { isExactWorkingDirectory, managedDirectoryForWorkingDirectory, parseRemoteWorkingDirectories, parseWorkingDirectories, remoteWorkingDirectories, workingDirectoryForThreadCreate, } from "./working-directories.js";
|
|
10
|
+
const BRIDGE_VERSION = "0.9.0";
|
|
11
|
+
const APP_SERVER_PROTOCOL = "codex-app-server/v1";
|
|
12
|
+
const THREAD_SOURCE_KINDS = ["cli", "vscode", "exec", "appServer"];
|
|
13
|
+
const DELTA_CHUNK_BYTES = 8_192;
|
|
14
|
+
const ACCUMULATED_TEXT_LIMIT = 100_000;
|
|
15
|
+
const STREAM_TRUNCATION_MARKER = "\n…[流式输出已截断]";
|
|
16
|
+
const MAX_NOTIFICATION_BACKLOG = 256;
|
|
17
|
+
const MAX_ACTIVITY_BACKLOG = 64;
|
|
18
|
+
const USER_INPUT_POLL_INTERVAL_MS = 1_500;
|
|
19
|
+
const MAX_CONCURRENT_TURNS = 32;
|
|
20
|
+
const MAX_MODEL_CATALOG_ENTRIES = 500;
|
|
21
|
+
const MODEL_CATALOG_PAGE_SIZE = 100;
|
|
22
|
+
function isRecord(value) {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
function stringValue(value) {
|
|
26
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
27
|
+
}
|
|
28
|
+
function boundedCatalogString(value, maximumLength) {
|
|
29
|
+
const parsed = stringValue(value);
|
|
30
|
+
return parsed && parsed.length <= maximumLength ? parsed : null;
|
|
31
|
+
}
|
|
32
|
+
function inventoryModel(value, allowDefault) {
|
|
33
|
+
if (!isRecord(value))
|
|
34
|
+
return null;
|
|
35
|
+
const model = boundedCatalogString(value.model, 200);
|
|
36
|
+
const id = boundedCatalogString(value.id, 200) ?? model;
|
|
37
|
+
if (!id || !model)
|
|
38
|
+
return null;
|
|
39
|
+
const efforts = [];
|
|
40
|
+
const seenEfforts = new Set();
|
|
41
|
+
if (Array.isArray(value.supportedReasoningEfforts)) {
|
|
42
|
+
for (const candidate of value.supportedReasoningEfforts.slice(0, 20)) {
|
|
43
|
+
if (!isRecord(candidate))
|
|
44
|
+
continue;
|
|
45
|
+
const reasoningEffort = boundedCatalogString(candidate.reasoningEffort, 100);
|
|
46
|
+
if (!reasoningEffort || seenEfforts.has(reasoningEffort))
|
|
47
|
+
continue;
|
|
48
|
+
seenEfforts.add(reasoningEffort);
|
|
49
|
+
efforts.push({
|
|
50
|
+
reasoning_effort: reasoningEffort,
|
|
51
|
+
description: typeof candidate.description === "string"
|
|
52
|
+
? candidate.description.trim().slice(0, 2_000) || null
|
|
53
|
+
: null,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const inputModalities = Array.isArray(value.inputModalities)
|
|
58
|
+
? [
|
|
59
|
+
...new Set(value.inputModalities
|
|
60
|
+
.slice(0, 20)
|
|
61
|
+
.map((modality) => boundedCatalogString(modality, 100))
|
|
62
|
+
.filter((modality) => Boolean(modality))),
|
|
63
|
+
]
|
|
64
|
+
: [];
|
|
65
|
+
const defaultEffort = boundedCatalogString(value.defaultReasoningEffort, 100);
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
model,
|
|
69
|
+
display_name: boundedCatalogString(value.displayName, 200) ?? model,
|
|
70
|
+
description: typeof value.description === "string"
|
|
71
|
+
? value.description.trim().slice(0, 2_000) || null
|
|
72
|
+
: null,
|
|
73
|
+
default_reasoning_effort: defaultEffort,
|
|
74
|
+
supported_reasoning_efforts: efforts,
|
|
75
|
+
input_modalities: inputModalities,
|
|
76
|
+
is_default: allowDefault && value.isDefault === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function parseStructuredUserInputRequest(value) {
|
|
80
|
+
if (!isRecord(value))
|
|
81
|
+
throw new Error("结构化问题参数无效");
|
|
82
|
+
const turnId = stringValue(value.turnId);
|
|
83
|
+
const itemId = stringValue(value.itemId);
|
|
84
|
+
if (!turnId || !itemId || value.isBlocking !== true) {
|
|
85
|
+
throw new Error("结构化问题缺少 blocking turn/item 标识");
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(value.questions) || value.questions.length < 1 || value.questions.length > 3) {
|
|
88
|
+
throw new Error("结构化问题数量必须为 1 到 3");
|
|
89
|
+
}
|
|
90
|
+
const ids = new Set();
|
|
91
|
+
const questions = value.questions.map((candidate) => {
|
|
92
|
+
if (!isRecord(candidate))
|
|
93
|
+
throw new Error("结构化问题格式无效");
|
|
94
|
+
const id = stringValue(candidate.id);
|
|
95
|
+
const header = stringValue(candidate.header);
|
|
96
|
+
const question = stringValue(candidate.question);
|
|
97
|
+
if (!id || id.length > 200 || ids.has(id) || !header || !question) {
|
|
98
|
+
throw new Error("结构化问题字段无效或 id 重复");
|
|
99
|
+
}
|
|
100
|
+
ids.add(id);
|
|
101
|
+
let options = null;
|
|
102
|
+
if (candidate.options !== null && candidate.options !== undefined) {
|
|
103
|
+
if (!Array.isArray(candidate.options) ||
|
|
104
|
+
candidate.options.length < 1 ||
|
|
105
|
+
candidate.options.length > 20) {
|
|
106
|
+
throw new Error("结构化问题选项无效");
|
|
107
|
+
}
|
|
108
|
+
const labels = new Set();
|
|
109
|
+
options = candidate.options.map((option) => {
|
|
110
|
+
if (!isRecord(option))
|
|
111
|
+
throw new Error("结构化问题选项格式无效");
|
|
112
|
+
const label = stringValue(option.label);
|
|
113
|
+
if (!label || label.length > 500 || labels.has(label)) {
|
|
114
|
+
throw new Error("结构化问题选项标签无效或重复");
|
|
115
|
+
}
|
|
116
|
+
if (typeof option.description !== "string" || option.description.length > 2_000) {
|
|
117
|
+
throw new Error("结构化问题选项说明无效");
|
|
118
|
+
}
|
|
119
|
+
labels.add(label);
|
|
120
|
+
return { label, description: option.description };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
id,
|
|
125
|
+
header: header.slice(0, 100),
|
|
126
|
+
question: question.slice(0, 10_000),
|
|
127
|
+
options,
|
|
128
|
+
isOther: candidate.isOther === true,
|
|
129
|
+
isSecret: candidate.isSecret === true,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
return { turnId, itemId, isBlocking: true, questions };
|
|
133
|
+
}
|
|
134
|
+
export function parseStructuredUserInputAnswers(value, questions) {
|
|
135
|
+
if (!isRecord(value))
|
|
136
|
+
throw new Error("Web Console 回答格式无效");
|
|
137
|
+
const result = {};
|
|
138
|
+
for (const question of questions) {
|
|
139
|
+
const candidate = value[question.id];
|
|
140
|
+
if (!Array.isArray(candidate) ||
|
|
141
|
+
candidate.length !== 1 ||
|
|
142
|
+
typeof candidate[0] !== "string" ||
|
|
143
|
+
!candidate[0].trim()) {
|
|
144
|
+
throw new Error(`Web Console 未返回问题 ${question.id} 的有效答案`);
|
|
145
|
+
}
|
|
146
|
+
result[question.id] = { answers: [candidate[0]] };
|
|
147
|
+
}
|
|
148
|
+
if (Object.keys(value).length !== questions.length) {
|
|
149
|
+
throw new Error("Web Console 回答包含未知问题");
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
function parseList(value) {
|
|
154
|
+
return [
|
|
155
|
+
...new Set(value
|
|
156
|
+
.split(/[,,\s]+/)
|
|
157
|
+
.map((item) => item.trim())
|
|
158
|
+
.filter(Boolean)),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
162
|
+
const parsed = value ? Number(value) : fallback;
|
|
163
|
+
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum
|
|
164
|
+
? parsed
|
|
165
|
+
: fallback;
|
|
166
|
+
}
|
|
167
|
+
function parseApprovalMode(value) {
|
|
168
|
+
const mode = value?.trim();
|
|
169
|
+
if (!mode || mode === "accept")
|
|
170
|
+
return "accept";
|
|
171
|
+
if (mode === "decline" || mode === "accept-session")
|
|
172
|
+
return mode;
|
|
173
|
+
throw new Error("CODEX_BRIDGE_APPROVAL_MODE must be accept, decline, or accept-session");
|
|
174
|
+
}
|
|
175
|
+
function parsePermissionMode(value) {
|
|
176
|
+
const mode = value?.trim();
|
|
177
|
+
if (!mode || mode === "danger-full-access") {
|
|
178
|
+
return "danger-full-access";
|
|
179
|
+
}
|
|
180
|
+
if (mode === "safe" || mode === "inherit")
|
|
181
|
+
return mode;
|
|
182
|
+
throw new Error("CODEX_BRIDGE_PERMISSION_MODE must be danger-full-access, safe, or inherit");
|
|
183
|
+
}
|
|
184
|
+
function threadPermissionOverrides(mode, cwd) {
|
|
185
|
+
if (mode === "inherit")
|
|
186
|
+
return {};
|
|
187
|
+
return {
|
|
188
|
+
cwd,
|
|
189
|
+
approvalPolicy: "on-request",
|
|
190
|
+
approvalsReviewer: "user",
|
|
191
|
+
sandbox: mode === "safe" ? "workspace-write" : "danger-full-access",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function turnPermissionOverrides(mode, cwd) {
|
|
195
|
+
if (mode === "inherit")
|
|
196
|
+
return {};
|
|
197
|
+
return {
|
|
198
|
+
cwd,
|
|
199
|
+
approvalPolicy: "on-request",
|
|
200
|
+
approvalsReviewer: "user",
|
|
201
|
+
sandboxPolicy: mode === "safe"
|
|
202
|
+
? {
|
|
203
|
+
type: "workspaceWrite",
|
|
204
|
+
writableRoots: [cwd],
|
|
205
|
+
networkAccess: false,
|
|
206
|
+
excludeTmpdirEnvVar: true,
|
|
207
|
+
excludeSlashTmp: true,
|
|
208
|
+
}
|
|
209
|
+
: { type: "dangerFullAccess" },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function parseThreadScope(value) {
|
|
213
|
+
return value === "all" ? "all" : "cwd";
|
|
214
|
+
}
|
|
215
|
+
function parseBoolean(value) {
|
|
216
|
+
return value?.trim().toLowerCase() === "true";
|
|
217
|
+
}
|
|
218
|
+
function copyWorkingDirectories(directories) {
|
|
219
|
+
return directories.map((directory) => ({ ...directory }));
|
|
220
|
+
}
|
|
221
|
+
export function appendBoundedPrefix(current, addition, limit = ACCUMULATED_TEXT_LIMIT) {
|
|
222
|
+
if (current.length >= limit || !addition)
|
|
223
|
+
return current;
|
|
224
|
+
let end = Math.min(addition.length, limit - current.length);
|
|
225
|
+
if (end > 0 &&
|
|
226
|
+
end < addition.length &&
|
|
227
|
+
/[\uD800-\uDBFF]/.test(addition[end - 1] ?? "")) {
|
|
228
|
+
end -= 1;
|
|
229
|
+
}
|
|
230
|
+
return current + addition.slice(0, end);
|
|
231
|
+
}
|
|
232
|
+
export function acceptBoundedStreamDelta(current, addition, limit = ACCUMULATED_TEXT_LIMIT) {
|
|
233
|
+
const remaining = Math.max(0, limit - current.length);
|
|
234
|
+
if (addition.length <= remaining) {
|
|
235
|
+
return {
|
|
236
|
+
accepted: addition,
|
|
237
|
+
accumulated: current + addition,
|
|
238
|
+
truncated: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const prefixLimit = Math.max(0, remaining - STREAM_TRUNCATION_MARKER.length);
|
|
242
|
+
const prefix = appendBoundedPrefix("", addition, prefixLimit);
|
|
243
|
+
const marker = STREAM_TRUNCATION_MARKER.slice(0, Math.max(0, remaining - prefix.length));
|
|
244
|
+
const accepted = prefix + marker;
|
|
245
|
+
return {
|
|
246
|
+
accepted,
|
|
247
|
+
accumulated: current + accepted,
|
|
248
|
+
truncated: true,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export function* utf8DeltaChunks(value, maximumBytes = DELTA_CHUNK_BYTES) {
|
|
252
|
+
if (!Number.isInteger(maximumBytes) || maximumBytes < 4) {
|
|
253
|
+
throw new Error("maximumBytes must be an integer of at least 4");
|
|
254
|
+
}
|
|
255
|
+
let chunk = "";
|
|
256
|
+
let bytes = 0;
|
|
257
|
+
for (const character of value) {
|
|
258
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
259
|
+
if (chunk && bytes + characterBytes > maximumBytes) {
|
|
260
|
+
yield chunk;
|
|
261
|
+
chunk = "";
|
|
262
|
+
bytes = 0;
|
|
263
|
+
}
|
|
264
|
+
chunk += character;
|
|
265
|
+
bytes += characterBytes;
|
|
266
|
+
}
|
|
267
|
+
if (chunk)
|
|
268
|
+
yield chunk;
|
|
269
|
+
}
|
|
270
|
+
export function loadConfiguration(environment = process.env) {
|
|
271
|
+
const boardUrl = (environment.AI_TASK_BOARD_URL?.trim() ?? "").replace(/\/+$/, "");
|
|
272
|
+
const connectionToken = environment.AI_TASK_BOARD_CONNECTION_TOKEN?.trim() ?? "";
|
|
273
|
+
for (const [name, value] of [
|
|
274
|
+
["AI_TASK_BOARD_URL", boardUrl],
|
|
275
|
+
["AI_TASK_BOARD_CONNECTION_TOKEN", connectionToken],
|
|
276
|
+
]) {
|
|
277
|
+
if (!value)
|
|
278
|
+
throw new Error(`${name} is required`);
|
|
279
|
+
}
|
|
280
|
+
const localMaxThreads = boundedInteger(environment.CODEX_MAX_THREADS, 50, 1, 500);
|
|
281
|
+
const startupMaxConcurrentTurns = boundedInteger(environment.CODEX_MAX_CONCURRENT_TURNS, 2, 1, MAX_CONCURRENT_TURNS);
|
|
282
|
+
const localMaxHistoryTurns = boundedInteger(environment.CODEX_BRIDGE_MAX_HISTORY_TURNS, 50, 1, 200);
|
|
283
|
+
const localIncludeThreadTitles = parseBoolean(environment.CODEX_BRIDGE_INCLUDE_THREAD_TITLES);
|
|
284
|
+
const configurationPollIntervalMs = boundedInteger(environment.AI_TASK_BOARD_CONFIG_POLL_INTERVAL_MS, 10_000, 1_000, 10 * 60_000);
|
|
285
|
+
const legacyWorkingDirectory = path.resolve(environment.CODEX_WORKING_DIRECTORY?.trim() || process.cwd());
|
|
286
|
+
const localWorkingDirectories = parseWorkingDirectories(environment.CODEX_WORKING_DIRECTORIES, legacyWorkingDirectory);
|
|
287
|
+
const localWorkingDirectory = localWorkingDirectories[0]?.workingDirectory ?? legacyWorkingDirectory;
|
|
288
|
+
return {
|
|
289
|
+
boardUrl,
|
|
290
|
+
connectionToken,
|
|
291
|
+
threadIdFilter: environment.CODEX_THREAD_ID?.trim() || null,
|
|
292
|
+
// The local list is an immutable device startup boundary. The effective
|
|
293
|
+
// list begins as a copy and may later be replaced by an explicitly gated
|
|
294
|
+
// Web configuration without losing the local fallback.
|
|
295
|
+
localWorkingDirectory,
|
|
296
|
+
localWorkingDirectories: copyWorkingDirectories(localWorkingDirectories),
|
|
297
|
+
workingDirectory: localWorkingDirectory,
|
|
298
|
+
workingDirectories: copyWorkingDirectories(localWorkingDirectories),
|
|
299
|
+
sessionNamePrefix: environment.CODEX_SESSION_NAME?.trim() || null,
|
|
300
|
+
model: environment.CODEX_MODEL?.trim() || null,
|
|
301
|
+
capabilities: parseList(environment.CODEX_CAPABILITIES ||
|
|
302
|
+
"coding,shell,file-edit,multi-thread,app-server"),
|
|
303
|
+
pollIntervalMs: boundedInteger(environment.AI_TASK_BOARD_POLL_INTERVAL_MS, 5_000, 500, 60_000),
|
|
304
|
+
leaseSeconds: boundedInteger(environment.AI_TASK_BOARD_LEASE_SECONDS, 900, 60, 3_600),
|
|
305
|
+
maxThreads: localMaxThreads,
|
|
306
|
+
maxConcurrentTurns: startupMaxConcurrentTurns,
|
|
307
|
+
syncIntervalMs: boundedInteger(environment.AI_TASK_BOARD_THREAD_SYNC_INTERVAL_MS, 60_000, 10_000, 10 * 60_000),
|
|
308
|
+
configurationPollIntervalMs,
|
|
309
|
+
configurationLeaseSeconds: Math.max(15, Math.ceil(Math.min(configurationPollIntervalMs, 10_000) / 1_000) * 3),
|
|
310
|
+
approvalMode: parseApprovalMode(environment.CODEX_BRIDGE_APPROVAL_MODE),
|
|
311
|
+
permissionMode: parsePermissionMode(environment.CODEX_BRIDGE_PERMISSION_MODE),
|
|
312
|
+
threadScope: parseThreadScope(environment.CODEX_THREAD_SCOPE),
|
|
313
|
+
enabled: true,
|
|
314
|
+
includeThreadTitles: localIncludeThreadTitles,
|
|
315
|
+
syncHistory: false,
|
|
316
|
+
historyTurnLimit: localMaxHistoryTurns,
|
|
317
|
+
localIncludeThreadTitles,
|
|
318
|
+
allowRemoteThreadTitles: localIncludeThreadTitles ||
|
|
319
|
+
parseBoolean(environment.CODEX_BRIDGE_ALLOW_REMOTE_THREAD_TITLES),
|
|
320
|
+
allowHistorySync: parseBoolean(environment.CODEX_BRIDGE_ALLOW_HISTORY_SYNC),
|
|
321
|
+
allowRemoteWorkingDirectories: parseBoolean(environment.CODEX_BRIDGE_ALLOW_REMOTE_WORKING_DIRECTORIES),
|
|
322
|
+
localMaxThreads,
|
|
323
|
+
localMaxHistoryTurns,
|
|
324
|
+
webConfigurationEnabled: parseBoolean(environment.CODEX_BRIDGE_WEB_CONFIG),
|
|
325
|
+
codexBinary: environment.CODEX_BINARY?.trim() || "codex",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
export function effectiveBridgeConfiguration(configuration) {
|
|
329
|
+
return {
|
|
330
|
+
enabled: configuration.enabled,
|
|
331
|
+
includeThreadTitles: configuration.includeThreadTitles,
|
|
332
|
+
maxThreads: configuration.maxThreads,
|
|
333
|
+
maxConcurrentTurns: configuration.maxConcurrentTurns,
|
|
334
|
+
syncHistory: configuration.syncHistory,
|
|
335
|
+
historyTurnLimit: configuration.historyTurnLimit,
|
|
336
|
+
workingDirectory: configuration.workingDirectory,
|
|
337
|
+
workingDirectories: copyWorkingDirectories(configuration.workingDirectories),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
export function bridgeConfigurationConstraints(configuration) {
|
|
341
|
+
return {
|
|
342
|
+
remote_configuration_enabled: configuration.webConfigurationEnabled,
|
|
343
|
+
allow_thread_titles: configuration.allowRemoteThreadTitles,
|
|
344
|
+
allow_history_sync: configuration.allowHistorySync,
|
|
345
|
+
allow_working_directory_configuration: configuration.allowRemoteWorkingDirectories,
|
|
346
|
+
max_threads: configuration.localMaxThreads,
|
|
347
|
+
// Kept in the compatibility envelope for older Boards/Bridges. Unlike
|
|
348
|
+
// the other local constraints, concurrency is now owned by the Web
|
|
349
|
+
// setting across the full supported product range.
|
|
350
|
+
max_concurrent_turns: MAX_CONCURRENT_TURNS,
|
|
351
|
+
max_history_turns: configuration.localMaxHistoryTurns,
|
|
352
|
+
thread_scope: configuration.threadScope,
|
|
353
|
+
working_directory: configuration.localWorkingDirectory,
|
|
354
|
+
fixed_thread: configuration.threadIdFilter !== null,
|
|
355
|
+
permission_mode: configuration.permissionMode,
|
|
356
|
+
approval_mode: configuration.approvalMode,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function clampedRemoteInteger(value, maximum, field, warnings) {
|
|
360
|
+
if (!Number.isInteger(value)) {
|
|
361
|
+
throw new Error(`看板配置 ${field} 必须是整数`);
|
|
362
|
+
}
|
|
363
|
+
const clamped = Math.min(maximum, Math.max(1, value));
|
|
364
|
+
if (clamped !== value) {
|
|
365
|
+
warnings.push(`${field}=${value} 超出设备允许范围,已限制为 ${clamped}`);
|
|
366
|
+
}
|
|
367
|
+
return clamped;
|
|
368
|
+
}
|
|
369
|
+
export function resolveRemoteConfiguration(configuration, desired) {
|
|
370
|
+
if (typeof desired.enabled !== "boolean") {
|
|
371
|
+
throw new Error("看板配置 enabled 必须是布尔值");
|
|
372
|
+
}
|
|
373
|
+
if (typeof desired.include_thread_titles !== "boolean") {
|
|
374
|
+
throw new Error("看板配置 include_thread_titles 必须是布尔值");
|
|
375
|
+
}
|
|
376
|
+
const warnings = [];
|
|
377
|
+
const includeThreadTitles = desired.include_thread_titles && configuration.allowRemoteThreadTitles;
|
|
378
|
+
if (desired.include_thread_titles && !includeThreadTitles) {
|
|
379
|
+
warnings.push("看板请求上传 thread 标题,但设备未启用 CODEX_BRIDGE_ALLOW_REMOTE_THREAD_TITLES");
|
|
380
|
+
}
|
|
381
|
+
if (desired.sync_history !== undefined &&
|
|
382
|
+
typeof desired.sync_history !== "boolean") {
|
|
383
|
+
throw new Error("看板配置 sync_history 必须是布尔值");
|
|
384
|
+
}
|
|
385
|
+
if (desired.history_turn_limit !== undefined &&
|
|
386
|
+
!Number.isInteger(desired.history_turn_limit)) {
|
|
387
|
+
throw new Error("看板配置 history_turn_limit 必须是整数");
|
|
388
|
+
}
|
|
389
|
+
const syncHistory = desired.sync_history === true && configuration.allowHistorySync;
|
|
390
|
+
if (desired.sync_history === true && !syncHistory) {
|
|
391
|
+
warnings.push("看板请求同步历史,但设备未启用 CODEX_BRIDGE_ALLOW_HISTORY_SYNC");
|
|
392
|
+
}
|
|
393
|
+
let workingDirectories = copyWorkingDirectories(configuration.localWorkingDirectories);
|
|
394
|
+
if (desired.working_directories !== null &&
|
|
395
|
+
desired.working_directories !== undefined) {
|
|
396
|
+
if (configuration.allowRemoteWorkingDirectories) {
|
|
397
|
+
workingDirectories = parseRemoteWorkingDirectories(desired.working_directories);
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
warnings.push("看板请求配置工作目录,但设备未启用 CODEX_BRIDGE_ALLOW_REMOTE_WORKING_DIRECTORIES;继续使用本机启动目录");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const workingDirectory = workingDirectories[0]?.workingDirectory ??
|
|
404
|
+
configuration.localWorkingDirectory;
|
|
405
|
+
return {
|
|
406
|
+
effective: {
|
|
407
|
+
enabled: desired.enabled,
|
|
408
|
+
includeThreadTitles,
|
|
409
|
+
maxThreads: clampedRemoteInteger(desired.max_threads, configuration.localMaxThreads, "max_threads", warnings),
|
|
410
|
+
maxConcurrentTurns: clampedRemoteInteger(desired.max_concurrent_turns, MAX_CONCURRENT_TURNS, "max_concurrent_turns", warnings),
|
|
411
|
+
syncHistory,
|
|
412
|
+
historyTurnLimit: clampedRemoteInteger(desired.history_turn_limit ?? Math.min(50, configuration.localMaxHistoryTurns), configuration.localMaxHistoryTurns, "history_turn_limit", warnings),
|
|
413
|
+
workingDirectory,
|
|
414
|
+
workingDirectories,
|
|
415
|
+
},
|
|
416
|
+
warnings,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
export function delay(ms, signal) {
|
|
420
|
+
if (signal?.aborted)
|
|
421
|
+
return Promise.reject(signal.reason);
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
424
|
+
const timer = setTimeout(() => {
|
|
425
|
+
cleanup();
|
|
426
|
+
resolve();
|
|
427
|
+
}, ms);
|
|
428
|
+
const onAbort = () => {
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
cleanup();
|
|
431
|
+
reject(signal?.reason);
|
|
432
|
+
};
|
|
433
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
function idempotencyKey(operation) {
|
|
437
|
+
return `codex-bridge/${operation}/${randomUUID()}`;
|
|
438
|
+
}
|
|
439
|
+
function errorMessage(error) {
|
|
440
|
+
return error instanceof Error ? error.message : String(error);
|
|
441
|
+
}
|
|
442
|
+
function errorStatus(error) {
|
|
443
|
+
return error?.status;
|
|
444
|
+
}
|
|
445
|
+
function monotonicMilliseconds() {
|
|
446
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
447
|
+
}
|
|
448
|
+
function isPersistentClientError(error) {
|
|
449
|
+
const status = errorStatus(error);
|
|
450
|
+
return status !== undefined &&
|
|
451
|
+
status >= 400 &&
|
|
452
|
+
status < 500 &&
|
|
453
|
+
status !== 408 &&
|
|
454
|
+
status !== 429;
|
|
455
|
+
}
|
|
456
|
+
function isSessionNotAuthorizedError(error) {
|
|
457
|
+
return (errorStatus(error) === 403 &&
|
|
458
|
+
error?.code === "SESSION_NOT_AUTHORIZED");
|
|
459
|
+
}
|
|
460
|
+
class WorkerRetirementDeferredError extends Error {
|
|
461
|
+
}
|
|
462
|
+
class WorkerRetirementFailureError extends Error {
|
|
463
|
+
}
|
|
464
|
+
export async function stopWorkersForRetirement(workers, reason) {
|
|
465
|
+
const stopped = await Promise.allSettled(workers.map(({ worker }) => worker.stop(reason)));
|
|
466
|
+
const failedStops = stopped.flatMap((result, index) => result.status === "rejected"
|
|
467
|
+
? [{ threadId: workers[index]?.threadId ?? "unknown", reason: result.reason }]
|
|
468
|
+
: []);
|
|
469
|
+
for (const failure of failedStops) {
|
|
470
|
+
process.stderr.write(`Thread worker ${failure.threadId} 停止时出错:${errorMessage(failure.reason)}\n`);
|
|
471
|
+
}
|
|
472
|
+
if (failedStops.length > 0) {
|
|
473
|
+
throw new WorkerRetirementFailureError(`${failedStops.length} 个已移除 thread worker 未能安全停止;保留本地映射并终止 Bridge`, { cause: failedStops[0]?.reason });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function actionableBoardError(error) {
|
|
477
|
+
const status = errorStatus(error);
|
|
478
|
+
const detail = redactHarnessText(errorMessage(error), 2_000);
|
|
479
|
+
if (status === 401 || status === 403) {
|
|
480
|
+
return new Error(`看板认证失败(HTTP ${status}):请检查 AI_TASK_BOARD_CONNECTION_TOKEN 及连接权限。${detail ? ` ${detail}` : ""}`, { cause: error });
|
|
481
|
+
}
|
|
482
|
+
if (status === 404) {
|
|
483
|
+
return new Error(`看板缺少 Bridge 0.4 API(HTTP 404):请先升级 Board schema/API,再启动 Bridge。${detail ? ` ${detail}` : ""}`, { cause: error });
|
|
484
|
+
}
|
|
485
|
+
if (status === 409) {
|
|
486
|
+
return new Error(`Bridge 运行实例冲突(HTTP 409):同一设备连接已有另一个 Bridge 持有配置租约,请只保留一个进程。${detail ? ` ${detail}` : ""}`, { cause: error });
|
|
487
|
+
}
|
|
488
|
+
return new Error(`看板拒绝 Bridge 请求(HTTP ${status ?? "unknown"}):请检查 Board API、连接权限与版本。${detail ? ` ${detail}` : ""}`, { cause: error });
|
|
489
|
+
}
|
|
490
|
+
function remoteDesiredFromEffective(effective) {
|
|
491
|
+
return {
|
|
492
|
+
enabled: effective.enabled,
|
|
493
|
+
include_thread_titles: effective.includeThreadTitles,
|
|
494
|
+
max_threads: effective.maxThreads,
|
|
495
|
+
max_concurrent_turns: effective.maxConcurrentTurns,
|
|
496
|
+
sync_history: effective.syncHistory,
|
|
497
|
+
history_turn_limit: effective.historyTurnLimit,
|
|
498
|
+
// Effective reports always carry the concrete non-empty list, even when
|
|
499
|
+
// the Board desired value was null and the local startup list won.
|
|
500
|
+
working_directories: remoteWorkingDirectories(effective.workingDirectories),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function parseRemoteConfigurationResponse(value) {
|
|
504
|
+
if (!isRecord(value) || !isRecord(value.configuration)) {
|
|
505
|
+
throw new Error("看板配置响应缺少 configuration");
|
|
506
|
+
}
|
|
507
|
+
const configuration = value.configuration;
|
|
508
|
+
if (!Number.isInteger(configuration.version) ||
|
|
509
|
+
configuration.version < 1) {
|
|
510
|
+
throw new Error("看板配置响应 version 无效");
|
|
511
|
+
}
|
|
512
|
+
if (!isRecord(configuration.desired)) {
|
|
513
|
+
throw new Error("看板配置响应缺少 desired");
|
|
514
|
+
}
|
|
515
|
+
const desired = configuration.desired;
|
|
516
|
+
return {
|
|
517
|
+
configuration: {
|
|
518
|
+
connection_id: stringValue(configuration.connection_id) ?? "",
|
|
519
|
+
version: configuration.version,
|
|
520
|
+
desired: {
|
|
521
|
+
enabled: desired.enabled,
|
|
522
|
+
include_thread_titles: desired.include_thread_titles,
|
|
523
|
+
max_threads: desired.max_threads,
|
|
524
|
+
max_concurrent_turns: desired.max_concurrent_turns,
|
|
525
|
+
sync_history: desired.sync_history === undefined
|
|
526
|
+
? false
|
|
527
|
+
: desired.sync_history,
|
|
528
|
+
history_turn_limit: desired.history_turn_limit === undefined
|
|
529
|
+
? 50
|
|
530
|
+
: desired.history_turn_limit,
|
|
531
|
+
working_directories: desired.working_directories === undefined
|
|
532
|
+
? null
|
|
533
|
+
: desired.working_directories,
|
|
534
|
+
},
|
|
535
|
+
applied: configuration.applied,
|
|
536
|
+
updated_at: stringValue(configuration.updated_at) ?? "",
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function threadIdFromMessage(value) {
|
|
541
|
+
if (!isRecord(value))
|
|
542
|
+
return null;
|
|
543
|
+
return stringValue(value.threadId) ?? stringValue(value.conversationId);
|
|
544
|
+
}
|
|
545
|
+
function notificationTurnId(params) {
|
|
546
|
+
const turn = isRecord(params.turn) ? params.turn : null;
|
|
547
|
+
return stringValue(params.turnId) ?? (turn ? stringValue(turn.id) : null);
|
|
548
|
+
}
|
|
549
|
+
function threadCwd(thread) {
|
|
550
|
+
return stringValue(thread.cwd);
|
|
551
|
+
}
|
|
552
|
+
function shortThreadTitle(thread) {
|
|
553
|
+
const explicit = stringValue(thread.name);
|
|
554
|
+
const preview = stringValue(thread.preview)?.split(/\r?\n/, 1)[0]?.trim();
|
|
555
|
+
const cwd = threadCwd(thread);
|
|
556
|
+
return (explicit || preview || (cwd ? path.basename(cwd) : null) || thread.id)
|
|
557
|
+
.replace(/\s+/g, " ")
|
|
558
|
+
.slice(0, 120);
|
|
559
|
+
}
|
|
560
|
+
function privateThreadTitle(thread) {
|
|
561
|
+
const cwd = threadCwd(thread);
|
|
562
|
+
const project = cwd ? path.basename(path.resolve(cwd)) : "thread";
|
|
563
|
+
return `${project || "thread"} · ${thread.id.slice(0, 8)}`;
|
|
564
|
+
}
|
|
565
|
+
function sessionName(thread, configuration) {
|
|
566
|
+
const title = configuration.includeThreadTitles
|
|
567
|
+
? shortThreadTitle(thread)
|
|
568
|
+
: privateThreadTitle(thread);
|
|
569
|
+
if (configuration.sessionNamePrefix) {
|
|
570
|
+
return (configuration.threadIdFilter
|
|
571
|
+
? configuration.sessionNamePrefix
|
|
572
|
+
: `${configuration.sessionNamePrefix} · ${title}`).slice(0, 200);
|
|
573
|
+
}
|
|
574
|
+
return `Codex · ${title}`.slice(0, 200);
|
|
575
|
+
}
|
|
576
|
+
function inventoryThread(thread, configuration) {
|
|
577
|
+
const workingDirectory = threadCwd(thread);
|
|
578
|
+
const directory = managedDirectoryForWorkingDirectory(workingDirectory, configuration.workingDirectories);
|
|
579
|
+
return {
|
|
580
|
+
external_conversation_ref: thread.id,
|
|
581
|
+
name: sessionName(thread, configuration),
|
|
582
|
+
platform: "codex",
|
|
583
|
+
model: stringValue(thread.model) ?? configuration.model,
|
|
584
|
+
working_directory: workingDirectory,
|
|
585
|
+
directory_key: directory?.key ?? null,
|
|
586
|
+
capabilities: configuration.capabilities,
|
|
587
|
+
archived: false,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
class BoardClient {
|
|
591
|
+
configuration;
|
|
592
|
+
isStopping;
|
|
593
|
+
constructor(configuration, isStopping) {
|
|
594
|
+
this.configuration = configuration;
|
|
595
|
+
this.isStopping = isStopping;
|
|
596
|
+
}
|
|
597
|
+
async request(pathname, options = {}) {
|
|
598
|
+
const method = options.method ?? "GET";
|
|
599
|
+
const maxAttempts = options.maxAttempts ?? Number.POSITIVE_INFINITY;
|
|
600
|
+
if (!Number.isInteger(maxAttempts) && maxAttempts !== Number.POSITIVE_INFINITY) {
|
|
601
|
+
throw new Error("maxAttempts must be a positive integer");
|
|
602
|
+
}
|
|
603
|
+
if (maxAttempts < 1)
|
|
604
|
+
throw new Error("maxAttempts must be at least 1");
|
|
605
|
+
let lastError;
|
|
606
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
607
|
+
if (options.signal?.aborted)
|
|
608
|
+
throw options.signal.reason;
|
|
609
|
+
if (attempt > 1 && this.isStopping())
|
|
610
|
+
throw lastError;
|
|
611
|
+
const requestController = new AbortController();
|
|
612
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
613
|
+
const timeout = setTimeout(() => requestController.abort(new Error("Board request timed out")), timeoutMs);
|
|
614
|
+
const abortRequest = () => requestController.abort(options.signal?.reason);
|
|
615
|
+
options.signal?.addEventListener("abort", abortRequest, { once: true });
|
|
616
|
+
try {
|
|
617
|
+
const response = await fetch(`${this.configuration.boardUrl}${pathname}`, {
|
|
618
|
+
method,
|
|
619
|
+
signal: requestController.signal,
|
|
620
|
+
headers: {
|
|
621
|
+
Authorization: `Bearer ${this.configuration.connectionToken}`,
|
|
622
|
+
...(options.sessionId
|
|
623
|
+
? { "X-AI-Session-ID": options.sessionId }
|
|
624
|
+
: {}),
|
|
625
|
+
...(options.body === undefined
|
|
626
|
+
? {}
|
|
627
|
+
: { "Content-Type": "application/json" }),
|
|
628
|
+
...(options.idempotencyKey
|
|
629
|
+
? { "Idempotency-Key": options.idempotencyKey }
|
|
630
|
+
: {}),
|
|
631
|
+
},
|
|
632
|
+
body: options.body === undefined
|
|
633
|
+
? undefined
|
|
634
|
+
: JSON.stringify(options.body),
|
|
635
|
+
});
|
|
636
|
+
const payload = (await response.json().catch(() => null));
|
|
637
|
+
if (!response.ok) {
|
|
638
|
+
const message = payload?.error?.message || `HTTP ${response.status}`;
|
|
639
|
+
const error = new Error(message);
|
|
640
|
+
error.status = response.status;
|
|
641
|
+
error.code = payload?.error?.code;
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
return (payload?.data ?? payload);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
lastError = error;
|
|
648
|
+
if (options.signal?.aborted)
|
|
649
|
+
throw options.signal.reason;
|
|
650
|
+
const status = error.status;
|
|
651
|
+
const retryable = status === undefined || status === 408 || status === 429 || status >= 500;
|
|
652
|
+
if (!retryable || this.isStopping() || attempt >= maxAttempts)
|
|
653
|
+
throw error;
|
|
654
|
+
if (attempt === 4 || (attempt > 4 && attempt % 10 === 1)) {
|
|
655
|
+
process.stderr.write(`看板请求暂时失败,继续重试 ${pathname}\n`);
|
|
656
|
+
}
|
|
657
|
+
await delay(Math.min(250 * 2 ** Math.min(attempt - 1, 6), 10_000), options.signal);
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
clearTimeout(timeout);
|
|
661
|
+
options.signal?.removeEventListener("abort", abortRequest);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
throw lastError;
|
|
665
|
+
}
|
|
666
|
+
async downloadTaskImage(artifact, sessionId, signal) {
|
|
667
|
+
const location = await this.request(`/api/ai/artifacts/${artifact.id}/download`, { sessionId, signal, maxAttempts: 3 });
|
|
668
|
+
const response = await fetch(location.url, { signal });
|
|
669
|
+
if (!response.ok)
|
|
670
|
+
throw new Error(`图片下载失败:HTTP ${response.status}`);
|
|
671
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
672
|
+
if (bytes.byteLength !== artifact.size || bytes.byteLength > 10 * 1024 * 1024) {
|
|
673
|
+
throw new Error(`图片大小校验失败:${artifact.name}`);
|
|
674
|
+
}
|
|
675
|
+
return `data:${artifact.mime_type};base64,${bytes.toString("base64")}`;
|
|
676
|
+
}
|
|
677
|
+
async syncSessions(threads, modelCatalog, signal) {
|
|
678
|
+
const body = {
|
|
679
|
+
bridge_version: BRIDGE_VERSION,
|
|
680
|
+
...(modelCatalog === undefined
|
|
681
|
+
? {}
|
|
682
|
+
: { model_catalog: modelCatalog }),
|
|
683
|
+
directories: this.configuration.workingDirectories.map((directory) => ({
|
|
684
|
+
directory_key: directory.key,
|
|
685
|
+
name: directory.name,
|
|
686
|
+
working_directory: directory.workingDirectory,
|
|
687
|
+
})),
|
|
688
|
+
threads: threads.map((thread) => inventoryThread(thread, this.configuration)),
|
|
689
|
+
};
|
|
690
|
+
const result = await this.request("/api/ai/sessions/sync", {
|
|
691
|
+
method: "POST",
|
|
692
|
+
idempotencyKey: idempotencyKey("sync-sessions"),
|
|
693
|
+
maxAttempts: 1,
|
|
694
|
+
signal,
|
|
695
|
+
body,
|
|
696
|
+
});
|
|
697
|
+
return new Map(result.sessions.flatMap((session) => session.external_conversation_ref
|
|
698
|
+
? [[session.external_conversation_ref, session]]
|
|
699
|
+
: []));
|
|
700
|
+
}
|
|
701
|
+
async exchangeConfiguration(status, signal, timeoutMs = 5_000) {
|
|
702
|
+
const result = await this.request("/api/ai/config", {
|
|
703
|
+
method: "POST",
|
|
704
|
+
maxAttempts: 1,
|
|
705
|
+
timeoutMs,
|
|
706
|
+
signal,
|
|
707
|
+
body: status,
|
|
708
|
+
});
|
|
709
|
+
return parseRemoteConfigurationResponse(result);
|
|
710
|
+
}
|
|
711
|
+
async claimThreadCommand(runtimeInstanceId, signal) {
|
|
712
|
+
const result = await this.request("/api/ai/thread-commands/claim", {
|
|
713
|
+
method: "POST",
|
|
714
|
+
maxAttempts: 1,
|
|
715
|
+
timeoutMs: 5_000,
|
|
716
|
+
signal,
|
|
717
|
+
body: {
|
|
718
|
+
runtime_instance_id: runtimeInstanceId,
|
|
719
|
+
lease_seconds: 60,
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
return result.command;
|
|
723
|
+
}
|
|
724
|
+
async listCreatedThreadIds(signal) {
|
|
725
|
+
const result = await this.request("/api/ai/thread-commands/created", {
|
|
726
|
+
method: "GET",
|
|
727
|
+
maxAttempts: 1,
|
|
728
|
+
timeoutMs: 5_000,
|
|
729
|
+
signal,
|
|
730
|
+
});
|
|
731
|
+
return Array.isArray(result.thread_ids)
|
|
732
|
+
? result.thread_ids.filter((threadId) => typeof threadId === "string" && threadId.length > 0)
|
|
733
|
+
: [];
|
|
734
|
+
}
|
|
735
|
+
async completeThreadCommand(runtimeInstanceId, commandId, result, signal) {
|
|
736
|
+
await this.request(`/api/ai/thread-commands/${commandId}/complete`, {
|
|
737
|
+
method: "POST",
|
|
738
|
+
signal,
|
|
739
|
+
body: {
|
|
740
|
+
runtime_instance_id: runtimeInstanceId,
|
|
741
|
+
succeeded: result.succeeded,
|
|
742
|
+
external_thread_id: result.succeeded ? result.externalThreadId : null,
|
|
743
|
+
error: result.succeeded ? null : result.error,
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
async importHistory(sessionId, body, signal) {
|
|
748
|
+
return this.request("/api/ai/sessions/history", {
|
|
749
|
+
method: "POST",
|
|
750
|
+
sessionId,
|
|
751
|
+
idempotencyKey: idempotencyKey("sync-history"),
|
|
752
|
+
maxAttempts: 1,
|
|
753
|
+
timeoutMs: 15_000,
|
|
754
|
+
signal,
|
|
755
|
+
body,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
export class TurnLimiter {
|
|
760
|
+
limit;
|
|
761
|
+
active = 0;
|
|
762
|
+
waiters = [];
|
|
763
|
+
constructor(limit) {
|
|
764
|
+
this.limit = limit;
|
|
765
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
766
|
+
throw new Error("TurnLimiter limit must be a positive integer");
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
get capacity() {
|
|
770
|
+
return this.limit;
|
|
771
|
+
}
|
|
772
|
+
resize(limit) {
|
|
773
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
774
|
+
throw new Error("TurnLimiter limit must be a positive integer");
|
|
775
|
+
}
|
|
776
|
+
this.limit = limit;
|
|
777
|
+
this.drain();
|
|
778
|
+
}
|
|
779
|
+
acquire(signal) {
|
|
780
|
+
if (signal.aborted)
|
|
781
|
+
return Promise.reject(signal.reason);
|
|
782
|
+
if (this.active < this.limit) {
|
|
783
|
+
this.active += 1;
|
|
784
|
+
return Promise.resolve(this.releaseFunction());
|
|
785
|
+
}
|
|
786
|
+
return new Promise((resolve, reject) => {
|
|
787
|
+
const onAbort = () => {
|
|
788
|
+
const index = this.waiters.indexOf(waiter);
|
|
789
|
+
if (index >= 0)
|
|
790
|
+
this.waiters.splice(index, 1);
|
|
791
|
+
signal.removeEventListener("abort", onAbort);
|
|
792
|
+
reject(signal.reason);
|
|
793
|
+
};
|
|
794
|
+
const waiter = {
|
|
795
|
+
resolve: (release) => {
|
|
796
|
+
signal.removeEventListener("abort", onAbort);
|
|
797
|
+
resolve(release);
|
|
798
|
+
},
|
|
799
|
+
reject,
|
|
800
|
+
signal,
|
|
801
|
+
};
|
|
802
|
+
this.waiters.push(waiter);
|
|
803
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
releaseFunction() {
|
|
807
|
+
let released = false;
|
|
808
|
+
return () => {
|
|
809
|
+
if (released)
|
|
810
|
+
return;
|
|
811
|
+
released = true;
|
|
812
|
+
this.active -= 1;
|
|
813
|
+
this.drain();
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
drain() {
|
|
817
|
+
while (this.active < this.limit && this.waiters.length > 0) {
|
|
818
|
+
const waiter = this.waiters.shift();
|
|
819
|
+
if (!waiter || waiter.signal.aborted)
|
|
820
|
+
continue;
|
|
821
|
+
this.active += 1;
|
|
822
|
+
waiter.resolve(this.releaseFunction());
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function protocolData(phase, turnId, itemId, extra = {}) {
|
|
827
|
+
return {
|
|
828
|
+
protocol: APP_SERVER_PROTOCOL,
|
|
829
|
+
phase,
|
|
830
|
+
turn_ref: turnId,
|
|
831
|
+
item_ref: itemId,
|
|
832
|
+
...extra,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function itemId(item) {
|
|
836
|
+
return stringValue(item.id);
|
|
837
|
+
}
|
|
838
|
+
export function completedItemActivity(item, turnId, bufferedText) {
|
|
839
|
+
const id = itemId(item);
|
|
840
|
+
if (!id)
|
|
841
|
+
return null;
|
|
842
|
+
switch (item.type) {
|
|
843
|
+
case "userMessage":
|
|
844
|
+
case "hookPrompt":
|
|
845
|
+
return null;
|
|
846
|
+
case "agentMessage": {
|
|
847
|
+
const text = stringValue(item.text) ??
|
|
848
|
+
(bufferedText && bufferedText.length > 0 ? bufferedText : null) ??
|
|
849
|
+
"(AI 未返回文本)";
|
|
850
|
+
return {
|
|
851
|
+
kind: "assistant_message",
|
|
852
|
+
content: redactHarnessText(text, 100_000),
|
|
853
|
+
data: protocolData("completed", turnId, id, {
|
|
854
|
+
phase_name: item.phase ?? null,
|
|
855
|
+
}),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
case "reasoning": {
|
|
859
|
+
const summary = Array.isArray(item.summary)
|
|
860
|
+
? item.summary.filter((part) => typeof part === "string").join("\n\n")
|
|
861
|
+
: "";
|
|
862
|
+
const visibleSummary = summary.trim() ||
|
|
863
|
+
(bufferedText && bufferedText.trim().length > 0 ? bufferedText : null);
|
|
864
|
+
// A reasoning item is only useful to the Board when Codex exposed a
|
|
865
|
+
// readable summary. Never manufacture a placeholder (or fall back to
|
|
866
|
+
// raw `item.content`) because that both clutters history and could blur
|
|
867
|
+
// the disclosure boundary.
|
|
868
|
+
if (!visibleSummary)
|
|
869
|
+
return null;
|
|
870
|
+
return {
|
|
871
|
+
kind: "reasoning",
|
|
872
|
+
content: redactHarnessText(visibleSummary, 100_000),
|
|
873
|
+
data: protocolData("completed", turnId, id, {
|
|
874
|
+
disclosure: "provider_summary",
|
|
875
|
+
}),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
case "plan":
|
|
879
|
+
return {
|
|
880
|
+
kind: "plan",
|
|
881
|
+
content: redactHarnessText(stringValue(item.text) || "计划已更新", 100_000),
|
|
882
|
+
data: protocolData("completed", turnId, id),
|
|
883
|
+
};
|
|
884
|
+
case "commandExecution":
|
|
885
|
+
{
|
|
886
|
+
const completedOutput = typeof item.aggregatedOutput === "string" && item.aggregatedOutput.length > 0
|
|
887
|
+
? item.aggregatedOutput
|
|
888
|
+
: bufferedText;
|
|
889
|
+
return {
|
|
890
|
+
kind: "command",
|
|
891
|
+
content: redactHarnessText(stringValue(item.command) || "命令执行", 100_000),
|
|
892
|
+
data: protocolData("completed", turnId, id, sanitizeHarnessValue({
|
|
893
|
+
cwd: item.cwd,
|
|
894
|
+
source: item.source,
|
|
895
|
+
status: item.status,
|
|
896
|
+
exit_code: item.exitCode,
|
|
897
|
+
duration_ms: item.durationMs,
|
|
898
|
+
output: completedOutput,
|
|
899
|
+
})),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
case "fileChange":
|
|
903
|
+
return {
|
|
904
|
+
kind: "file_change",
|
|
905
|
+
content: `文件变更 ${Array.isArray(item.changes) ? item.changes.length : 0} 项`,
|
|
906
|
+
data: protocolData("completed", turnId, id, sanitizeHarnessValue({ changes: item.changes, status: item.status })),
|
|
907
|
+
};
|
|
908
|
+
case "mcpToolCall":
|
|
909
|
+
return {
|
|
910
|
+
kind: "mcp_tool",
|
|
911
|
+
content: `${stringValue(item.server) || "MCP"} · ${stringValue(item.tool) || "tool"}`,
|
|
912
|
+
data: protocolData("completed", turnId, id, sanitizeHarnessValue({
|
|
913
|
+
status: item.status,
|
|
914
|
+
arguments: item.arguments,
|
|
915
|
+
result: item.result,
|
|
916
|
+
error: item.error,
|
|
917
|
+
duration_ms: item.durationMs,
|
|
918
|
+
})),
|
|
919
|
+
};
|
|
920
|
+
case "dynamicToolCall":
|
|
921
|
+
case "collabAgentToolCall":
|
|
922
|
+
case "subAgentActivity":
|
|
923
|
+
return {
|
|
924
|
+
kind: "mcp_tool",
|
|
925
|
+
content: redactHarnessText(stringValue(item.tool) || stringValue(item.kind) || String(item.type), 100_000),
|
|
926
|
+
data: protocolData("completed", turnId, id, sanitizeHarnessValue(item)),
|
|
927
|
+
};
|
|
928
|
+
case "webSearch":
|
|
929
|
+
return {
|
|
930
|
+
kind: "web_search",
|
|
931
|
+
content: redactHarnessText(stringValue(item.query) || stringValue(item.action) || "网页搜索", 100_000),
|
|
932
|
+
data: protocolData("completed", turnId, id),
|
|
933
|
+
};
|
|
934
|
+
case "imageView":
|
|
935
|
+
case "imageGeneration":
|
|
936
|
+
return {
|
|
937
|
+
kind: "status",
|
|
938
|
+
content: item.type === "imageView" ? "已查看图片" : "已生成图片",
|
|
939
|
+
data: protocolData("completed", turnId, id, sanitizeHarnessValue(item)),
|
|
940
|
+
};
|
|
941
|
+
case "enteredReviewMode":
|
|
942
|
+
case "exitedReviewMode":
|
|
943
|
+
case "contextCompaction":
|
|
944
|
+
case "sleep":
|
|
945
|
+
return {
|
|
946
|
+
kind: "status",
|
|
947
|
+
content: redactHarnessText(stringValue(item.review) || String(item.type), 100_000),
|
|
948
|
+
data: protocolData("completed", turnId, id),
|
|
949
|
+
};
|
|
950
|
+
default:
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
export function startedItemActivity(item, turnId) {
|
|
955
|
+
const id = itemId(item);
|
|
956
|
+
if (!id)
|
|
957
|
+
return null;
|
|
958
|
+
switch (item.type) {
|
|
959
|
+
case "commandExecution":
|
|
960
|
+
return {
|
|
961
|
+
kind: "command",
|
|
962
|
+
content: redactHarnessText(stringValue(item.command) || "正在执行命令"),
|
|
963
|
+
data: protocolData("started", turnId, id, {
|
|
964
|
+
cwd: sanitizeHarnessValue(item.cwd),
|
|
965
|
+
status: item.status,
|
|
966
|
+
}),
|
|
967
|
+
};
|
|
968
|
+
case "fileChange":
|
|
969
|
+
return {
|
|
970
|
+
kind: "file_change",
|
|
971
|
+
content: "正在应用文件变更",
|
|
972
|
+
data: protocolData("started", turnId, id),
|
|
973
|
+
};
|
|
974
|
+
case "mcpToolCall":
|
|
975
|
+
case "dynamicToolCall":
|
|
976
|
+
case "collabAgentToolCall":
|
|
977
|
+
return {
|
|
978
|
+
kind: "mcp_tool",
|
|
979
|
+
content: redactHarnessText([stringValue(item.server), stringValue(item.tool)].filter(Boolean).join(" · ") ||
|
|
980
|
+
"正在调用工具"),
|
|
981
|
+
data: protocolData("started", turnId, id),
|
|
982
|
+
};
|
|
983
|
+
case "webSearch":
|
|
984
|
+
return {
|
|
985
|
+
kind: "web_search",
|
|
986
|
+
content: redactHarnessText(stringValue(item.query) || "正在搜索网页"),
|
|
987
|
+
data: protocolData("started", turnId, id),
|
|
988
|
+
};
|
|
989
|
+
default:
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
class SessionWorker {
|
|
994
|
+
thread;
|
|
995
|
+
session;
|
|
996
|
+
configuration;
|
|
997
|
+
board;
|
|
998
|
+
appServer;
|
|
999
|
+
limiter;
|
|
1000
|
+
onFatal;
|
|
1001
|
+
stopController = new AbortController();
|
|
1002
|
+
wakeLatch = new WakeLatch();
|
|
1003
|
+
stopping = false;
|
|
1004
|
+
activeClaim = null;
|
|
1005
|
+
activeTurnId = null;
|
|
1006
|
+
lastAssistantMessage = "";
|
|
1007
|
+
realtimeAvailable = false;
|
|
1008
|
+
consecutiveEmptyClaims = 0;
|
|
1009
|
+
eventChain = Promise.resolve();
|
|
1010
|
+
activityChain = Promise.resolve();
|
|
1011
|
+
eventError = null;
|
|
1012
|
+
queueOverflowError = null;
|
|
1013
|
+
notificationBacklog = 0;
|
|
1014
|
+
activityBacklog = 0;
|
|
1015
|
+
turnResults = new Map();
|
|
1016
|
+
turnWaiters = new Map();
|
|
1017
|
+
buffers = new Map();
|
|
1018
|
+
preStartNotifications = [];
|
|
1019
|
+
backgroundBoardOperations = new Set();
|
|
1020
|
+
retirementWaiters = new Set();
|
|
1021
|
+
awaitingTurnStart = false;
|
|
1022
|
+
mutatingRequestCount = 0;
|
|
1023
|
+
heartbeatInFlightPromise = null;
|
|
1024
|
+
usageSequence = 0;
|
|
1025
|
+
runPromise = null;
|
|
1026
|
+
stopPromise = null;
|
|
1027
|
+
constructor(thread, session, configuration, board, appServer, limiter, onFatal) {
|
|
1028
|
+
this.thread = thread;
|
|
1029
|
+
this.session = session;
|
|
1030
|
+
this.configuration = configuration;
|
|
1031
|
+
this.board = board;
|
|
1032
|
+
this.appServer = appServer;
|
|
1033
|
+
this.limiter = limiter;
|
|
1034
|
+
this.onFatal = onFatal;
|
|
1035
|
+
}
|
|
1036
|
+
updateSession(session) {
|
|
1037
|
+
if (session.id !== this.session.id) {
|
|
1038
|
+
throw new Error(`Thread ${this.thread.id} received a different Session id`);
|
|
1039
|
+
}
|
|
1040
|
+
this.session = session;
|
|
1041
|
+
}
|
|
1042
|
+
start() {
|
|
1043
|
+
if (!this.runPromise)
|
|
1044
|
+
this.runPromise = this.run();
|
|
1045
|
+
return this.runPromise;
|
|
1046
|
+
}
|
|
1047
|
+
get retirementBlocked() {
|
|
1048
|
+
return this.mutatingRequestCount > 0;
|
|
1049
|
+
}
|
|
1050
|
+
async waitForRetirementReady(milliseconds) {
|
|
1051
|
+
if (!this.retirementBlocked)
|
|
1052
|
+
return true;
|
|
1053
|
+
let resolveReady;
|
|
1054
|
+
const ready = new Promise((resolve) => {
|
|
1055
|
+
resolveReady = resolve;
|
|
1056
|
+
this.retirementWaiters.add(resolve);
|
|
1057
|
+
});
|
|
1058
|
+
try {
|
|
1059
|
+
return await Promise.race([
|
|
1060
|
+
ready.then(() => true),
|
|
1061
|
+
delay(milliseconds).then(() => false),
|
|
1062
|
+
]);
|
|
1063
|
+
}
|
|
1064
|
+
finally {
|
|
1065
|
+
this.retirementWaiters.delete(resolveReady);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
enqueueNotification(notification) {
|
|
1069
|
+
if (this.stopping)
|
|
1070
|
+
return;
|
|
1071
|
+
const params = isRecord(notification.params) ? notification.params : null;
|
|
1072
|
+
if (!params || threadIdFromMessage(params) !== this.thread.id)
|
|
1073
|
+
return;
|
|
1074
|
+
const turnId = notificationTurnId(params);
|
|
1075
|
+
if (!turnId)
|
|
1076
|
+
return;
|
|
1077
|
+
if (this.awaitingTurnStart && !this.activeTurnId) {
|
|
1078
|
+
if (this.preStartNotifications.length >= 500) {
|
|
1079
|
+
this.preStartNotifications.shift();
|
|
1080
|
+
}
|
|
1081
|
+
this.preStartNotifications.push(notification);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (!this.activeTurnId || turnId !== this.activeTurnId)
|
|
1085
|
+
return;
|
|
1086
|
+
if (this.consumeStreamDelta(notification, params, turnId))
|
|
1087
|
+
return;
|
|
1088
|
+
this.queueNotification(notification);
|
|
1089
|
+
}
|
|
1090
|
+
queueNotification(notification) {
|
|
1091
|
+
if (this.queueOverflowError)
|
|
1092
|
+
return;
|
|
1093
|
+
if (this.notificationBacklog >= MAX_NOTIFICATION_BACKLOG) {
|
|
1094
|
+
this.failForQueueOverflow("App Server notification");
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
this.notificationBacklog += 1;
|
|
1098
|
+
const queued = this.eventChain.then(() => this.handleNotification(notification));
|
|
1099
|
+
this.eventChain = queued
|
|
1100
|
+
.catch((error) => {
|
|
1101
|
+
this.eventError ??= error instanceof Error ? error : new Error(String(error));
|
|
1102
|
+
process.stderr.write(`Thread ${this.thread.id} 事件处理失败:${errorMessage(error)}\n`);
|
|
1103
|
+
})
|
|
1104
|
+
.finally(() => {
|
|
1105
|
+
this.notificationBacklog = Math.max(0, this.notificationBacklog - 1);
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
consumeStreamDelta(notification, params, turnId) {
|
|
1109
|
+
let kind = null;
|
|
1110
|
+
switch (notification.method) {
|
|
1111
|
+
case "item/agentMessage/delta":
|
|
1112
|
+
kind = "assistant_message";
|
|
1113
|
+
break;
|
|
1114
|
+
case "item/reasoning/summaryTextDelta":
|
|
1115
|
+
kind = "reasoning";
|
|
1116
|
+
break;
|
|
1117
|
+
case "item/commandExecution/outputDelta":
|
|
1118
|
+
kind = "command";
|
|
1119
|
+
break;
|
|
1120
|
+
default:
|
|
1121
|
+
return false;
|
|
1122
|
+
}
|
|
1123
|
+
this.appendDelta(turnId, stringValue(params.itemId), kind, typeof params.delta === "string" ? params.delta : "");
|
|
1124
|
+
return true;
|
|
1125
|
+
}
|
|
1126
|
+
failForQueueOverflow(queueName) {
|
|
1127
|
+
if (this.queueOverflowError)
|
|
1128
|
+
return this.queueOverflowError;
|
|
1129
|
+
const error = new Error(`${queueName} backlog exceeded its safety limit for thread ${this.thread.id}`);
|
|
1130
|
+
this.queueOverflowError = error;
|
|
1131
|
+
this.eventError ??= error;
|
|
1132
|
+
process.stderr.write(`${error.message};正在中断本轮并重启 Bridge\n`);
|
|
1133
|
+
this.onFatal(error);
|
|
1134
|
+
return error;
|
|
1135
|
+
}
|
|
1136
|
+
async handleServerRequest(request) {
|
|
1137
|
+
const params = isRecord(request.params) ? request.params : {};
|
|
1138
|
+
const requestTurnId = notificationTurnId(params);
|
|
1139
|
+
const correlatedActiveTurn = Boolean(this.activeClaim &&
|
|
1140
|
+
requestTurnId &&
|
|
1141
|
+
(requestTurnId === this.activeTurnId ||
|
|
1142
|
+
(this.awaitingTurnStart && this.activeTurnId === null)));
|
|
1143
|
+
if (this.activeClaim) {
|
|
1144
|
+
this.trackBackgroundBoardOperation(this.reportActivity(`request:${String(request.id)}`, {
|
|
1145
|
+
kind: "status",
|
|
1146
|
+
content: request.method === "item/tool/requestUserInput"
|
|
1147
|
+
? "Codex 正在等待 Web Console 的结构化回答"
|
|
1148
|
+
: this.configuration.approvalMode === "decline"
|
|
1149
|
+
? "Codex 请求本地审批;Bridge 已按设备策略拒绝"
|
|
1150
|
+
: "Codex 请求本地审批;Bridge 已按设备策略处理",
|
|
1151
|
+
data: {
|
|
1152
|
+
protocol: APP_SERVER_PROTOCOL,
|
|
1153
|
+
phase: request.method === "item/tool/requestUserInput"
|
|
1154
|
+
? "waiting_user_input"
|
|
1155
|
+
: "completed",
|
|
1156
|
+
request_method: request.method,
|
|
1157
|
+
request_id: String(request.id),
|
|
1158
|
+
approval_mode: this.configuration.approvalMode,
|
|
1159
|
+
correlated_active_turn: correlatedActiveTurn,
|
|
1160
|
+
request: sanitizeHarnessValue(params),
|
|
1161
|
+
},
|
|
1162
|
+
}, { maxAttempts: 1 }), `审批审计 ${request.method}`);
|
|
1163
|
+
}
|
|
1164
|
+
const sessionDecision = this.configuration.approvalMode === "accept-session";
|
|
1165
|
+
const accepts = this.configuration.approvalMode !== "decline" && correlatedActiveTurn;
|
|
1166
|
+
switch (request.method) {
|
|
1167
|
+
case "item/commandExecution/requestApproval":
|
|
1168
|
+
case "item/fileChange/requestApproval":
|
|
1169
|
+
return {
|
|
1170
|
+
decision: accepts
|
|
1171
|
+
? sessionDecision
|
|
1172
|
+
? "acceptForSession"
|
|
1173
|
+
: "accept"
|
|
1174
|
+
: "decline",
|
|
1175
|
+
};
|
|
1176
|
+
case "execCommandApproval":
|
|
1177
|
+
case "applyPatchApproval":
|
|
1178
|
+
return {
|
|
1179
|
+
decision: accepts
|
|
1180
|
+
? sessionDecision
|
|
1181
|
+
? "approved_for_session"
|
|
1182
|
+
: "approved"
|
|
1183
|
+
: { denied: { rejection: "Web Bridge approval is not enabled" } },
|
|
1184
|
+
};
|
|
1185
|
+
case "item/tool/requestUserInput":
|
|
1186
|
+
return this.handleStructuredUserInput(request, params, correlatedActiveTurn);
|
|
1187
|
+
case "mcpServer/elicitation/request":
|
|
1188
|
+
return { action: "decline", content: null, _meta: null };
|
|
1189
|
+
case "item/permissions/requestApproval": {
|
|
1190
|
+
if (!accepts)
|
|
1191
|
+
throw new Error("Permission request declined by Bridge policy");
|
|
1192
|
+
const requested = isRecord(params.permissions) ? params.permissions : {};
|
|
1193
|
+
return {
|
|
1194
|
+
permissions: Object.fromEntries(Object.entries(requested).filter(([, value]) => value !== null)),
|
|
1195
|
+
scope: sessionDecision ? "session" : "turn",
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
case "currentTime/read":
|
|
1199
|
+
return { currentTimeAt: Math.floor(Date.now() / 1_000) };
|
|
1200
|
+
default:
|
|
1201
|
+
throw new Error(`Unsupported App Server request: ${request.method}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
async handleStructuredUserInput(request, params, correlatedActiveTurn) {
|
|
1205
|
+
if (!correlatedActiveTurn || !this.activeClaim) {
|
|
1206
|
+
throw new Error("Codex 结构化问题未关联到当前活动 turn,Bridge 无法安全转交");
|
|
1207
|
+
}
|
|
1208
|
+
const prompt = parseStructuredUserInputRequest(params);
|
|
1209
|
+
const task = this.activeClaim;
|
|
1210
|
+
const requestId = randomUUID();
|
|
1211
|
+
const externalRequestId = String(request.id);
|
|
1212
|
+
await this.board.request("/api/ai/tasks/user-input-requests", {
|
|
1213
|
+
method: "POST",
|
|
1214
|
+
sessionId: this.session.id,
|
|
1215
|
+
idempotencyKey: idempotencyKey(`user-input-register/${externalRequestId}`),
|
|
1216
|
+
signal: this.stopController.signal,
|
|
1217
|
+
body: {
|
|
1218
|
+
task_id: task.id,
|
|
1219
|
+
claim_token: task.claim_token,
|
|
1220
|
+
request_id: requestId,
|
|
1221
|
+
external_request_id: externalRequestId,
|
|
1222
|
+
turn_id: prompt.turnId,
|
|
1223
|
+
item_id: prompt.itemId,
|
|
1224
|
+
is_blocking: true,
|
|
1225
|
+
questions: prompt.questions,
|
|
1226
|
+
},
|
|
1227
|
+
});
|
|
1228
|
+
process.stdout.write(`等待 Web 回答 [${shortThreadTitle(this.thread)}]:${prompt.questions
|
|
1229
|
+
.map((question) => question.header)
|
|
1230
|
+
.join(" / ")}\n`);
|
|
1231
|
+
while (!this.stopController.signal.aborted) {
|
|
1232
|
+
const response = await this.board.request(`/api/ai/tasks/user-input-requests/${requestId}/poll`, {
|
|
1233
|
+
method: "POST",
|
|
1234
|
+
sessionId: this.session.id,
|
|
1235
|
+
signal: this.stopController.signal,
|
|
1236
|
+
body: {
|
|
1237
|
+
task_id: task.id,
|
|
1238
|
+
claim_token: task.claim_token,
|
|
1239
|
+
request_id: requestId,
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
if (response.request.status === "answered") {
|
|
1243
|
+
const answers = parseStructuredUserInputAnswers(response.request.answers, prompt.questions);
|
|
1244
|
+
this.trackBackgroundBoardOperation(this.reportActivity(`request:${externalRequestId}:answered`, {
|
|
1245
|
+
kind: "status",
|
|
1246
|
+
content: "Web Console 已提交结构化回答;原 turn 继续执行",
|
|
1247
|
+
data: {
|
|
1248
|
+
protocol: APP_SERVER_PROTOCOL,
|
|
1249
|
+
phase: "answered",
|
|
1250
|
+
request_method: request.method,
|
|
1251
|
+
request_id: externalRequestId,
|
|
1252
|
+
question_count: prompt.questions.length,
|
|
1253
|
+
turn_continues: true,
|
|
1254
|
+
},
|
|
1255
|
+
}, { maxAttempts: 1 }), `结构化回答审计 ${externalRequestId}`);
|
|
1256
|
+
return { answers };
|
|
1257
|
+
}
|
|
1258
|
+
if (response.request.status !== "pending") {
|
|
1259
|
+
throw new Error(`Web Console 结构化回答已${response.request.status === "cancelled" ? "取消" : "失效"}`);
|
|
1260
|
+
}
|
|
1261
|
+
await delay(USER_INPUT_POLL_INTERVAL_MS, this.stopController.signal);
|
|
1262
|
+
}
|
|
1263
|
+
throw this.stopController.signal.reason ?? new Error("Codex Bridge 已停止");
|
|
1264
|
+
}
|
|
1265
|
+
async stop(reason = "Codex Bridge 已停止") {
|
|
1266
|
+
if (this.stopPromise)
|
|
1267
|
+
return this.stopPromise;
|
|
1268
|
+
const attempt = this.stopWorker(reason);
|
|
1269
|
+
this.stopPromise = attempt;
|
|
1270
|
+
try {
|
|
1271
|
+
await attempt;
|
|
1272
|
+
}
|
|
1273
|
+
catch (error) {
|
|
1274
|
+
if (this.stopPromise === attempt)
|
|
1275
|
+
this.stopPromise = null;
|
|
1276
|
+
throw error;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
async stopWorker(reason) {
|
|
1280
|
+
this.stopping = true;
|
|
1281
|
+
this.stopController.abort(new Error(reason));
|
|
1282
|
+
this.wakeLatch.wake();
|
|
1283
|
+
for (const waiter of this.turnWaiters.values()) {
|
|
1284
|
+
waiter.cleanup();
|
|
1285
|
+
waiter.reject(new Error(reason));
|
|
1286
|
+
}
|
|
1287
|
+
this.turnWaiters.clear();
|
|
1288
|
+
for (const buffer of this.buffers.values()) {
|
|
1289
|
+
if (buffer.timer)
|
|
1290
|
+
clearTimeout(buffer.timer);
|
|
1291
|
+
}
|
|
1292
|
+
if (this.activeTurnId) {
|
|
1293
|
+
await this.appServer
|
|
1294
|
+
.turnInterrupt({ threadId: this.thread.id, turnId: this.activeTurnId }, { timeoutMs: 5_000 })
|
|
1295
|
+
.catch(() => undefined);
|
|
1296
|
+
}
|
|
1297
|
+
await (this.runPromise ?? Promise.resolve());
|
|
1298
|
+
}
|
|
1299
|
+
async run() {
|
|
1300
|
+
let wakeListener = Promise.resolve();
|
|
1301
|
+
let heartbeatTimer = null;
|
|
1302
|
+
try {
|
|
1303
|
+
await this.heartbeatSession();
|
|
1304
|
+
if (this.stopping)
|
|
1305
|
+
return;
|
|
1306
|
+
wakeListener = runSessionWakeListener({
|
|
1307
|
+
endpoint: `${this.configuration.boardUrl}/api/ai/sessions/wake`,
|
|
1308
|
+
connectionToken: this.configuration.connectionToken,
|
|
1309
|
+
sessionId: this.session.id,
|
|
1310
|
+
signal: this.stopController.signal,
|
|
1311
|
+
onWake: () => {
|
|
1312
|
+
this.consecutiveEmptyClaims = 0;
|
|
1313
|
+
this.wakeLatch.wake();
|
|
1314
|
+
},
|
|
1315
|
+
onAvailabilityChange: (available) => {
|
|
1316
|
+
this.realtimeAvailable = available;
|
|
1317
|
+
this.consecutiveEmptyClaims = 0;
|
|
1318
|
+
this.wakeLatch.wake();
|
|
1319
|
+
},
|
|
1320
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
1321
|
+
}).catch((error) => {
|
|
1322
|
+
if (!this.stopping) {
|
|
1323
|
+
process.stderr.write(`Thread ${this.thread.id} 实时唤醒失败,继续轮询:${errorMessage(error)}\n`);
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
heartbeatTimer = setInterval(() => {
|
|
1327
|
+
if (this.heartbeatInFlightPromise || this.stopping)
|
|
1328
|
+
return;
|
|
1329
|
+
const heartbeat = Promise.resolve(this.activeClaim
|
|
1330
|
+
? this.heartbeatClaim(this.activeClaim)
|
|
1331
|
+
: this.heartbeatSession())
|
|
1332
|
+
.then(() => undefined)
|
|
1333
|
+
.catch((error) => {
|
|
1334
|
+
if (!this.stopping) {
|
|
1335
|
+
process.stderr.write(`Thread ${this.thread.id} 心跳失败:${errorMessage(error)}\n`);
|
|
1336
|
+
}
|
|
1337
|
+
})
|
|
1338
|
+
.finally(() => {
|
|
1339
|
+
if (this.heartbeatInFlightPromise === heartbeat) {
|
|
1340
|
+
this.heartbeatInFlightPromise = null;
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
this.heartbeatInFlightPromise = heartbeat;
|
|
1344
|
+
}, 45_000);
|
|
1345
|
+
while (!this.stopping)
|
|
1346
|
+
await this.runOneIteration();
|
|
1347
|
+
}
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
if (!this.stopping)
|
|
1350
|
+
throw error;
|
|
1351
|
+
}
|
|
1352
|
+
finally {
|
|
1353
|
+
this.stopController.abort();
|
|
1354
|
+
this.wakeLatch.wake();
|
|
1355
|
+
if (heartbeatTimer)
|
|
1356
|
+
clearInterval(heartbeatTimer);
|
|
1357
|
+
for (const buffer of this.buffers.values()) {
|
|
1358
|
+
if (buffer.timer)
|
|
1359
|
+
clearTimeout(buffer.timer);
|
|
1360
|
+
}
|
|
1361
|
+
await Promise.allSettled([
|
|
1362
|
+
wakeListener,
|
|
1363
|
+
this.heartbeatInFlightPromise ?? Promise.resolve(),
|
|
1364
|
+
]);
|
|
1365
|
+
await this.eventChain.catch(() => undefined);
|
|
1366
|
+
await this.activityChain.catch(() => undefined);
|
|
1367
|
+
await this.waitForBackgroundBoardOperations();
|
|
1368
|
+
await this.releaseActiveTask("Codex Bridge 已停止");
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
async runOneIteration() {
|
|
1372
|
+
let releasePermit = null;
|
|
1373
|
+
try {
|
|
1374
|
+
releasePermit = await this.limiter.acquire(this.stopController.signal);
|
|
1375
|
+
let response;
|
|
1376
|
+
try {
|
|
1377
|
+
response = await this.board.request("/api/ai/tasks/claim-next", {
|
|
1378
|
+
method: "POST",
|
|
1379
|
+
sessionId: this.session.id,
|
|
1380
|
+
idempotencyKey: idempotencyKey("claim-next"),
|
|
1381
|
+
signal: this.stopController.signal,
|
|
1382
|
+
body: { lease_seconds: this.configuration.leaseSeconds },
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
if (!isSessionActiveClaimConflict(error))
|
|
1387
|
+
throw error;
|
|
1388
|
+
releasePermit();
|
|
1389
|
+
releasePermit = null;
|
|
1390
|
+
await this.wakeLatch.wait(Math.max(10_000, this.configuration.pollIntervalMs), this.stopController.signal);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
const action = nextClaimAction({
|
|
1394
|
+
hasTask: response.task !== null,
|
|
1395
|
+
stopping: this.stopping,
|
|
1396
|
+
});
|
|
1397
|
+
if (action === "stop") {
|
|
1398
|
+
if (response.task) {
|
|
1399
|
+
this.activeClaim = response.task;
|
|
1400
|
+
await this.releaseActiveTask("Codex Bridge 正在停止");
|
|
1401
|
+
}
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (action === "idle" || !response.task) {
|
|
1405
|
+
releasePermit();
|
|
1406
|
+
releasePermit = null;
|
|
1407
|
+
const waitMs = adaptiveIdlePollDelay({
|
|
1408
|
+
baseIntervalMs: this.configuration.pollIntervalMs,
|
|
1409
|
+
emptyPolls: this.consecutiveEmptyClaims,
|
|
1410
|
+
realtimeAvailable: this.realtimeAvailable,
|
|
1411
|
+
});
|
|
1412
|
+
this.consecutiveEmptyClaims += 1;
|
|
1413
|
+
await this.wakeLatch.wait(waitMs, this.stopController.signal);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
this.consecutiveEmptyClaims = 0;
|
|
1417
|
+
this.activeClaim = response.task;
|
|
1418
|
+
process.stdout.write(`开始任务 [${shortThreadTitle(this.thread)}]:${response.task.title}\n`);
|
|
1419
|
+
try {
|
|
1420
|
+
await this.executeTask(response.task);
|
|
1421
|
+
process.stdout.write(`完成任务 [${shortThreadTitle(this.thread)}]:${response.task.title}\n`);
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
if (this.stopping) {
|
|
1425
|
+
await this.releaseActiveTask("Codex Bridge 正在停止");
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
const reason = redactHarnessText(errorMessage(error), 10_000);
|
|
1429
|
+
await this.board
|
|
1430
|
+
.request("/api/ai/tasks/fail", {
|
|
1431
|
+
method: "POST",
|
|
1432
|
+
sessionId: this.session.id,
|
|
1433
|
+
idempotencyKey: idempotencyKey("fail"),
|
|
1434
|
+
signal: this.stopController.signal,
|
|
1435
|
+
body: {
|
|
1436
|
+
task_id: response.task.id,
|
|
1437
|
+
claim_token: response.task.claim_token,
|
|
1438
|
+
reason,
|
|
1439
|
+
result_json: null,
|
|
1440
|
+
},
|
|
1441
|
+
})
|
|
1442
|
+
.catch(() => undefined);
|
|
1443
|
+
process.stderr.write(`任务失败 [${this.thread.id}]:${reason}\n`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
finally {
|
|
1447
|
+
this.activeClaim = null;
|
|
1448
|
+
this.activeTurnId = null;
|
|
1449
|
+
this.lastAssistantMessage = "";
|
|
1450
|
+
this.eventError = null;
|
|
1451
|
+
this.turnResults.clear();
|
|
1452
|
+
this.preStartNotifications.length = 0;
|
|
1453
|
+
this.awaitingTurnStart = false;
|
|
1454
|
+
this.buffers.clear();
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
finally {
|
|
1458
|
+
releasePermit?.();
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async executeTask(task) {
|
|
1462
|
+
if (this.stopping)
|
|
1463
|
+
throw new Error("Codex Bridge 正在停止");
|
|
1464
|
+
await this.board.request("/api/ai/tasks/report-progress", {
|
|
1465
|
+
method: "POST",
|
|
1466
|
+
sessionId: this.session.id,
|
|
1467
|
+
idempotencyKey: idempotencyKey("started"),
|
|
1468
|
+
signal: this.stopController.signal,
|
|
1469
|
+
body: {
|
|
1470
|
+
task_id: task.id,
|
|
1471
|
+
claim_token: task.claim_token,
|
|
1472
|
+
progress_note: "Codex App Server 已接收任务,正在执行",
|
|
1473
|
+
progress_percent_estimate: 5,
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
if (this.stopping)
|
|
1477
|
+
throw new Error("Codex Bridge 正在停止");
|
|
1478
|
+
const workspaceRoot = path.resolve(threadCwd(this.thread) ?? this.configuration.workingDirectory);
|
|
1479
|
+
const model = stringValue(task.model);
|
|
1480
|
+
const reasoningEffort = stringValue(task.reasoning_effort);
|
|
1481
|
+
await this.trackMutatingRequest(this.appServer.threadResume({
|
|
1482
|
+
threadId: this.thread.id,
|
|
1483
|
+
excludeTurns: true,
|
|
1484
|
+
...threadPermissionOverrides(this.configuration.permissionMode, workspaceRoot),
|
|
1485
|
+
}, { timeoutMs: 0 }));
|
|
1486
|
+
if (this.stopping)
|
|
1487
|
+
throw new Error("Codex Bridge 正在停止");
|
|
1488
|
+
const text = [
|
|
1489
|
+
task.description?.trim() || task.title,
|
|
1490
|
+
task.acceptance_criteria
|
|
1491
|
+
? `\n\n验收条件:\n${task.acceptance_criteria}`
|
|
1492
|
+
: "",
|
|
1493
|
+
].join("");
|
|
1494
|
+
const taskDetails = await this.board
|
|
1495
|
+
.request(`/api/ai/tasks/${task.id}`, {
|
|
1496
|
+
sessionId: this.session.id,
|
|
1497
|
+
signal: this.stopController.signal,
|
|
1498
|
+
maxAttempts: 3,
|
|
1499
|
+
})
|
|
1500
|
+
.catch((error) => {
|
|
1501
|
+
if (error.status === 404)
|
|
1502
|
+
return { artifacts: [] };
|
|
1503
|
+
throw error;
|
|
1504
|
+
});
|
|
1505
|
+
const imageArtifacts = (taskDetails.artifacts ?? []).filter((artifact) => ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(artifact.mime_type));
|
|
1506
|
+
const imageInputs = await Promise.all(imageArtifacts.map(async (artifact) => ({
|
|
1507
|
+
type: "image",
|
|
1508
|
+
url: await this.board.downloadTaskImage(artifact, this.session.id, this.stopController.signal),
|
|
1509
|
+
})));
|
|
1510
|
+
this.awaitingTurnStart = true;
|
|
1511
|
+
this.preStartNotifications.length = 0;
|
|
1512
|
+
let started;
|
|
1513
|
+
try {
|
|
1514
|
+
started = await this.trackMutatingRequest(this.appServer.turnStart({
|
|
1515
|
+
threadId: this.thread.id,
|
|
1516
|
+
clientUserMessageId: task.id,
|
|
1517
|
+
input: [{ type: "text", text, text_elements: [] }, ...imageInputs],
|
|
1518
|
+
...(model ? { model } : {}),
|
|
1519
|
+
...(reasoningEffort ? { effort: reasoningEffort } : {}),
|
|
1520
|
+
// The Board persists AI replies only, so do not ask Codex to produce
|
|
1521
|
+
// a reasoning summary that would be discarded.
|
|
1522
|
+
summary: "none",
|
|
1523
|
+
...turnPermissionOverrides(this.configuration.permissionMode, workspaceRoot),
|
|
1524
|
+
}, { timeoutMs: 0 }));
|
|
1525
|
+
}
|
|
1526
|
+
catch (error) {
|
|
1527
|
+
this.awaitingTurnStart = false;
|
|
1528
|
+
this.preStartNotifications.length = 0;
|
|
1529
|
+
throw error;
|
|
1530
|
+
}
|
|
1531
|
+
const turnId = stringValue(started.turn.id);
|
|
1532
|
+
if (!turnId)
|
|
1533
|
+
throw new Error("Codex App Server 未返回 turn id");
|
|
1534
|
+
this.activeTurnId = turnId;
|
|
1535
|
+
this.awaitingTurnStart = false;
|
|
1536
|
+
const pendingNotifications = this.preStartNotifications.splice(0);
|
|
1537
|
+
for (const notification of pendingNotifications) {
|
|
1538
|
+
const params = isRecord(notification.params) ? notification.params : null;
|
|
1539
|
+
if (params && notificationTurnId(params) === turnId) {
|
|
1540
|
+
this.enqueueNotification(notification);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
if (this.stopping || this.stopController.signal.aborted) {
|
|
1544
|
+
await this.appServer
|
|
1545
|
+
.turnInterrupt({ threadId: this.thread.id, turnId }, { timeoutMs: 5_000 })
|
|
1546
|
+
.catch(() => undefined);
|
|
1547
|
+
throw this.stopController.signal.reason ?? new Error("Codex Bridge 正在停止");
|
|
1548
|
+
}
|
|
1549
|
+
const turn = await this.waitForTurn(turnId, this.stopController.signal);
|
|
1550
|
+
await this.eventChain;
|
|
1551
|
+
await this.flushAllBuffers();
|
|
1552
|
+
await this.activityChain;
|
|
1553
|
+
if (this.eventError)
|
|
1554
|
+
throw this.eventError;
|
|
1555
|
+
const status = stringValue(turn.status);
|
|
1556
|
+
if (status !== "completed") {
|
|
1557
|
+
const error = isRecord(turn.error)
|
|
1558
|
+
? stringValue(turn.error.message)
|
|
1559
|
+
: null;
|
|
1560
|
+
throw new Error(error || `Codex turn ${status || "failed"}`);
|
|
1561
|
+
}
|
|
1562
|
+
await this.board.request("/api/ai/tasks/complete", {
|
|
1563
|
+
method: "POST",
|
|
1564
|
+
sessionId: this.session.id,
|
|
1565
|
+
idempotencyKey: idempotencyKey("complete"),
|
|
1566
|
+
signal: this.stopController.signal,
|
|
1567
|
+
body: {
|
|
1568
|
+
task_id: task.id,
|
|
1569
|
+
claim_token: task.claim_token,
|
|
1570
|
+
result_summary: redactHarnessText(this.lastAssistantMessage || "Codex 已完成本轮任务", 100_000),
|
|
1571
|
+
result_json: null,
|
|
1572
|
+
message: null,
|
|
1573
|
+
artifacts: [],
|
|
1574
|
+
},
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
waitForTurn(turnId, signal) {
|
|
1578
|
+
if (signal.aborted)
|
|
1579
|
+
return Promise.reject(signal.reason);
|
|
1580
|
+
const completed = this.turnResults.get(turnId);
|
|
1581
|
+
if (completed) {
|
|
1582
|
+
this.turnResults.delete(turnId);
|
|
1583
|
+
return Promise.resolve(completed);
|
|
1584
|
+
}
|
|
1585
|
+
return new Promise((resolve, reject) => {
|
|
1586
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
1587
|
+
const onAbort = () => {
|
|
1588
|
+
const waiter = this.turnWaiters.get(turnId);
|
|
1589
|
+
if (waiter?.cleanup === cleanup)
|
|
1590
|
+
this.turnWaiters.delete(turnId);
|
|
1591
|
+
cleanup();
|
|
1592
|
+
reject(signal.reason);
|
|
1593
|
+
};
|
|
1594
|
+
this.turnWaiters.set(turnId, {
|
|
1595
|
+
resolve: (turn) => {
|
|
1596
|
+
cleanup();
|
|
1597
|
+
resolve(turn);
|
|
1598
|
+
},
|
|
1599
|
+
reject: (error) => {
|
|
1600
|
+
cleanup();
|
|
1601
|
+
reject(error);
|
|
1602
|
+
},
|
|
1603
|
+
cleanup,
|
|
1604
|
+
});
|
|
1605
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1606
|
+
if (signal.aborted)
|
|
1607
|
+
onAbort();
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
resolveTurn(turn) {
|
|
1611
|
+
if (!this.activeTurnId || turn.id !== this.activeTurnId)
|
|
1612
|
+
return;
|
|
1613
|
+
const waiter = this.turnWaiters.get(turn.id);
|
|
1614
|
+
if (waiter) {
|
|
1615
|
+
this.turnWaiters.delete(turn.id);
|
|
1616
|
+
waiter.cleanup();
|
|
1617
|
+
waiter.resolve(turn);
|
|
1618
|
+
}
|
|
1619
|
+
else {
|
|
1620
|
+
this.turnResults.set(turn.id, turn);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async handleNotification(notification) {
|
|
1624
|
+
const params = isRecord(notification.params) ? notification.params : null;
|
|
1625
|
+
if (!params || threadIdFromMessage(params) !== this.thread.id)
|
|
1626
|
+
return;
|
|
1627
|
+
const turnId = notificationTurnId(params);
|
|
1628
|
+
if (!turnId || turnId !== this.activeTurnId)
|
|
1629
|
+
return;
|
|
1630
|
+
switch (notification.method) {
|
|
1631
|
+
case "item/started": {
|
|
1632
|
+
if (!turnId || !isRecord(params.item) || !this.activeClaim)
|
|
1633
|
+
return;
|
|
1634
|
+
const activity = startedItemActivity(params.item, turnId);
|
|
1635
|
+
const id = itemId(params.item);
|
|
1636
|
+
if (activity && id) {
|
|
1637
|
+
await this.queueActivity(() => this.reportActivity(`turn:${turnId}:item:${id}:started`, activity));
|
|
1638
|
+
}
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
case "item/agentMessage/delta":
|
|
1642
|
+
if (turnId) {
|
|
1643
|
+
this.appendDelta(turnId, stringValue(params.itemId), "assistant_message", typeof params.delta === "string" ? params.delta : "");
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
case "item/reasoning/summaryTextDelta":
|
|
1647
|
+
if (turnId) {
|
|
1648
|
+
this.appendDelta(turnId, stringValue(params.itemId), "reasoning", typeof params.delta === "string" ? params.delta : "");
|
|
1649
|
+
}
|
|
1650
|
+
return;
|
|
1651
|
+
case "item/commandExecution/outputDelta":
|
|
1652
|
+
if (turnId) {
|
|
1653
|
+
this.appendDelta(turnId, stringValue(params.itemId), "command", typeof params.delta === "string" ? params.delta : "");
|
|
1654
|
+
}
|
|
1655
|
+
return;
|
|
1656
|
+
case "item/completed": {
|
|
1657
|
+
if (!turnId || !isRecord(params.item) || !this.activeClaim)
|
|
1658
|
+
return;
|
|
1659
|
+
const id = itemId(params.item);
|
|
1660
|
+
if (!id)
|
|
1661
|
+
return;
|
|
1662
|
+
await this.flushBuffer(`${turnId}:${id}`);
|
|
1663
|
+
const buffer = this.buffers.get(`${turnId}:${id}`);
|
|
1664
|
+
const activity = completedItemActivity(params.item, turnId, buffer?.accumulated || null);
|
|
1665
|
+
if (params.item.type === "agentMessage") {
|
|
1666
|
+
this.lastAssistantMessage =
|
|
1667
|
+
stringValue(params.item.text) ||
|
|
1668
|
+
stringValue(buffer?.accumulated) ||
|
|
1669
|
+
this.lastAssistantMessage;
|
|
1670
|
+
}
|
|
1671
|
+
if (activity) {
|
|
1672
|
+
await this.queueActivity(() => this.reportActivity(`turn:${turnId}:item:${id}:completed`, activity));
|
|
1673
|
+
}
|
|
1674
|
+
this.buffers.delete(`${turnId}:${id}`);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
case "thread/tokenUsage/updated":
|
|
1678
|
+
if (!this.activeClaim)
|
|
1679
|
+
return;
|
|
1680
|
+
this.usageSequence += 1;
|
|
1681
|
+
await this.queueActivity(() => this.reportActivity(`usage:${this.usageSequence}`, {
|
|
1682
|
+
kind: "usage",
|
|
1683
|
+
content: null,
|
|
1684
|
+
data: {
|
|
1685
|
+
protocol: APP_SERVER_PROTOCOL,
|
|
1686
|
+
phase: "completed",
|
|
1687
|
+
usage: sanitizeHarnessValue(params.tokenUsage ?? params),
|
|
1688
|
+
},
|
|
1689
|
+
}));
|
|
1690
|
+
return;
|
|
1691
|
+
case "turn/completed": {
|
|
1692
|
+
if (!isRecord(params.turn) || !stringValue(params.turn.id))
|
|
1693
|
+
return;
|
|
1694
|
+
await this.flushAllBuffers();
|
|
1695
|
+
await this.activityChain;
|
|
1696
|
+
this.resolveTurn(params.turn);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
case "error": {
|
|
1700
|
+
if (!this.activeClaim)
|
|
1701
|
+
return;
|
|
1702
|
+
const protocolError = isRecord(params.error) ? params.error : {};
|
|
1703
|
+
const codexErrorInfo = isRecord(protocolError.codexErrorInfo)
|
|
1704
|
+
? protocolError.codexErrorInfo
|
|
1705
|
+
: null;
|
|
1706
|
+
const message = stringValue(protocolError.message) || "Codex App Server 错误";
|
|
1707
|
+
const errorCode = codexErrorInfo
|
|
1708
|
+
? stringValue(codexErrorInfo.code) ??
|
|
1709
|
+
(typeof codexErrorInfo.code === "number"
|
|
1710
|
+
? codexErrorInfo.code
|
|
1711
|
+
: null)
|
|
1712
|
+
: null;
|
|
1713
|
+
await this.queueActivity(() => this.reportActivity(`error:${randomUUID()}`, {
|
|
1714
|
+
kind: "error",
|
|
1715
|
+
content: redactHarnessText(message, 100_000),
|
|
1716
|
+
data: {
|
|
1717
|
+
protocol: APP_SERVER_PROTOCOL,
|
|
1718
|
+
phase: "completed",
|
|
1719
|
+
turn_ref: turnId,
|
|
1720
|
+
will_retry: params.willRetry === true,
|
|
1721
|
+
error_code: errorCode,
|
|
1722
|
+
codex_error_info: sanitizeHarnessValue(codexErrorInfo),
|
|
1723
|
+
additional_details: sanitizeHarnessValue(protocolError.additionalDetails),
|
|
1724
|
+
},
|
|
1725
|
+
}));
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
default:
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
appendDelta(turnId, itemIdValue, kind, deltaValue) {
|
|
1733
|
+
if (!this.activeClaim || !itemIdValue || !deltaValue)
|
|
1734
|
+
return;
|
|
1735
|
+
const key = `${turnId}:${itemIdValue}`;
|
|
1736
|
+
const buffer = this.buffers.get(key) ?? {
|
|
1737
|
+
kind,
|
|
1738
|
+
turnId,
|
|
1739
|
+
itemId: itemIdValue,
|
|
1740
|
+
pending: "",
|
|
1741
|
+
accumulated: "",
|
|
1742
|
+
streamClosed: false,
|
|
1743
|
+
chunkIndex: 0,
|
|
1744
|
+
timer: null,
|
|
1745
|
+
};
|
|
1746
|
+
if (buffer.streamClosed)
|
|
1747
|
+
return;
|
|
1748
|
+
const bounded = acceptBoundedStreamDelta(buffer.accumulated, deltaValue);
|
|
1749
|
+
buffer.accumulated = bounded.accumulated;
|
|
1750
|
+
buffer.streamClosed = bounded.truncated;
|
|
1751
|
+
this.buffers.set(key, buffer);
|
|
1752
|
+
for (const chunk of utf8DeltaChunks(bounded.accepted)) {
|
|
1753
|
+
if (buffer.pending &&
|
|
1754
|
+
Buffer.byteLength(buffer.pending, "utf8") +
|
|
1755
|
+
Buffer.byteLength(chunk, "utf8") >
|
|
1756
|
+
DELTA_CHUNK_BYTES) {
|
|
1757
|
+
void this.flushBuffer(key).catch(() => undefined);
|
|
1758
|
+
}
|
|
1759
|
+
buffer.pending += chunk;
|
|
1760
|
+
if (Buffer.byteLength(buffer.pending, "utf8") >= DELTA_CHUNK_BYTES) {
|
|
1761
|
+
void this.flushBuffer(key).catch(() => undefined);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (buffer.pending && !buffer.timer) {
|
|
1765
|
+
buffer.timer = setTimeout(() => {
|
|
1766
|
+
buffer.timer = null;
|
|
1767
|
+
void this.flushBuffer(key).catch(() => undefined);
|
|
1768
|
+
}, 500);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
flushBuffer(key) {
|
|
1772
|
+
const buffer = this.buffers.get(key);
|
|
1773
|
+
if (!buffer || !buffer.pending || !this.activeClaim) {
|
|
1774
|
+
return Promise.resolve();
|
|
1775
|
+
}
|
|
1776
|
+
if (buffer.timer) {
|
|
1777
|
+
clearTimeout(buffer.timer);
|
|
1778
|
+
buffer.timer = null;
|
|
1779
|
+
}
|
|
1780
|
+
const content = buffer.pending;
|
|
1781
|
+
buffer.pending = "";
|
|
1782
|
+
const chunkIndex = buffer.chunkIndex;
|
|
1783
|
+
buffer.chunkIndex += 1;
|
|
1784
|
+
return this.queueActivity(() => this.reportActivity(`turn:${buffer.turnId}:item:${buffer.itemId}:delta:${chunkIndex}`, {
|
|
1785
|
+
kind: buffer.kind,
|
|
1786
|
+
content: redactHarnessText(content, 100_000),
|
|
1787
|
+
data: protocolData("delta", buffer.turnId, buffer.itemId, {
|
|
1788
|
+
chunk_index: chunkIndex,
|
|
1789
|
+
disclosure: buffer.kind === "reasoning" ? "provider_summary" : undefined,
|
|
1790
|
+
}),
|
|
1791
|
+
}));
|
|
1792
|
+
}
|
|
1793
|
+
async flushAllBuffers() {
|
|
1794
|
+
await Promise.all([...this.buffers.keys()].map((key) => this.flushBuffer(key)));
|
|
1795
|
+
}
|
|
1796
|
+
queueActivity(operation) {
|
|
1797
|
+
if (this.queueOverflowError) {
|
|
1798
|
+
return Promise.reject(this.queueOverflowError);
|
|
1799
|
+
}
|
|
1800
|
+
if (this.activityBacklog >= MAX_ACTIVITY_BACKLOG) {
|
|
1801
|
+
return Promise.reject(this.failForQueueOverflow("Board activity upload"));
|
|
1802
|
+
}
|
|
1803
|
+
this.activityBacklog += 1;
|
|
1804
|
+
const queued = this.activityChain.then(operation);
|
|
1805
|
+
this.activityChain = queued
|
|
1806
|
+
.catch((error) => {
|
|
1807
|
+
this.eventError ??= error instanceof Error ? error : new Error(String(error));
|
|
1808
|
+
})
|
|
1809
|
+
.finally(() => {
|
|
1810
|
+
this.activityBacklog = Math.max(0, this.activityBacklog - 1);
|
|
1811
|
+
});
|
|
1812
|
+
return queued;
|
|
1813
|
+
}
|
|
1814
|
+
async reportActivity(externalSuffix, activity, options = {}) {
|
|
1815
|
+
const task = this.activeClaim;
|
|
1816
|
+
if (!task)
|
|
1817
|
+
return;
|
|
1818
|
+
if (activity.kind !== "assistant_message")
|
|
1819
|
+
return;
|
|
1820
|
+
await this.board.request("/api/ai/sessions/activity", {
|
|
1821
|
+
method: "POST",
|
|
1822
|
+
sessionId: this.session.id,
|
|
1823
|
+
idempotencyKey: idempotencyKey("activity"),
|
|
1824
|
+
maxAttempts: options.maxAttempts,
|
|
1825
|
+
timeoutMs: options.maxAttempts === 1 ? 5_000 : undefined,
|
|
1826
|
+
signal: this.stopController.signal,
|
|
1827
|
+
body: {
|
|
1828
|
+
task_id: task.id,
|
|
1829
|
+
claim_token: task.claim_token,
|
|
1830
|
+
external_ref: `codex:${this.thread.id}:${task.id}:${externalSuffix}`,
|
|
1831
|
+
kind: activity.kind,
|
|
1832
|
+
content: activity.content,
|
|
1833
|
+
data: boundActivityData(activity.data),
|
|
1834
|
+
},
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
async heartbeatSession() {
|
|
1838
|
+
const response = await this.board.request("/api/ai/sessions/presence", {
|
|
1839
|
+
method: "POST",
|
|
1840
|
+
sessionId: this.session.id,
|
|
1841
|
+
idempotencyKey: idempotencyKey("session-heartbeat"),
|
|
1842
|
+
signal: this.stopController.signal,
|
|
1843
|
+
body: {},
|
|
1844
|
+
});
|
|
1845
|
+
if (response.session)
|
|
1846
|
+
this.updateSession(response.session);
|
|
1847
|
+
}
|
|
1848
|
+
heartbeatClaim(task) {
|
|
1849
|
+
return this.board.request("/api/ai/sessions/heartbeat", {
|
|
1850
|
+
method: "POST",
|
|
1851
|
+
sessionId: this.session.id,
|
|
1852
|
+
idempotencyKey: idempotencyKey("claim-heartbeat"),
|
|
1853
|
+
signal: this.stopController.signal,
|
|
1854
|
+
body: {
|
|
1855
|
+
task_id: task.id,
|
|
1856
|
+
claim_token: task.claim_token,
|
|
1857
|
+
lease_seconds: this.configuration.leaseSeconds,
|
|
1858
|
+
},
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
async releaseActiveTask(reason) {
|
|
1862
|
+
if (!this.activeClaim)
|
|
1863
|
+
return;
|
|
1864
|
+
const task = this.activeClaim;
|
|
1865
|
+
await this.board
|
|
1866
|
+
.request("/api/ai/tasks/release", {
|
|
1867
|
+
method: "POST",
|
|
1868
|
+
sessionId: this.session.id,
|
|
1869
|
+
idempotencyKey: idempotencyKey("release"),
|
|
1870
|
+
maxAttempts: 1,
|
|
1871
|
+
timeoutMs: 5_000,
|
|
1872
|
+
body: {
|
|
1873
|
+
task_id: task.id,
|
|
1874
|
+
claim_token: task.claim_token,
|
|
1875
|
+
reason,
|
|
1876
|
+
},
|
|
1877
|
+
})
|
|
1878
|
+
.catch(() => undefined);
|
|
1879
|
+
this.activeClaim = null;
|
|
1880
|
+
}
|
|
1881
|
+
trackBackgroundBoardOperation(operation, description) {
|
|
1882
|
+
const tracked = operation
|
|
1883
|
+
.catch((error) => {
|
|
1884
|
+
if (!this.stopping) {
|
|
1885
|
+
process.stderr.write(`Thread ${this.thread.id} ${description}失败:${errorMessage(error)}\n`);
|
|
1886
|
+
}
|
|
1887
|
+
})
|
|
1888
|
+
.finally(() => this.backgroundBoardOperations.delete(tracked));
|
|
1889
|
+
this.backgroundBoardOperations.add(tracked);
|
|
1890
|
+
}
|
|
1891
|
+
async waitForBackgroundBoardOperations() {
|
|
1892
|
+
while (this.backgroundBoardOperations.size > 0) {
|
|
1893
|
+
await Promise.allSettled([...this.backgroundBoardOperations]);
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
async trackMutatingRequest(operation) {
|
|
1897
|
+
this.mutatingRequestCount += 1;
|
|
1898
|
+
try {
|
|
1899
|
+
return await operation;
|
|
1900
|
+
}
|
|
1901
|
+
finally {
|
|
1902
|
+
this.mutatingRequestCount -= 1;
|
|
1903
|
+
if (this.mutatingRequestCount === 0) {
|
|
1904
|
+
for (const resolve of this.retirementWaiters)
|
|
1905
|
+
resolve();
|
|
1906
|
+
this.retirementWaiters.clear();
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
class DeviceBridge {
|
|
1912
|
+
configuration;
|
|
1913
|
+
appServer;
|
|
1914
|
+
stopController = new AbortController();
|
|
1915
|
+
board;
|
|
1916
|
+
limiter;
|
|
1917
|
+
workers = new Map();
|
|
1918
|
+
workerRuns = new Map();
|
|
1919
|
+
managedThreadIds = new Set();
|
|
1920
|
+
historySynchronizer;
|
|
1921
|
+
historySyncPromise = null;
|
|
1922
|
+
historyConfigurationReady = false;
|
|
1923
|
+
stopping = false;
|
|
1924
|
+
fatalError = null;
|
|
1925
|
+
stopPromise = null;
|
|
1926
|
+
appliedConfigurationVersion = null;
|
|
1927
|
+
configurationError = null;
|
|
1928
|
+
effectiveConfigurationKnown = true;
|
|
1929
|
+
runtimeInstanceId = randomUUID();
|
|
1930
|
+
reportSequence = 0;
|
|
1931
|
+
runtimeLeaseClaimed = false;
|
|
1932
|
+
leaseRenewalPromise = null;
|
|
1933
|
+
leaseSafetyDeadlineMs = null;
|
|
1934
|
+
latestSuccessfulReportSequence = 0;
|
|
1935
|
+
legacyConfigurationCompatibility = false;
|
|
1936
|
+
inventoryReady = false;
|
|
1937
|
+
modelCatalog;
|
|
1938
|
+
constructor(configuration, appServer) {
|
|
1939
|
+
this.configuration = configuration;
|
|
1940
|
+
this.appServer = appServer;
|
|
1941
|
+
this.board = new BoardClient(configuration, () => this.stopping);
|
|
1942
|
+
this.limiter = new TurnLimiter(configuration.maxConcurrentTurns);
|
|
1943
|
+
this.historySynchronizer = new HistorySynchronizer({
|
|
1944
|
+
appServer,
|
|
1945
|
+
runtimeInstanceId: this.runtimeInstanceId,
|
|
1946
|
+
configuration: () => ({
|
|
1947
|
+
enabled: this.historyConfigurationReady &&
|
|
1948
|
+
this.configuration.enabled &&
|
|
1949
|
+
this.configuration.syncHistory,
|
|
1950
|
+
turnLimit: this.configuration.historyTurnLimit,
|
|
1951
|
+
}),
|
|
1952
|
+
importHistory: (sessionId, request, signal) => this.board.importHistory(sessionId, request, signal),
|
|
1953
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
1954
|
+
});
|
|
1955
|
+
appServer.onNotification((notification) => {
|
|
1956
|
+
const params = isRecord(notification.params) ? notification.params : null;
|
|
1957
|
+
const threadId = threadIdFromMessage(params);
|
|
1958
|
+
if (threadId)
|
|
1959
|
+
this.workers.get(threadId)?.enqueueNotification(notification);
|
|
1960
|
+
});
|
|
1961
|
+
appServer.onError((error) => {
|
|
1962
|
+
if (this.stopping)
|
|
1963
|
+
return;
|
|
1964
|
+
process.stderr.write(`Codex App Server 已退出:${error.message}\n`);
|
|
1965
|
+
this.markFatal(new Error(`Codex App Server 意外退出:${error.message}`, {
|
|
1966
|
+
cause: error,
|
|
1967
|
+
}));
|
|
1968
|
+
});
|
|
1969
|
+
appServer.setServerRequestHandler((request) => this.handleServerRequest(request));
|
|
1970
|
+
}
|
|
1971
|
+
async run() {
|
|
1972
|
+
if (this.configuration.approvalMode !== "decline") {
|
|
1973
|
+
process.stderr.write(`警告:CODEX_BRIDGE_APPROVAL_MODE=${this.configuration.approvalMode} 会自动批准本机操作\n`);
|
|
1974
|
+
}
|
|
1975
|
+
if (this.configuration.permissionMode === "inherit") {
|
|
1976
|
+
process.stderr.write("高风险警告:CODEX_BRIDGE_PERMISSION_MODE=inherit 会沿用 thread 的审批与沙箱设置,可能继承 danger-full-access 或额外可写目录\n");
|
|
1977
|
+
}
|
|
1978
|
+
else if (this.configuration.permissionMode === "danger-full-access") {
|
|
1979
|
+
process.stderr.write("高风险警告:CODEX_BRIDGE_PERMISSION_MODE=danger-full-access 不使用 Codex 沙箱,thread 可访问本机用户有权访问的文件与网络\n");
|
|
1980
|
+
}
|
|
1981
|
+
if (!this.configuration.threadIdFilter && this.configuration.threadScope === "all") {
|
|
1982
|
+
process.stderr.write("高风险警告:CODEX_THREAD_SCOPE=all 会管理当前系统用户的跨项目顶层 Codex threads\n");
|
|
1983
|
+
}
|
|
1984
|
+
await this.discoverModelCatalog();
|
|
1985
|
+
await this.establishRemoteConfigurationLease();
|
|
1986
|
+
if (this.stopping) {
|
|
1987
|
+
await this.stop();
|
|
1988
|
+
if (this.fatalError)
|
|
1989
|
+
throw this.fatalError;
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
this.leaseRenewalPromise = this.runRemoteConfigurationLeaseRenewal();
|
|
1993
|
+
this.historySyncPromise = this.historySynchronizer
|
|
1994
|
+
.start(this.stopController.signal)
|
|
1995
|
+
.catch((error) => {
|
|
1996
|
+
if (!this.stopping) {
|
|
1997
|
+
process.stderr.write(`Codex 历史后台同步器已停止:${errorMessage(error)}\n`);
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
let nextInventorySyncAt = 0;
|
|
2001
|
+
do {
|
|
2002
|
+
let reconciledConfiguration = false;
|
|
2003
|
+
if (this.configuration.webConfigurationEnabled) {
|
|
2004
|
+
try {
|
|
2005
|
+
reconciledConfiguration = await this.reconcileRemoteConfiguration();
|
|
2006
|
+
}
|
|
2007
|
+
catch (error) {
|
|
2008
|
+
if (this.stopping)
|
|
2009
|
+
break;
|
|
2010
|
+
if (error instanceof WorkerRetirementFailureError) {
|
|
2011
|
+
this.markFatal(error);
|
|
2012
|
+
break;
|
|
2013
|
+
}
|
|
2014
|
+
if (isPersistentClientError(error)) {
|
|
2015
|
+
this.markFatal(actionableBoardError(error));
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
process.stderr.write(`同步 Web Bridge 配置失败,继续使用当前有效配置:${errorMessage(error)}\n`);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (this.stopping)
|
|
2022
|
+
break;
|
|
2023
|
+
const inventoryDue = Date.now() >= nextInventorySyncAt;
|
|
2024
|
+
if (reconciledConfiguration) {
|
|
2025
|
+
nextInventorySyncAt = Date.now() + this.configuration.syncIntervalMs;
|
|
2026
|
+
}
|
|
2027
|
+
else if (inventoryDue) {
|
|
2028
|
+
try {
|
|
2029
|
+
await this.syncWorkers();
|
|
2030
|
+
this.effectiveConfigurationKnown = true;
|
|
2031
|
+
nextInventorySyncAt = Date.now() + this.configuration.syncIntervalMs;
|
|
2032
|
+
}
|
|
2033
|
+
catch (error) {
|
|
2034
|
+
if (this.stopping)
|
|
2035
|
+
break;
|
|
2036
|
+
if (error instanceof WorkerRetirementFailureError) {
|
|
2037
|
+
this.markFatal(error);
|
|
2038
|
+
break;
|
|
2039
|
+
}
|
|
2040
|
+
if (isPersistentClientError(error)) {
|
|
2041
|
+
this.markFatal(actionableBoardError(error));
|
|
2042
|
+
break;
|
|
2043
|
+
}
|
|
2044
|
+
process.stderr.write(`同步 Codex threads 失败:${errorMessage(error)}\n`);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
if (this.stopping)
|
|
2048
|
+
break;
|
|
2049
|
+
if (this.inventoryReady) {
|
|
2050
|
+
try {
|
|
2051
|
+
const inventoryChanged = await this.processThreadCommands();
|
|
2052
|
+
if (inventoryChanged && !this.stopping) {
|
|
2053
|
+
await this.syncWorkers();
|
|
2054
|
+
nextInventorySyncAt = Date.now() + this.configuration.syncIntervalMs;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
catch (error) {
|
|
2058
|
+
if (this.stopping)
|
|
2059
|
+
break;
|
|
2060
|
+
if (error instanceof WorkerRetirementFailureError) {
|
|
2061
|
+
this.markFatal(error);
|
|
2062
|
+
break;
|
|
2063
|
+
}
|
|
2064
|
+
if (isPersistentClientError(error)) {
|
|
2065
|
+
this.markFatal(actionableBoardError(error));
|
|
2066
|
+
break;
|
|
2067
|
+
}
|
|
2068
|
+
process.stderr.write(`处理 Web Thread 管理指令失败:${errorMessage(error)}\n`);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
if (this.stopping)
|
|
2072
|
+
break;
|
|
2073
|
+
const untilInventorySync = Math.max(1_000, nextInventorySyncAt - Date.now());
|
|
2074
|
+
const sleepMilliseconds = Math.min(this.configuration.configurationPollIntervalMs, untilInventorySync);
|
|
2075
|
+
try {
|
|
2076
|
+
await delay(sleepMilliseconds, this.stopController.signal);
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
break;
|
|
2080
|
+
}
|
|
2081
|
+
} while (!this.stopping);
|
|
2082
|
+
await this.stop();
|
|
2083
|
+
if (this.fatalError)
|
|
2084
|
+
throw this.fatalError;
|
|
2085
|
+
}
|
|
2086
|
+
async stop() {
|
|
2087
|
+
if (this.stopPromise)
|
|
2088
|
+
return this.stopPromise;
|
|
2089
|
+
this.stopPromise = this.stopBridge();
|
|
2090
|
+
return this.stopPromise;
|
|
2091
|
+
}
|
|
2092
|
+
async stopBridge() {
|
|
2093
|
+
this.stopping = true;
|
|
2094
|
+
this.historySynchronizer.stop();
|
|
2095
|
+
this.stopController.abort(new Error("Codex Bridge 正在停止"));
|
|
2096
|
+
const workerStops = [...this.workers.values()].map((worker) => worker.stop("Codex Bridge 正在停止"));
|
|
2097
|
+
await Promise.race([
|
|
2098
|
+
Promise.allSettled(workerStops),
|
|
2099
|
+
delay(3_000),
|
|
2100
|
+
]);
|
|
2101
|
+
await this.appServer.close();
|
|
2102
|
+
await Promise.allSettled(workerStops);
|
|
2103
|
+
await (this.historySyncPromise ?? Promise.resolve()).catch(() => undefined);
|
|
2104
|
+
await (this.leaseRenewalPromise ?? Promise.resolve()).catch(() => undefined);
|
|
2105
|
+
await this.releaseRemoteConfigurationLease();
|
|
2106
|
+
}
|
|
2107
|
+
configurationLeaseRenewalIntervalMs() {
|
|
2108
|
+
return Math.min(this.configuration.configurationPollIntervalMs, Math.max(1_000, Math.floor((this.configuration.configurationLeaseSeconds * 1_000) / 3)));
|
|
2109
|
+
}
|
|
2110
|
+
configurationLeaseRequestTimeoutMs() {
|
|
2111
|
+
const intervalMs = this.configurationLeaseRenewalIntervalMs();
|
|
2112
|
+
return Math.min(5_000, Math.max(500, Math.floor(intervalMs * 0.8)));
|
|
2113
|
+
}
|
|
2114
|
+
configurationLeaseSafetyMarginMs() {
|
|
2115
|
+
return 5_000;
|
|
2116
|
+
}
|
|
2117
|
+
leaseSafetyExpired() {
|
|
2118
|
+
return (this.leaseSafetyDeadlineMs !== null &&
|
|
2119
|
+
monotonicMilliseconds() >= this.leaseSafetyDeadlineMs);
|
|
2120
|
+
}
|
|
2121
|
+
markLeaseSafetyFatal(lastError) {
|
|
2122
|
+
this.markFatal(new Error(`Bridge 运行租约未能在本地安全期限前续租,已停止所有 worker,避免多个实例同时运行。${lastError ? ` ${errorMessage(lastError)}` : ""}`, lastError === undefined ? undefined : { cause: lastError }));
|
|
2123
|
+
}
|
|
2124
|
+
async establishRemoteConfigurationLease() {
|
|
2125
|
+
let attempt = 0;
|
|
2126
|
+
while (!this.stopping) {
|
|
2127
|
+
attempt += 1;
|
|
2128
|
+
try {
|
|
2129
|
+
await this.exchangeRemoteConfiguration({ timeoutMs: 5_000 });
|
|
2130
|
+
if (!this.leaseSafetyExpired())
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
catch (error) {
|
|
2134
|
+
if (this.stopping)
|
|
2135
|
+
return;
|
|
2136
|
+
const status = errorStatus(error);
|
|
2137
|
+
if (status === 404 && !this.configuration.webConfigurationEnabled) {
|
|
2138
|
+
// Board 0.2 compatibility: inventory can run without config support.
|
|
2139
|
+
this.legacyConfigurationCompatibility = true;
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
if (status === 409) {
|
|
2143
|
+
if (attempt === 1 || attempt % 10 === 0) {
|
|
2144
|
+
process.stderr.write("同一连接的旧 Bridge 租约仍有效;本实例保持待机并等待接管\n");
|
|
2145
|
+
}
|
|
2146
|
+
await delay(Math.min(500 * 2 ** Math.min(attempt - 1, 4), 5_000), this.stopController.signal).catch(() => undefined);
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
if (isPersistentClientError(error)) {
|
|
2150
|
+
throw actionableBoardError(error);
|
|
2151
|
+
}
|
|
2152
|
+
if (attempt === 1 || attempt % 10 === 0) {
|
|
2153
|
+
process.stderr.write(`尚未取得 Bridge 运行租约,等待后重试:${errorMessage(error)}\n`);
|
|
2154
|
+
}
|
|
2155
|
+
await delay(Math.min(500 * 2 ** Math.min(attempt - 1, 4), 5_000), this.stopController.signal).catch(() => undefined);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
async runRemoteConfigurationLeaseRenewal() {
|
|
2160
|
+
const intervalMs = this.configurationLeaseRenewalIntervalMs();
|
|
2161
|
+
const timeoutMs = this.configurationLeaseRequestTimeoutMs();
|
|
2162
|
+
while (!this.stopping) {
|
|
2163
|
+
const untilSafetyDeadline = this.leaseSafetyDeadlineMs === null
|
|
2164
|
+
? intervalMs
|
|
2165
|
+
: Math.max(0, this.leaseSafetyDeadlineMs - monotonicMilliseconds());
|
|
2166
|
+
if (this.leaseSafetyDeadlineMs !== null && untilSafetyDeadline <= 0) {
|
|
2167
|
+
this.markLeaseSafetyFatal();
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
try {
|
|
2171
|
+
await delay(Math.min(intervalMs, untilSafetyDeadline), this.stopController.signal);
|
|
2172
|
+
}
|
|
2173
|
+
catch {
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
if (this.stopping)
|
|
2177
|
+
return;
|
|
2178
|
+
if (this.leaseSafetyExpired()) {
|
|
2179
|
+
this.markLeaseSafetyFatal();
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
try {
|
|
2183
|
+
// This loop only renews the runtime fence and reports the latest
|
|
2184
|
+
// in-memory status. Desired config is applied by the main reconcile.
|
|
2185
|
+
await this.exchangeRemoteConfiguration({ timeoutMs });
|
|
2186
|
+
}
|
|
2187
|
+
catch (error) {
|
|
2188
|
+
if (this.stopping)
|
|
2189
|
+
return;
|
|
2190
|
+
const status = errorStatus(error);
|
|
2191
|
+
if (status === 404 &&
|
|
2192
|
+
!this.configuration.webConfigurationEnabled &&
|
|
2193
|
+
this.legacyConfigurationCompatibility &&
|
|
2194
|
+
!this.runtimeLeaseClaimed) {
|
|
2195
|
+
continue;
|
|
2196
|
+
}
|
|
2197
|
+
if (isPersistentClientError(error)) {
|
|
2198
|
+
this.markFatal(actionableBoardError(error));
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
process.stderr.write(`Bridge 运行租约续租失败,将在下一周期重试:${errorMessage(error)}\n`);
|
|
2202
|
+
if (this.leaseSafetyExpired()) {
|
|
2203
|
+
this.markLeaseSafetyFatal(error);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
configurationStatus(releaseRuntime = false) {
|
|
2210
|
+
this.reportSequence += 1;
|
|
2211
|
+
return {
|
|
2212
|
+
runtime_instance_id: this.runtimeInstanceId,
|
|
2213
|
+
report_sequence: this.reportSequence,
|
|
2214
|
+
lease_seconds: this.configuration.configurationLeaseSeconds,
|
|
2215
|
+
release_runtime: releaseRuntime,
|
|
2216
|
+
applied_version: this.appliedConfigurationVersion,
|
|
2217
|
+
effective: this.effectiveConfigurationKnown
|
|
2218
|
+
? remoteDesiredFromEffective(effectiveBridgeConfiguration(this.configuration))
|
|
2219
|
+
: null,
|
|
2220
|
+
constraints: bridgeConfigurationConstraints(this.configuration),
|
|
2221
|
+
error: this.configurationError
|
|
2222
|
+
? redactHarnessText(this.configurationError, 2_000)
|
|
2223
|
+
: null,
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2226
|
+
async exchangeRemoteConfiguration(options = {}) {
|
|
2227
|
+
const releaseRuntime = options.releaseRuntime === true;
|
|
2228
|
+
const status = this.configurationStatus(releaseRuntime);
|
|
2229
|
+
const requestStartedAt = monotonicMilliseconds();
|
|
2230
|
+
const response = await this.board.exchangeConfiguration(status, releaseRuntime
|
|
2231
|
+
? options.signal
|
|
2232
|
+
: (options.signal ?? this.stopController.signal), options.timeoutMs);
|
|
2233
|
+
if (releaseRuntime) {
|
|
2234
|
+
this.runtimeLeaseClaimed = false;
|
|
2235
|
+
this.leaseSafetyDeadlineMs = null;
|
|
2236
|
+
}
|
|
2237
|
+
else if (!this.stopping &&
|
|
2238
|
+
status.report_sequence > this.latestSuccessfulReportSequence) {
|
|
2239
|
+
this.latestSuccessfulReportSequence = status.report_sequence;
|
|
2240
|
+
this.runtimeLeaseClaimed = true;
|
|
2241
|
+
this.legacyConfigurationCompatibility = false;
|
|
2242
|
+
this.leaseSafetyDeadlineMs =
|
|
2243
|
+
requestStartedAt +
|
|
2244
|
+
this.configuration.configurationLeaseSeconds * 1_000 -
|
|
2245
|
+
this.configurationLeaseSafetyMarginMs();
|
|
2246
|
+
}
|
|
2247
|
+
return response;
|
|
2248
|
+
}
|
|
2249
|
+
async releaseRemoteConfigurationLease() {
|
|
2250
|
+
if (!this.runtimeLeaseClaimed)
|
|
2251
|
+
return;
|
|
2252
|
+
await this.exchangeRemoteConfiguration({
|
|
2253
|
+
releaseRuntime: true,
|
|
2254
|
+
signal: undefined,
|
|
2255
|
+
timeoutMs: 1_500,
|
|
2256
|
+
})
|
|
2257
|
+
.then(() => {
|
|
2258
|
+
this.runtimeLeaseClaimed = false;
|
|
2259
|
+
})
|
|
2260
|
+
.catch(() => undefined);
|
|
2261
|
+
}
|
|
2262
|
+
async reconcileRemoteConfiguration() {
|
|
2263
|
+
let response = await this.exchangeRemoteConfiguration();
|
|
2264
|
+
let reconciled = false;
|
|
2265
|
+
// A re-report can race a Web edit. Apply a few consecutive versions now;
|
|
2266
|
+
// any later version remains unapplied and is picked up by the next poll.
|
|
2267
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2268
|
+
const remote = response.configuration;
|
|
2269
|
+
if (remote.version === this.appliedConfigurationVersion)
|
|
2270
|
+
return reconciled;
|
|
2271
|
+
if (this.appliedConfigurationVersion !== null &&
|
|
2272
|
+
remote.version < this.appliedConfigurationVersion) {
|
|
2273
|
+
this.configurationError =
|
|
2274
|
+
`忽略过期看板配置 version=${remote.version};设备已应用 version=${this.appliedConfigurationVersion}`;
|
|
2275
|
+
process.stderr.write(`${this.configurationError}\n`);
|
|
2276
|
+
return reconciled;
|
|
2277
|
+
}
|
|
2278
|
+
const previousEffective = effectiveBridgeConfiguration(this.configuration);
|
|
2279
|
+
const previousEffectiveKnown = this.effectiveConfigurationKnown;
|
|
2280
|
+
let resolved;
|
|
2281
|
+
try {
|
|
2282
|
+
resolved = resolveRemoteConfiguration(this.configuration, remote.desired);
|
|
2283
|
+
}
|
|
2284
|
+
catch (error) {
|
|
2285
|
+
// Validation failures happen before any effective state is mutated.
|
|
2286
|
+
// Keep reporting the last concrete state/version, but surface the
|
|
2287
|
+
// rejected version to the Board so Web does not wait indefinitely.
|
|
2288
|
+
this.configurationError =
|
|
2289
|
+
`应用 version=${remote.version} 失败:${errorMessage(error)}`;
|
|
2290
|
+
process.stderr.write(`${this.configurationError}\n`);
|
|
2291
|
+
await this
|
|
2292
|
+
.exchangeRemoteConfiguration()
|
|
2293
|
+
.catch(() => undefined);
|
|
2294
|
+
throw error;
|
|
2295
|
+
}
|
|
2296
|
+
this.historyConfigurationReady = false;
|
|
2297
|
+
this.historySynchronizer.configurationChanged();
|
|
2298
|
+
this.configuration.enabled = resolved.effective.enabled;
|
|
2299
|
+
this.configuration.includeThreadTitles =
|
|
2300
|
+
resolved.effective.includeThreadTitles;
|
|
2301
|
+
this.configuration.maxThreads = resolved.effective.maxThreads;
|
|
2302
|
+
this.configuration.maxConcurrentTurns =
|
|
2303
|
+
resolved.effective.maxConcurrentTurns;
|
|
2304
|
+
this.configuration.syncHistory = resolved.effective.syncHistory;
|
|
2305
|
+
this.configuration.historyTurnLimit = resolved.effective.historyTurnLimit;
|
|
2306
|
+
this.configuration.workingDirectory =
|
|
2307
|
+
resolved.effective.workingDirectory;
|
|
2308
|
+
this.configuration.workingDirectories = copyWorkingDirectories(resolved.effective.workingDirectories);
|
|
2309
|
+
this.limiter.resize(resolved.effective.maxConcurrentTurns);
|
|
2310
|
+
this.configurationError = resolved.warnings.length
|
|
2311
|
+
? resolved.warnings.join(";")
|
|
2312
|
+
: null;
|
|
2313
|
+
for (const warning of resolved.warnings) {
|
|
2314
|
+
process.stderr.write(`Web Bridge 配置警告:${warning}\n`);
|
|
2315
|
+
}
|
|
2316
|
+
this.effectiveConfigurationKnown = false;
|
|
2317
|
+
try {
|
|
2318
|
+
// Retire excluded workers before publishing the authoritative inventory.
|
|
2319
|
+
await this.syncWorkers();
|
|
2320
|
+
}
|
|
2321
|
+
catch (error) {
|
|
2322
|
+
if (error instanceof WorkerRetirementDeferredError) {
|
|
2323
|
+
// The retirement fence fires before worker-map mutation, so the last
|
|
2324
|
+
// effective view is still exact and can be restored safely.
|
|
2325
|
+
this.configuration.enabled = previousEffective.enabled;
|
|
2326
|
+
this.configuration.includeThreadTitles =
|
|
2327
|
+
previousEffective.includeThreadTitles;
|
|
2328
|
+
this.configuration.maxThreads = previousEffective.maxThreads;
|
|
2329
|
+
this.configuration.maxConcurrentTurns =
|
|
2330
|
+
previousEffective.maxConcurrentTurns;
|
|
2331
|
+
this.configuration.syncHistory = previousEffective.syncHistory;
|
|
2332
|
+
this.configuration.historyTurnLimit = previousEffective.historyTurnLimit;
|
|
2333
|
+
this.configuration.workingDirectory =
|
|
2334
|
+
previousEffective.workingDirectory;
|
|
2335
|
+
this.configuration.workingDirectories = copyWorkingDirectories(previousEffective.workingDirectories);
|
|
2336
|
+
this.limiter.resize(previousEffective.maxConcurrentTurns);
|
|
2337
|
+
this.effectiveConfigurationKnown = previousEffectiveKnown;
|
|
2338
|
+
this.historyConfigurationReady =
|
|
2339
|
+
previousEffectiveKnown && this.appliedConfigurationVersion !== null;
|
|
2340
|
+
}
|
|
2341
|
+
else {
|
|
2342
|
+
// Directory scope is safe to restore even if worker retirement or
|
|
2343
|
+
// inventory publication made partial progress. The version remains
|
|
2344
|
+
// unapplied and the next reconcile retries from the previous
|
|
2345
|
+
// effective allowlist instead of leaking a failed remote directory
|
|
2346
|
+
// change into thread creation or later inventory scans.
|
|
2347
|
+
this.configuration.workingDirectory =
|
|
2348
|
+
previousEffective.workingDirectory;
|
|
2349
|
+
this.configuration.workingDirectories = copyWorkingDirectories(previousEffective.workingDirectories);
|
|
2350
|
+
// A later failure may happen after workers were stopped. Do not claim
|
|
2351
|
+
// a precise effective state until a full reconciliation succeeds.
|
|
2352
|
+
this.effectiveConfigurationKnown = false;
|
|
2353
|
+
}
|
|
2354
|
+
this.configurationError = [
|
|
2355
|
+
this.configurationError,
|
|
2356
|
+
`应用 version=${remote.version} 失败:${errorMessage(error)}`,
|
|
2357
|
+
]
|
|
2358
|
+
.filter(Boolean)
|
|
2359
|
+
.join(";");
|
|
2360
|
+
await this
|
|
2361
|
+
.exchangeRemoteConfiguration()
|
|
2362
|
+
.catch(() => undefined);
|
|
2363
|
+
throw error;
|
|
2364
|
+
}
|
|
2365
|
+
this.effectiveConfigurationKnown = true;
|
|
2366
|
+
this.appliedConfigurationVersion = remote.version;
|
|
2367
|
+
reconciled = true;
|
|
2368
|
+
process.stdout.write(`已应用 Web Bridge 配置 version=${remote.version}:${this.configuration.enabled ? "已启用" : "已停用"},${this.configuration.workingDirectories.length} 个工作目录,最多 ${this.configuration.maxThreads} 个 thread / ${this.configuration.maxConcurrentTurns} 个并行 turn\n`);
|
|
2369
|
+
response = await this.exchangeRemoteConfiguration();
|
|
2370
|
+
this.historyConfigurationReady = true;
|
|
2371
|
+
this.historySynchronizer.configurationChanged();
|
|
2372
|
+
}
|
|
2373
|
+
return reconciled;
|
|
2374
|
+
}
|
|
2375
|
+
async syncWorkers() {
|
|
2376
|
+
const threads = await this.listThreads();
|
|
2377
|
+
const visibleThreadIds = new Set(threads.map((thread) => thread.id));
|
|
2378
|
+
const removed = [];
|
|
2379
|
+
for (const [threadId, worker] of this.workers) {
|
|
2380
|
+
if (visibleThreadIds.has(threadId))
|
|
2381
|
+
continue;
|
|
2382
|
+
removed.push({ threadId, worker });
|
|
2383
|
+
}
|
|
2384
|
+
const blocked = removed.filter(({ worker }) => worker.retirementBlocked);
|
|
2385
|
+
if (blocked.length > 0) {
|
|
2386
|
+
await Promise.all(blocked.map(({ worker }) => worker.waitForRetirementReady(3_000)));
|
|
2387
|
+
if (blocked.some(({ worker }) => worker.retirementBlocked)) {
|
|
2388
|
+
throw new WorkerRetirementDeferredError(`暂缓同步:${blocked.length} 个已移除 thread 仍在等待 App Server 返回 resume/turn-start,避免产生孤儿 turn`);
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
if (removed.length > 0) {
|
|
2392
|
+
await stopWorkersForRetirement(removed, "Codex thread 已从设备清单移除");
|
|
2393
|
+
for (const { threadId } of removed)
|
|
2394
|
+
this.workers.delete(threadId);
|
|
2395
|
+
}
|
|
2396
|
+
if (this.stopping)
|
|
2397
|
+
return;
|
|
2398
|
+
const sessions = await this.board.syncSessions(threads, this.modelCatalog, this.stopController.signal);
|
|
2399
|
+
if (this.stopping)
|
|
2400
|
+
return;
|
|
2401
|
+
this.managedThreadIds = visibleThreadIds;
|
|
2402
|
+
this.historySynchronizer.updateTargets(threads.flatMap((thread) => {
|
|
2403
|
+
const session = sessions.get(thread.id);
|
|
2404
|
+
return session &&
|
|
2405
|
+
!session.deletion_requested_at &&
|
|
2406
|
+
isInteractiveHistoryThread(thread)
|
|
2407
|
+
? [{
|
|
2408
|
+
thread,
|
|
2409
|
+
sessionId: session.id,
|
|
2410
|
+
}]
|
|
2411
|
+
: [];
|
|
2412
|
+
}));
|
|
2413
|
+
for (const thread of threads) {
|
|
2414
|
+
const session = sessions.get(thread.id);
|
|
2415
|
+
if (!session) {
|
|
2416
|
+
process.stderr.write(`看板未返回 thread ${thread.id} 对应的 Session\n`);
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
const existingWorker = this.workers.get(thread.id);
|
|
2420
|
+
if (session.deletion_requested_at) {
|
|
2421
|
+
existingWorker?.updateSession(session);
|
|
2422
|
+
if (!existingWorker) {
|
|
2423
|
+
process.stdout.write(`Thread ${thread.id} 正在等待 Web 删除指令,暂不启动 worker\n`);
|
|
2424
|
+
}
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
if (existingWorker) {
|
|
2428
|
+
existingWorker.updateSession(session);
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
const worker = new SessionWorker(thread, session, this.configuration, this.board, this.appServer, this.limiter, (error) => this.markFatal(error));
|
|
2432
|
+
this.workers.set(thread.id, worker);
|
|
2433
|
+
const run = worker.start().catch((error) => {
|
|
2434
|
+
if (!this.stopping) {
|
|
2435
|
+
process.stderr.write(`Thread worker ${thread.id} 已退出:${errorMessage(error)}\n`);
|
|
2436
|
+
this.workers.delete(thread.id);
|
|
2437
|
+
if (isSessionNotAuthorizedError(error)) {
|
|
2438
|
+
process.stderr.write(`Thread ${thread.id} 已被看板停用;保留 Bridge 运行以完成待处理管理指令\n`);
|
|
2439
|
+
}
|
|
2440
|
+
else if (isPersistentClientError(error)) {
|
|
2441
|
+
this.markFatal(actionableBoardError(error));
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
this.workerRuns.set(thread.id, run);
|
|
2446
|
+
void run.finally(() => this.workerRuns.delete(thread.id));
|
|
2447
|
+
process.stdout.write(`已连接 thread:${sessionName(thread, this.configuration)} (${thread.id})\n`);
|
|
2448
|
+
}
|
|
2449
|
+
process.stdout.write(`Codex Bridge 已同步 ${threads.length} 个 thread,最多并行 ${this.configuration.maxConcurrentTurns} 个 turn\n`);
|
|
2450
|
+
this.inventoryReady = true;
|
|
2451
|
+
}
|
|
2452
|
+
async discoverModelCatalog() {
|
|
2453
|
+
const models = [];
|
|
2454
|
+
const seenModels = new Set();
|
|
2455
|
+
const seenCursors = new Set();
|
|
2456
|
+
let cursor = null;
|
|
2457
|
+
let defaultClaimed = false;
|
|
2458
|
+
try {
|
|
2459
|
+
do {
|
|
2460
|
+
const response = await this.appServer.modelList({
|
|
2461
|
+
cursor,
|
|
2462
|
+
limit: MODEL_CATALOG_PAGE_SIZE,
|
|
2463
|
+
includeHidden: false,
|
|
2464
|
+
}, { signal: this.stopController.signal, timeoutMs: 5_000 });
|
|
2465
|
+
if (!Array.isArray(response.data)) {
|
|
2466
|
+
throw new Error("model/list 未返回模型数组");
|
|
2467
|
+
}
|
|
2468
|
+
for (const candidate of response.data) {
|
|
2469
|
+
const normalized = inventoryModel(candidate, !defaultClaimed);
|
|
2470
|
+
if (!normalized || seenModels.has(normalized.model))
|
|
2471
|
+
continue;
|
|
2472
|
+
if (normalized.is_default)
|
|
2473
|
+
defaultClaimed = true;
|
|
2474
|
+
seenModels.add(normalized.model);
|
|
2475
|
+
models.push(normalized);
|
|
2476
|
+
if (models.length >= MAX_MODEL_CATALOG_ENTRIES)
|
|
2477
|
+
break;
|
|
2478
|
+
}
|
|
2479
|
+
if (models.length >= MAX_MODEL_CATALOG_ENTRIES)
|
|
2480
|
+
break;
|
|
2481
|
+
const nextCursor = stringValue(response.nextCursor);
|
|
2482
|
+
if (!nextCursor)
|
|
2483
|
+
break;
|
|
2484
|
+
if (seenCursors.has(nextCursor)) {
|
|
2485
|
+
throw new Error("model/list 返回了重复分页游标");
|
|
2486
|
+
}
|
|
2487
|
+
seenCursors.add(nextCursor);
|
|
2488
|
+
cursor = nextCursor;
|
|
2489
|
+
} while (!this.stopping);
|
|
2490
|
+
this.modelCatalog = models;
|
|
2491
|
+
process.stdout.write(`已从 Codex App Server 读取 ${models.length} 个可用模型\n`);
|
|
2492
|
+
}
|
|
2493
|
+
catch (error) {
|
|
2494
|
+
if (this.stopping)
|
|
2495
|
+
return;
|
|
2496
|
+
process.stderr.write(`读取 Codex 模型目录失败,Web 将使用已有目录或兼容列表:${errorMessage(error)}\n`);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
async processThreadCommands() {
|
|
2500
|
+
let inventoryChanged = false;
|
|
2501
|
+
for (let processed = 0; processed < 10 && !this.stopping; processed += 1) {
|
|
2502
|
+
const command = await this.board.claimThreadCommand(this.runtimeInstanceId, this.stopController.signal);
|
|
2503
|
+
if (!command)
|
|
2504
|
+
break;
|
|
2505
|
+
try {
|
|
2506
|
+
const externalThreadId = await this.executeThreadCommand(command);
|
|
2507
|
+
await this.board.completeThreadCommand(this.runtimeInstanceId, command.id, { succeeded: true, externalThreadId }, this.stopController.signal);
|
|
2508
|
+
inventoryChanged = true;
|
|
2509
|
+
process.stdout.write(`Web Thread 指令已完成:${command.action} (${externalThreadId ?? command.id})\n`);
|
|
2510
|
+
}
|
|
2511
|
+
catch (error) {
|
|
2512
|
+
if (this.stopping)
|
|
2513
|
+
throw error;
|
|
2514
|
+
const message = redactHarnessText(errorMessage(error), 2_000);
|
|
2515
|
+
await this.board.completeThreadCommand(this.runtimeInstanceId, command.id, { succeeded: false, error: message }, this.stopController.signal);
|
|
2516
|
+
process.stderr.write(`Web Thread 指令 ${command.action} 失败:${message}\n`);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
return inventoryChanged;
|
|
2520
|
+
}
|
|
2521
|
+
async executeThreadCommand(command) {
|
|
2522
|
+
if (command.action === "create") {
|
|
2523
|
+
if (!this.configuration.enabled) {
|
|
2524
|
+
throw new Error("Bridge 已暂停,无法新建 Thread");
|
|
2525
|
+
}
|
|
2526
|
+
if (this.configuration.threadIdFilter) {
|
|
2527
|
+
throw new Error("固定 Thread 模式不支持从 Web 新建 Thread");
|
|
2528
|
+
}
|
|
2529
|
+
if (this.managedThreadIds.size >= this.configuration.maxThreads) {
|
|
2530
|
+
throw new Error("已达到 Bridge 的 Thread 数量上限");
|
|
2531
|
+
}
|
|
2532
|
+
const name = stringValue(command.name);
|
|
2533
|
+
if (!name)
|
|
2534
|
+
throw new Error("新建 Thread 指令缺少名称");
|
|
2535
|
+
const cwd = workingDirectoryForThreadCreate(command.directory_key, this.configuration.workingDirectories, this.configuration.workingDirectory);
|
|
2536
|
+
const model = stringValue(command.model);
|
|
2537
|
+
const reasoningEffort = stringValue(command.reasoning_effort);
|
|
2538
|
+
const response = await this.appServer.threadStart({
|
|
2539
|
+
cwd,
|
|
2540
|
+
...(model ? { model } : {}),
|
|
2541
|
+
...(reasoningEffort
|
|
2542
|
+
? { config: { model_reasoning_effort: reasoningEffort } }
|
|
2543
|
+
: {}),
|
|
2544
|
+
...threadPermissionOverrides(this.configuration.permissionMode, cwd),
|
|
2545
|
+
});
|
|
2546
|
+
const threadId = stringValue(response.thread?.id);
|
|
2547
|
+
if (!threadId)
|
|
2548
|
+
throw new Error("Codex App Server 未返回新 Thread ID");
|
|
2549
|
+
try {
|
|
2550
|
+
await this.appServer.threadSetName({ threadId, name });
|
|
2551
|
+
}
|
|
2552
|
+
catch (error) {
|
|
2553
|
+
// Thread creation already committed locally. Completing the command is
|
|
2554
|
+
// safer than retrying thread/start and producing a duplicate; the Board
|
|
2555
|
+
// keeps the requested display name even on older App Server versions.
|
|
2556
|
+
process.stderr.write(`新 Thread 已创建,但本机名称同步失败:${errorMessage(error)}\n`);
|
|
2557
|
+
}
|
|
2558
|
+
this.managedThreadIds.add(threadId);
|
|
2559
|
+
return threadId;
|
|
2560
|
+
}
|
|
2561
|
+
const threadId = stringValue(command.external_thread_id);
|
|
2562
|
+
const worker = threadId ? this.workers.get(threadId) : null;
|
|
2563
|
+
if (!threadId)
|
|
2564
|
+
throw new Error("Thread 指令缺少目标 ID");
|
|
2565
|
+
const managed = this.managedThreadIds.has(threadId);
|
|
2566
|
+
if (command.action === "rename") {
|
|
2567
|
+
if (!managed) {
|
|
2568
|
+
throw new Error("目标 Thread 不在当前 Bridge 的受管清单中");
|
|
2569
|
+
}
|
|
2570
|
+
const name = stringValue(command.name);
|
|
2571
|
+
if (!name)
|
|
2572
|
+
throw new Error("Thread 改名指令缺少名称");
|
|
2573
|
+
const model = stringValue(command.model);
|
|
2574
|
+
const reasoningEffort = stringValue(command.reasoning_effort);
|
|
2575
|
+
if (model || reasoningEffort) {
|
|
2576
|
+
const workspaceRoot = path.resolve(threadCwd(worker?.thread ?? { id: threadId }) ??
|
|
2577
|
+
this.configuration.workingDirectory);
|
|
2578
|
+
const resumed = await this.appServer.threadResume({
|
|
2579
|
+
threadId,
|
|
2580
|
+
excludeTurns: true,
|
|
2581
|
+
...(model ? { model } : {}),
|
|
2582
|
+
...(reasoningEffort
|
|
2583
|
+
? { config: { model_reasoning_effort: reasoningEffort } }
|
|
2584
|
+
: {}),
|
|
2585
|
+
...threadPermissionOverrides(this.configuration.permissionMode, workspaceRoot),
|
|
2586
|
+
});
|
|
2587
|
+
const effectiveModel = stringValue(resumed.model);
|
|
2588
|
+
const effectiveReasoningEffort = stringValue(resumed.reasoningEffort);
|
|
2589
|
+
if (model && effectiveModel !== model) {
|
|
2590
|
+
throw new Error(`Codex 未应用请求的模型 ${model}(实际:${effectiveModel ?? "未返回"})`);
|
|
2591
|
+
}
|
|
2592
|
+
if (reasoningEffort &&
|
|
2593
|
+
effectiveReasoningEffort !== reasoningEffort) {
|
|
2594
|
+
throw new Error(`Codex 未应用请求的思考强度 ${reasoningEffort}(实际:${effectiveReasoningEffort ?? "未返回"})`);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
await this.appServer.threadSetName({ threadId, name });
|
|
2598
|
+
return threadId;
|
|
2599
|
+
}
|
|
2600
|
+
if (this.configuration.threadIdFilter) {
|
|
2601
|
+
throw new Error("固定 Thread 模式不支持从 Web 删除 Thread");
|
|
2602
|
+
}
|
|
2603
|
+
// A delete may be reclaimed after the previous Bridge deleted the local
|
|
2604
|
+
// Thread but crashed before acknowledging the command. Absence from the
|
|
2605
|
+
// freshly synced managed inventory makes that replay a successful no-op.
|
|
2606
|
+
if (!managed && (command.attempt_count ?? 1) > 1)
|
|
2607
|
+
return threadId;
|
|
2608
|
+
if (!managed) {
|
|
2609
|
+
throw new Error("目标 Thread 不在当前 Bridge 的受管清单中");
|
|
2610
|
+
}
|
|
2611
|
+
if (worker?.retirementBlocked) {
|
|
2612
|
+
throw new WorkerRetirementDeferredError("Thread 正在启动或执行 turn,暂时不能删除");
|
|
2613
|
+
}
|
|
2614
|
+
if (worker) {
|
|
2615
|
+
await stopWorkersForRetirement([{ threadId, worker }], "用户从 Web Console 删除了 Codex Thread");
|
|
2616
|
+
this.workers.delete(threadId);
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
await this.appServer.threadDelete({ threadId });
|
|
2620
|
+
}
|
|
2621
|
+
catch (error) {
|
|
2622
|
+
if (!(error instanceof AppServerRpcError) || error.code !== -32601) {
|
|
2623
|
+
throw error;
|
|
2624
|
+
}
|
|
2625
|
+
// Older compatible Codex builds expose archive but not hard delete.
|
|
2626
|
+
await this.appServer.threadArchive({ threadId });
|
|
2627
|
+
}
|
|
2628
|
+
this.managedThreadIds.delete(threadId);
|
|
2629
|
+
return threadId;
|
|
2630
|
+
}
|
|
2631
|
+
async listThreads() {
|
|
2632
|
+
if (!this.configuration.enabled)
|
|
2633
|
+
return [];
|
|
2634
|
+
const threads = [];
|
|
2635
|
+
let cursor = null;
|
|
2636
|
+
let scanned = 0;
|
|
2637
|
+
do {
|
|
2638
|
+
const page = await this.appServer.threadList({
|
|
2639
|
+
cursor,
|
|
2640
|
+
limit: this.configuration.threadIdFilter
|
|
2641
|
+
? 100
|
|
2642
|
+
: Math.min(100, this.configuration.maxThreads - threads.length),
|
|
2643
|
+
sortKey: "recency_at",
|
|
2644
|
+
sortDirection: "desc",
|
|
2645
|
+
sourceKinds: THREAD_SOURCE_KINDS,
|
|
2646
|
+
archived: false,
|
|
2647
|
+
});
|
|
2648
|
+
scanned += page.data.length;
|
|
2649
|
+
for (const candidate of page.data) {
|
|
2650
|
+
const thread = candidate;
|
|
2651
|
+
if (!this.shouldManageThread(thread))
|
|
2652
|
+
continue;
|
|
2653
|
+
threads.push(thread);
|
|
2654
|
+
if (threads.length >= this.configuration.maxThreads)
|
|
2655
|
+
break;
|
|
2656
|
+
}
|
|
2657
|
+
cursor = page.nextCursor;
|
|
2658
|
+
} while (cursor &&
|
|
2659
|
+
threads.length < this.configuration.maxThreads &&
|
|
2660
|
+
scanned < 5_000 &&
|
|
2661
|
+
(!this.configuration.threadIdFilter ||
|
|
2662
|
+
(threads.length === 0 && scanned < 5_000)));
|
|
2663
|
+
if (this.configuration.threadIdFilter && threads.length === 0) {
|
|
2664
|
+
throw new Error(`找不到 CODEX_THREAD_ID=${this.configuration.threadIdFilter};请确认该 thread 属于当前系统用户`);
|
|
2665
|
+
}
|
|
2666
|
+
// App Server deliberately hides a Thread with no Turns from thread/list.
|
|
2667
|
+
// Successful Web creates are persisted locally and remain readable by id,
|
|
2668
|
+
// so merge those exact records into the authoritative inventory until the
|
|
2669
|
+
// first Turn makes them naturally discoverable.
|
|
2670
|
+
const discoveredIds = new Set(threads.map((thread) => thread.id));
|
|
2671
|
+
const createdThreadIds = await this.board.listCreatedThreadIds(this.stopController.signal);
|
|
2672
|
+
const recovered = await Promise.all(createdThreadIds
|
|
2673
|
+
.filter((threadId) => !discoveredIds.has(threadId))
|
|
2674
|
+
.map(async (threadId) => {
|
|
2675
|
+
try {
|
|
2676
|
+
const response = await this.appServer.threadRead({
|
|
2677
|
+
threadId,
|
|
2678
|
+
includeTurns: false,
|
|
2679
|
+
});
|
|
2680
|
+
const thread = response.thread;
|
|
2681
|
+
return this.shouldManageThread(thread) ? thread : null;
|
|
2682
|
+
}
|
|
2683
|
+
catch {
|
|
2684
|
+
// A locally deleted/archived create can outlive its audit command.
|
|
2685
|
+
return null;
|
|
2686
|
+
}
|
|
2687
|
+
}));
|
|
2688
|
+
const combined = [
|
|
2689
|
+
...threads,
|
|
2690
|
+
...recovered.filter((thread) => thread !== null),
|
|
2691
|
+
];
|
|
2692
|
+
combined.sort((left, right) => {
|
|
2693
|
+
const leftRecency = left.updatedAt ?? left.createdAt ?? 0;
|
|
2694
|
+
const rightRecency = right.updatedAt ?? right.createdAt ?? 0;
|
|
2695
|
+
return rightRecency - leftRecency;
|
|
2696
|
+
});
|
|
2697
|
+
return combined.slice(0, this.configuration.maxThreads);
|
|
2698
|
+
}
|
|
2699
|
+
shouldManageThread(thread) {
|
|
2700
|
+
if (this.configuration.threadIdFilter &&
|
|
2701
|
+
thread.id !== this.configuration.threadIdFilter) {
|
|
2702
|
+
return false;
|
|
2703
|
+
}
|
|
2704
|
+
if (stringValue(thread.parentThreadId))
|
|
2705
|
+
return false;
|
|
2706
|
+
if (!this.configuration.threadIdFilter &&
|
|
2707
|
+
this.configuration.threadScope === "cwd") {
|
|
2708
|
+
const cwd = threadCwd(thread);
|
|
2709
|
+
return Boolean(cwd &&
|
|
2710
|
+
managedDirectoryForWorkingDirectory(cwd, this.configuration.workingDirectories));
|
|
2711
|
+
}
|
|
2712
|
+
return true;
|
|
2713
|
+
}
|
|
2714
|
+
async handleServerRequest(request) {
|
|
2715
|
+
const threadId = threadIdFromMessage(request.params);
|
|
2716
|
+
const worker = threadId ? this.workers.get(threadId) : null;
|
|
2717
|
+
if (!worker) {
|
|
2718
|
+
if (request.method === "currentTime/read") {
|
|
2719
|
+
return { currentTimeAt: Math.floor(Date.now() / 1_000) };
|
|
2720
|
+
}
|
|
2721
|
+
throw new Error(`App Server request ${request.method} did not match a managed thread`);
|
|
2722
|
+
}
|
|
2723
|
+
return worker.handleServerRequest(request);
|
|
2724
|
+
}
|
|
2725
|
+
markFatal(error) {
|
|
2726
|
+
if (this.stopping)
|
|
2727
|
+
return;
|
|
2728
|
+
this.fatalError ??= error;
|
|
2729
|
+
void this.stop();
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
async function main() {
|
|
2733
|
+
const configuration = loadConfiguration();
|
|
2734
|
+
const appServer = await CodexAppServerClient.connect({
|
|
2735
|
+
binary: configuration.codexBinary,
|
|
2736
|
+
args: ["app-server", "--stdio"],
|
|
2737
|
+
// The App Server process is launched before any remote desired version is
|
|
2738
|
+
// fetched. Keep its process cwd tied to the immutable local startup value;
|
|
2739
|
+
// thread/start and every safe turn still receive the effective cwd
|
|
2740
|
+
// explicitly.
|
|
2741
|
+
cwd: configuration.localWorkingDirectory,
|
|
2742
|
+
unsetEnv: ["AI_TASK_BOARD_CONNECTION_TOKEN"],
|
|
2743
|
+
clientInfo: {
|
|
2744
|
+
name: "ai_task_board_bridge",
|
|
2745
|
+
title: "AI Task Board Bridge",
|
|
2746
|
+
version: BRIDGE_VERSION,
|
|
2747
|
+
},
|
|
2748
|
+
capabilities: { experimentalApi: true, requestAttestation: false },
|
|
2749
|
+
onStderr: (text) => process.stderr.write(text),
|
|
2750
|
+
onError: (error) => process.stderr.write(`Codex App Server:${error.message}\n`),
|
|
2751
|
+
});
|
|
2752
|
+
const bridge = new DeviceBridge(configuration, appServer);
|
|
2753
|
+
const requestStop = () => void bridge.stop();
|
|
2754
|
+
process.once("SIGINT", requestStop);
|
|
2755
|
+
process.once("SIGTERM", requestStop);
|
|
2756
|
+
try {
|
|
2757
|
+
await bridge.run();
|
|
2758
|
+
}
|
|
2759
|
+
finally {
|
|
2760
|
+
process.removeListener("SIGINT", requestStop);
|
|
2761
|
+
process.removeListener("SIGTERM", requestStop);
|
|
2762
|
+
await bridge.stop();
|
|
2763
|
+
await appServer.close();
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
export async function runBridgeCli() {
|
|
2767
|
+
await main();
|
|
2768
|
+
}
|
|
2769
|
+
//# sourceMappingURL=bridge.js.map
|