doer-agent 0.9.6 → 0.9.8
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/dist/agent-runtime-utils.js +0 -3
- package/dist/agent-thread-tags-rpc.js +150 -17
- package/dist/agent-thread-tags-rpc.test.js +57 -1
- package/dist/agent.js +1 -11
- package/dist/codex-app-server-client.js +141 -11
- package/dist/codex-app-server-client.test.js +91 -0
- package/dist/codex-app-server-manager.js +54 -2
- package/package.json +1 -1
|
@@ -28,9 +28,6 @@ export function buildAgentNotesRpcSubject(userId, agentId) {
|
|
|
28
28
|
export function buildAgentNotesAiRpcSubject(userId, agentId) {
|
|
29
29
|
return `doer.agent.notes.ai.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
30
30
|
}
|
|
31
|
-
export function buildAgentThreadTagsRpcSubject(userId, agentId) {
|
|
32
|
-
return `doer.agent.thread.tags.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
33
|
-
}
|
|
34
31
|
export function buildAgentDaemonRpcSubject(userId, agentId) {
|
|
35
32
|
return `doer.agent.daemon.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
36
33
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { StringCodec } from "nats";
|
|
5
5
|
import { runCodexTextTask } from "./codex-text-task.js";
|
|
@@ -9,6 +9,8 @@ const MAX_THREADS_PER_REBUILD = 2_000;
|
|
|
9
9
|
const MAX_TAGS = 12;
|
|
10
10
|
const MIN_TAGS = 2;
|
|
11
11
|
const OTHER_TAG_ID = "other";
|
|
12
|
+
const MAX_REPOSITORIES = 200;
|
|
13
|
+
const MAX_RELATED_REPOS = 3;
|
|
12
14
|
const DEFAULT_TAGS = [
|
|
13
15
|
{ id: "deployment", label: "Deployment", description: "Deploy, release, publish, production rollout, or app-store delivery.", color: "#8b5cf6" },
|
|
14
16
|
{ id: "bug", label: "Bug · diagnosis", description: "Debugging, errors, failures, regressions, diagnosis, or fixes.", color: "#ef4444" },
|
|
@@ -55,6 +57,70 @@ function tagsFilePath(workspaceRoot, threadId) {
|
|
|
55
57
|
function configFilePath(workspaceRoot) {
|
|
56
58
|
return path.join(workspaceRoot, ".doer-agent", "thread-tags", "config.json");
|
|
57
59
|
}
|
|
60
|
+
async function isGitRepository(directoryPath) {
|
|
61
|
+
try {
|
|
62
|
+
const gitEntry = await stat(path.join(directoryPath, ".git"));
|
|
63
|
+
return gitEntry.isDirectory() || gitEntry.isFile();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function repositoryCandidate(workspaceRoot, absolutePath) {
|
|
70
|
+
const relativePath = path.relative(workspaceRoot, absolutePath).split(path.sep).join("/") || ".";
|
|
71
|
+
return {
|
|
72
|
+
id: relativePath,
|
|
73
|
+
label: path.basename(absolutePath),
|
|
74
|
+
relativePath,
|
|
75
|
+
absolutePath,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export async function discoverWorkspaceRepositories(workspaceRoot) {
|
|
79
|
+
const resolvedRoot = path.resolve(workspaceRoot);
|
|
80
|
+
const candidates = [];
|
|
81
|
+
if (await isGitRepository(resolvedRoot)) {
|
|
82
|
+
candidates.push(repositoryCandidate(resolvedRoot, resolvedRoot));
|
|
83
|
+
}
|
|
84
|
+
const entries = await readdir(resolvedRoot, { withFileTypes: true }).catch(() => []);
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (candidates.length >= MAX_REPOSITORIES ||
|
|
87
|
+
!entry.isDirectory() ||
|
|
88
|
+
entry.name.startsWith(".")) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const absolutePath = path.join(resolvedRoot, entry.name);
|
|
92
|
+
if (await isGitRepository(absolutePath)) {
|
|
93
|
+
candidates.push(repositoryCandidate(resolvedRoot, absolutePath));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return candidates.sort((left, right) => (left.label.localeCompare(right.label) || left.id.localeCompare(right.id)));
|
|
97
|
+
}
|
|
98
|
+
function repositoryFingerprint(repositories) {
|
|
99
|
+
return crypto.createHash("sha256").update(JSON.stringify(repositories.map((repository) => ({
|
|
100
|
+
id: repository.id,
|
|
101
|
+
label: repository.label,
|
|
102
|
+
relativePath: repository.relativePath,
|
|
103
|
+
})))).digest("hex");
|
|
104
|
+
}
|
|
105
|
+
function pathIsWithin(candidatePath, targetPath) {
|
|
106
|
+
const relative = path.relative(candidatePath, targetPath);
|
|
107
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
108
|
+
}
|
|
109
|
+
export function relatedReposFromCwd(cwd, repositories) {
|
|
110
|
+
if (!cwd) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const resolvedCwd = path.resolve(cwd);
|
|
114
|
+
const repository = repositories
|
|
115
|
+
.filter((candidate) => pathIsWithin(candidate.absolutePath, resolvedCwd))
|
|
116
|
+
.sort((left, right) => right.absolutePath.length - left.absolutePath.length)[0];
|
|
117
|
+
return repository ? [{
|
|
118
|
+
id: repository.id,
|
|
119
|
+
label: repository.label,
|
|
120
|
+
confidence: 1,
|
|
121
|
+
source: "cwd",
|
|
122
|
+
}] : [];
|
|
123
|
+
}
|
|
58
124
|
function normalizeInputs(value, limit) {
|
|
59
125
|
const rows = Array.isArray(value) ? value.slice(0, limit) : [];
|
|
60
126
|
const seen = new Set();
|
|
@@ -191,9 +257,10 @@ function jsonObjectFromText(text) {
|
|
|
191
257
|
}
|
|
192
258
|
return payload;
|
|
193
259
|
}
|
|
194
|
-
export function parseThreadTagClassificationResponse(text, requestedThreadIds, allowedTagIds = new Set(DEFAULT_TAGS.map((tag) => tag.id))) {
|
|
260
|
+
export function parseThreadTagClassificationResponse(text, requestedThreadIds, allowedTagIds = new Set(DEFAULT_TAGS.map((tag) => tag.id)), repositories = []) {
|
|
195
261
|
const payload = jsonObjectFromText(text);
|
|
196
262
|
const assignments = Array.isArray(payload.assignments) ? payload.assignments : [];
|
|
263
|
+
const repositoriesById = new Map(repositories.map((repository) => [repository.id, repository]));
|
|
197
264
|
const seen = new Set();
|
|
198
265
|
const results = [];
|
|
199
266
|
for (const assignmentValue of assignments) {
|
|
@@ -207,11 +274,30 @@ export function parseThreadTagClassificationResponse(text, requestedThreadIds, a
|
|
|
207
274
|
continue;
|
|
208
275
|
}
|
|
209
276
|
seen.add(threadId);
|
|
277
|
+
const seenRepositoryIds = new Set();
|
|
278
|
+
const relatedRepos = (Array.isArray(assignment?.relatedRepos) ? assignment.relatedRepos : [])
|
|
279
|
+
.flatMap((value) => {
|
|
280
|
+
const row = recordValue(value);
|
|
281
|
+
const id = stringValue(row?.id) || stringValue(value);
|
|
282
|
+
const repository = repositoriesById.get(id);
|
|
283
|
+
if (!repository || seenRepositoryIds.has(id)) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
seenRepositoryIds.add(id);
|
|
287
|
+
return [{
|
|
288
|
+
id: repository.id,
|
|
289
|
+
label: repository.label,
|
|
290
|
+
confidence: boundedConfidence(row?.confidence),
|
|
291
|
+
source: "ai",
|
|
292
|
+
}];
|
|
293
|
+
})
|
|
294
|
+
.slice(0, MAX_RELATED_REPOS);
|
|
210
295
|
results.push({
|
|
211
296
|
threadId,
|
|
212
297
|
activityTag,
|
|
213
298
|
confidence: boundedConfidence(assignment?.confidence),
|
|
214
299
|
source: "ai",
|
|
300
|
+
relatedRepos,
|
|
215
301
|
});
|
|
216
302
|
}
|
|
217
303
|
return results;
|
|
@@ -219,41 +305,61 @@ export function parseThreadTagClassificationResponse(text, requestedThreadIds, a
|
|
|
219
305
|
export function parseSuggestedThreadTags(text) {
|
|
220
306
|
return normalizeTagDefinitions(jsonObjectFromText(text).tags);
|
|
221
307
|
}
|
|
222
|
-
async function readStoredTags(workspaceRoot, input, config) {
|
|
308
|
+
async function readStoredTags(workspaceRoot, input, config, repositories) {
|
|
223
309
|
try {
|
|
224
310
|
const row = recordValue(JSON.parse(await readFile(tagsFilePath(workspaceRoot, input.threadId), "utf8")));
|
|
225
311
|
const activityTag = sanitizeTagId(stringValue(row?.activityTag));
|
|
226
312
|
const allowedTagIds = new Set(config.tags.map((tag) => tag.id));
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
313
|
+
const isCurrentCache = row?.version === 3 &&
|
|
314
|
+
stringValue(row.configFingerprint) === config.fingerprint &&
|
|
315
|
+
stringValue(row.repositoryFingerprint) === repositoryFingerprint(repositories);
|
|
316
|
+
if (!isCurrentCache ||
|
|
230
317
|
stringValue(row?.threadId) !== input.threadId ||
|
|
231
318
|
stringValue(row?.contentFingerprint) !== contentFingerprint(input) ||
|
|
232
319
|
!allowedTagIds.has(activityTag)) {
|
|
233
320
|
return null;
|
|
234
321
|
}
|
|
322
|
+
const repositoriesById = new Map(repositories.map((repository) => [repository.id, repository]));
|
|
323
|
+
const relatedRepos = (Array.isArray(row?.relatedRepos) ? row.relatedRepos : [])
|
|
324
|
+
.flatMap((value) => {
|
|
325
|
+
const stored = recordValue(value);
|
|
326
|
+
const repository = repositoriesById.get(stringValue(stored?.id));
|
|
327
|
+
if (!repository) {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
return [{
|
|
331
|
+
id: repository.id,
|
|
332
|
+
label: repository.label,
|
|
333
|
+
confidence: boundedConfidence(stored?.confidence),
|
|
334
|
+
source: stored?.source === "cwd" ? "cwd" : "ai",
|
|
335
|
+
}];
|
|
336
|
+
})
|
|
337
|
+
.slice(0, MAX_RELATED_REPOS);
|
|
235
338
|
return {
|
|
236
339
|
threadId: input.threadId,
|
|
237
340
|
activityTag,
|
|
238
341
|
confidence: boundedConfidence(row?.confidence),
|
|
239
342
|
source: "ai",
|
|
343
|
+
relatedRepos,
|
|
240
344
|
};
|
|
241
345
|
}
|
|
242
346
|
catch {
|
|
243
347
|
return null;
|
|
244
348
|
}
|
|
245
349
|
}
|
|
246
|
-
async function writeStoredTags(workspaceRoot, input, classification, config) {
|
|
350
|
+
async function writeStoredTags(workspaceRoot, input, classification, config, repositories) {
|
|
247
351
|
const filePath = tagsFilePath(workspaceRoot, input.threadId);
|
|
248
352
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
249
353
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
250
354
|
const payload = {
|
|
251
|
-
version:
|
|
355
|
+
version: 3,
|
|
252
356
|
threadId: input.threadId,
|
|
253
357
|
contentFingerprint: contentFingerprint(input),
|
|
254
358
|
configFingerprint: config.fingerprint,
|
|
359
|
+
repositoryFingerprint: repositoryFingerprint(repositories),
|
|
255
360
|
activityTag: classification.activityTag,
|
|
256
361
|
confidence: classification.confidence,
|
|
362
|
+
relatedRepos: classification.relatedRepos,
|
|
257
363
|
source: "ai",
|
|
258
364
|
updatedAt: new Date().toISOString(),
|
|
259
365
|
};
|
|
@@ -263,15 +369,23 @@ async function writeStoredTags(workspaceRoot, input, classification, config) {
|
|
|
263
369
|
function configuredTagsPrompt(tags) {
|
|
264
370
|
return tags.map((tag) => `- ${tag.id} (${tag.label}): ${tag.description}`);
|
|
265
371
|
}
|
|
266
|
-
function classifierPrompt(inputs, tags) {
|
|
372
|
+
function classifierPrompt(inputs, tags, repositories) {
|
|
267
373
|
return [
|
|
268
374
|
"You classify Codex threads inside Doer.",
|
|
269
375
|
"Return only one valid JSON object. Do not use Markdown fences or add explanations.",
|
|
270
376
|
"Classify every input thread into exactly one activityTag from the configured list.",
|
|
271
377
|
"Configured activityTag values:",
|
|
272
378
|
...configuredTagsPrompt(tags),
|
|
273
|
-
"
|
|
274
|
-
|
|
379
|
+
"Also select zero to three relatedRepos for each thread from the repository candidates below.",
|
|
380
|
+
"A related repository is a code repository that the thread discusses, inspects, modifies, tests, deploys, or otherwise directly works on.",
|
|
381
|
+
"Do not invent repository ids and do not select a repository merely because it shares the agent workspace.",
|
|
382
|
+
`Repository candidates: ${JSON.stringify(repositories.map((repository) => ({
|
|
383
|
+
id: repository.id,
|
|
384
|
+
label: repository.label,
|
|
385
|
+
relativePath: repository.relativePath,
|
|
386
|
+
})))}`,
|
|
387
|
+
"Use label and preview as the main evidence. Use cwd as strong supporting repository evidence.",
|
|
388
|
+
`Output shape: {"assignments":[{"threadId":"...","activityTag":"${tags[0]?.id ?? OTHER_TAG_ID}","confidence":0.9,"relatedRepos":[{"id":"repository-id","confidence":0.9}]}]}`,
|
|
275
389
|
"",
|
|
276
390
|
JSON.stringify({ threads: inputs }),
|
|
277
391
|
].join("\n");
|
|
@@ -284,7 +398,7 @@ function suggestionPrompt(inputs, candidateTags) {
|
|
|
284
398
|
"Every tag needs: a stable lowercase kebab-case id, a concise label, a one-sentence classification description, and a distinct #RRGGBB color.",
|
|
285
399
|
"Write labels and descriptions in the predominant language of the supplied threads. Use Korean when the threads are predominantly Korean.",
|
|
286
400
|
`Always include "${OTHER_TAG_ID}" as the final fallback tag.`,
|
|
287
|
-
"Avoid tags for
|
|
401
|
+
"Avoid tags for repositories or running status; those are managed separately.",
|
|
288
402
|
"Prefer work-intent categories that will remain useful for future threads.",
|
|
289
403
|
'Output shape: {"tags":[{"id":"feature","label":"Feature","description":"Implementation and product changes.","color":"#22c55e"}]}',
|
|
290
404
|
candidateTags?.length
|
|
@@ -299,12 +413,30 @@ function suggestionPrompt(inputs, candidateTags) {
|
|
|
299
413
|
})}` : "",
|
|
300
414
|
].filter(Boolean).join("\n");
|
|
301
415
|
}
|
|
416
|
+
function withCwdRelatedRepos(classification, input, repositories) {
|
|
417
|
+
const merged = new Map();
|
|
418
|
+
for (const relatedRepo of classification.relatedRepos) {
|
|
419
|
+
merged.set(relatedRepo.id, relatedRepo);
|
|
420
|
+
}
|
|
421
|
+
for (const relatedRepo of relatedReposFromCwd(input.cwd, repositories)) {
|
|
422
|
+
merged.set(relatedRepo.id, relatedRepo);
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
...classification,
|
|
426
|
+
relatedRepos: [...merged.values()]
|
|
427
|
+
.sort((left, right) => (Number(right.source === "cwd") - Number(left.source === "cwd")
|
|
428
|
+
|| right.confidence - left.confidence
|
|
429
|
+
|| left.label.localeCompare(right.label)))
|
|
430
|
+
.slice(0, MAX_RELATED_REPOS),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
302
433
|
async function classifyThreads(args) {
|
|
303
434
|
const config = await readConfig(args.workspaceRoot);
|
|
435
|
+
const repositories = await discoverWorkspaceRepositories(args.workspaceRoot);
|
|
304
436
|
const classifications = new Map();
|
|
305
437
|
const staleInputs = [];
|
|
306
438
|
for (const input of args.inputs) {
|
|
307
|
-
const stored = args.force ? null : await readStoredTags(args.workspaceRoot, input, config);
|
|
439
|
+
const stored = args.force ? null : await readStoredTags(args.workspaceRoot, input, config, repositories);
|
|
308
440
|
if (stored) {
|
|
309
441
|
classifications.set(input.threadId, stored);
|
|
310
442
|
}
|
|
@@ -317,17 +449,18 @@ async function classifyThreads(args) {
|
|
|
317
449
|
try {
|
|
318
450
|
const resultText = await runCodexTextTask({
|
|
319
451
|
manager: args.manager,
|
|
320
|
-
prompt: classifierPrompt(staleInputs, config.tags),
|
|
452
|
+
prompt: classifierPrompt(staleInputs, config.tags, repositories),
|
|
321
453
|
});
|
|
322
|
-
const resolved = parseThreadTagClassificationResponse(resultText, new Set(staleInputs.map((input) => input.threadId)), new Set(config.tags.map((tag) => tag.id)));
|
|
454
|
+
const resolved = parseThreadTagClassificationResponse(resultText, new Set(staleInputs.map((input) => input.threadId)), new Set(config.tags.map((tag) => tag.id)), repositories);
|
|
323
455
|
const inputsById = new Map(staleInputs.map((input) => [input.threadId, input]));
|
|
324
456
|
await Promise.all(resolved.map(async (classification) => {
|
|
325
457
|
const input = inputsById.get(classification.threadId);
|
|
326
458
|
if (!input) {
|
|
327
459
|
return;
|
|
328
460
|
}
|
|
329
|
-
|
|
330
|
-
|
|
461
|
+
const completedClassification = withCwdRelatedRepos(classification, input, repositories);
|
|
462
|
+
await writeStoredTags(args.workspaceRoot, input, completedClassification, config, repositories);
|
|
463
|
+
classifications.set(classification.threadId, completedClassification);
|
|
331
464
|
}));
|
|
332
465
|
if (resolved.length !== staleInputs.length) {
|
|
333
466
|
classificationError = `AI classified ${resolved.length} of ${staleInputs.length} threads`;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import test from "node:test";
|
|
3
|
-
import { parseSuggestedThreadTags, parseThreadTagClassificationResponse, } from "./agent-thread-tags-rpc.js";
|
|
6
|
+
import { discoverWorkspaceRepositories, parseSuggestedThreadTags, parseThreadTagClassificationResponse, relatedReposFromCwd, } from "./agent-thread-tags-rpc.js";
|
|
4
7
|
test("parses only requested valid thread tag assignments", () => {
|
|
5
8
|
const result = parseThreadTagClassificationResponse(JSON.stringify({
|
|
6
9
|
assignments: [
|
|
@@ -14,6 +17,7 @@ test("parses only requested valid thread tag assignments", () => {
|
|
|
14
17
|
activityTag: "feature",
|
|
15
18
|
confidence: 0.91,
|
|
16
19
|
source: "ai",
|
|
20
|
+
relatedRepos: [],
|
|
17
21
|
}]);
|
|
18
22
|
});
|
|
19
23
|
test("accepts fenced JSON and clamps confidence", () => {
|
|
@@ -24,6 +28,58 @@ test("accepts classifications from a custom configured range", () => {
|
|
|
24
28
|
const result = parseThreadTagClassificationResponse("{\"assignments\":[{\"threadId\":\"a\",\"activityTag\":\"customer-request\",\"confidence\":0.8}]}", new Set(["a"]), new Set(["customer-request", "other"]));
|
|
25
29
|
assert.equal(result[0]?.activityTag, "customer-request");
|
|
26
30
|
});
|
|
31
|
+
test("accepts only configured related repository ids", () => {
|
|
32
|
+
const repositories = [{
|
|
33
|
+
id: "doer",
|
|
34
|
+
label: "doer",
|
|
35
|
+
relativePath: "doer",
|
|
36
|
+
absolutePath: "/workspace/doer",
|
|
37
|
+
}];
|
|
38
|
+
const result = parseThreadTagClassificationResponse(JSON.stringify({
|
|
39
|
+
assignments: [{
|
|
40
|
+
threadId: "a",
|
|
41
|
+
activityTag: "feature",
|
|
42
|
+
confidence: 0.9,
|
|
43
|
+
relatedRepos: [
|
|
44
|
+
{ id: "doer", confidence: 0.82 },
|
|
45
|
+
{ id: "invented", confidence: 1 },
|
|
46
|
+
],
|
|
47
|
+
}],
|
|
48
|
+
}), new Set(["a"]), new Set(["feature", "other"]), repositories);
|
|
49
|
+
assert.deepEqual(result[0]?.relatedRepos, [{
|
|
50
|
+
id: "doer",
|
|
51
|
+
label: "doer",
|
|
52
|
+
confidence: 0.82,
|
|
53
|
+
source: "ai",
|
|
54
|
+
}]);
|
|
55
|
+
});
|
|
56
|
+
test("uses the most specific repository containing cwd", () => {
|
|
57
|
+
const repositories = [
|
|
58
|
+
{ id: ".", label: "workspace", relativePath: ".", absolutePath: "/workspace" },
|
|
59
|
+
{ id: "doer", label: "doer", relativePath: "doer", absolutePath: "/workspace/doer" },
|
|
60
|
+
];
|
|
61
|
+
assert.deepEqual(relatedReposFromCwd("/workspace/doer/src", repositories), [{
|
|
62
|
+
id: "doer",
|
|
63
|
+
label: "doer",
|
|
64
|
+
confidence: 1,
|
|
65
|
+
source: "cwd",
|
|
66
|
+
}]);
|
|
67
|
+
});
|
|
68
|
+
test("discovers the workspace root and immediate child git repositories", async () => {
|
|
69
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "doer-thread-repos-"));
|
|
70
|
+
try {
|
|
71
|
+
await mkdir(path.join(root, ".git"));
|
|
72
|
+
await mkdir(path.join(root, "child", ".git"), { recursive: true });
|
|
73
|
+
await mkdir(path.join(root, "not-a-repo"), { recursive: true });
|
|
74
|
+
await mkdir(path.join(root, "worktree"), { recursive: true });
|
|
75
|
+
await writeFile(path.join(root, "worktree", ".git"), "gitdir: ../actual\n", "utf8");
|
|
76
|
+
const repositories = await discoverWorkspaceRepositories(root);
|
|
77
|
+
assert.deepEqual(repositories.map((repository) => repository.id).sort(), [".", "child", "worktree"]);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
27
83
|
test("normalizes AI suggested tags and always adds other", () => {
|
|
28
84
|
const result = parseSuggestedThreadTags(JSON.stringify({
|
|
29
85
|
tags: [
|
package/dist/agent.js
CHANGED
|
@@ -7,7 +7,6 @@ import { handleFsRpcMessage } from "./agent-fs-rpc.js";
|
|
|
7
7
|
import { handleGitRpcMessage } from "./agent-git-rpc.js";
|
|
8
8
|
import { handleNotesRpcMessage } from "./agent-notes-rpc.js";
|
|
9
9
|
import { subscribeToNotesAiRpc } from "./agent-notes-ai-rpc.js";
|
|
10
|
-
import { subscribeToThreadTagsRpc } from "./agent-thread-tags-rpc.js";
|
|
11
10
|
import { ensureBundledDoerSkills } from "./agent-bundled-skills.js";
|
|
12
11
|
import { subscribeToCodexAppRpc } from "./agent-codex-app-rpc.js";
|
|
13
12
|
import { createCodexAppServerManager } from "./codex-app-server-manager.js";
|
|
@@ -19,7 +18,7 @@ import { subscribeToSkillRpc } from "./agent-skill-rpc.js";
|
|
|
19
18
|
import { subscribeToMaintenanceRpc } from "./agent-maintenance-rpc.js";
|
|
20
19
|
import { subscribeToHttpProxyRpc } from "./agent-http-proxy-rpc.js";
|
|
21
20
|
import { sendSignalToTaskProcess } from "./agent-task-execution.js";
|
|
22
|
-
import { buildAgentCodexAppEventsSubject, buildAgentCodexAppRpcSubject, buildAgentDaemonRpcSubject, buildAgentFsRpcSubject, buildAgentGitRpcSubject, buildAgentHttpProxyRpcSubject, buildAgentMaintenanceRpcSubject, buildAgentNotesRpcSubject, buildAgentNotesAiRpcSubject,
|
|
21
|
+
import { buildAgentCodexAppEventsSubject, buildAgentCodexAppRpcSubject, buildAgentDaemonRpcSubject, buildAgentFsRpcSubject, buildAgentGitRpcSubject, buildAgentHttpProxyRpcSubject, buildAgentMaintenanceRpcSubject, buildAgentNotesRpcSubject, buildAgentNotesAiRpcSubject, buildAgentSettingsRpcSubject, buildAgentSkillRpcSubject, formatLocalTimestamp, parseArgs, resolveAgentVersion, resolveArgOrEnv, resolveContainerReachableServerBaseUrl, sanitizeUserId, sleep, } from "./agent-runtime-utils.js";
|
|
23
22
|
import { createRuntimeEnvHelpers } from "./agent-runtime-env.js";
|
|
24
23
|
import { createEventPersistenceHelpers, heartbeatAgentSession, postJson, } from "./agent-runtime-io.js";
|
|
25
24
|
import { handleSettingsRpcMessage } from "./agent-settings-rpc.js";
|
|
@@ -310,15 +309,6 @@ async function main() {
|
|
|
310
309
|
agentId: initialAgentId,
|
|
311
310
|
codexAppServerManager,
|
|
312
311
|
});
|
|
313
|
-
subscribeToThreadTagsRpc({
|
|
314
|
-
nc: jetstream.nc,
|
|
315
|
-
subject: buildAgentThreadTagsRpcSubject(userId, initialAgentId),
|
|
316
|
-
agentId: initialAgentId,
|
|
317
|
-
workspaceRoot: resolveWorkspaceRoot(),
|
|
318
|
-
manager: codexAppServerManager,
|
|
319
|
-
onInfo: writeAgentInfo,
|
|
320
|
-
onError: writeAgentError,
|
|
321
|
-
});
|
|
322
312
|
subscribeToDaemonRpc({
|
|
323
313
|
nc: jetstream.nc,
|
|
324
314
|
subject: buildAgentDaemonRpcSubject(userId, initialAgentId),
|
|
@@ -12,6 +12,16 @@ function resolveCodexCliBinPath() {
|
|
|
12
12
|
}
|
|
13
13
|
return path.resolve(path.dirname(packageJsonPath), codexBin);
|
|
14
14
|
}
|
|
15
|
+
export class CodexAppServerRequestError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
data;
|
|
18
|
+
constructor(message, code = -32603, data) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.data = data;
|
|
22
|
+
this.name = "CodexAppServerRequestError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
export class CodexAppServerClient {
|
|
16
26
|
options;
|
|
17
27
|
child = null;
|
|
@@ -20,10 +30,15 @@ export class CodexAppServerClient {
|
|
|
20
30
|
startPromise = null;
|
|
21
31
|
stopPromise = null;
|
|
22
32
|
pending = new Map();
|
|
33
|
+
activeTurns = new Map();
|
|
34
|
+
activeTurnsChanged = new Set();
|
|
23
35
|
constructor(options) {
|
|
24
36
|
this.options = options;
|
|
25
37
|
}
|
|
26
38
|
async request(method, params, timeoutMs) {
|
|
39
|
+
if (this.stopPromise) {
|
|
40
|
+
throw new Error("Codex app-server is stopping");
|
|
41
|
+
}
|
|
27
42
|
await this.start();
|
|
28
43
|
return await this.requestStarted(method, params, timeoutMs);
|
|
29
44
|
}
|
|
@@ -68,7 +83,9 @@ export class CodexAppServerClient {
|
|
|
68
83
|
}
|
|
69
84
|
}
|
|
70
85
|
async startInner() {
|
|
71
|
-
|
|
86
|
+
const executable = this.options.executable ?? process.execPath;
|
|
87
|
+
const executableArgs = this.options.executableArgs ?? [resolveCodexCliBinPath()];
|
|
88
|
+
this.child = spawn(executable, [...executableArgs, ...this.options.args], {
|
|
72
89
|
cwd: this.options.cwd,
|
|
73
90
|
detached: process.platform !== "win32",
|
|
74
91
|
env: this.options.env,
|
|
@@ -91,6 +108,8 @@ export class CodexAppServerClient {
|
|
|
91
108
|
removeExitHooks();
|
|
92
109
|
this.signalProcessGroup(childPid, "SIGTERM");
|
|
93
110
|
this.rejectPending(new Error("Codex app-server exited"));
|
|
111
|
+
this.activeTurns.clear();
|
|
112
|
+
this.notifyActiveTurnsChanged();
|
|
94
113
|
this.stdoutLines?.close();
|
|
95
114
|
this.stdoutLines = null;
|
|
96
115
|
this.child = null;
|
|
@@ -144,6 +163,16 @@ export class CodexAppServerClient {
|
|
|
144
163
|
return;
|
|
145
164
|
}
|
|
146
165
|
const record = message;
|
|
166
|
+
if (typeof record.method === "string") {
|
|
167
|
+
if (this.isRequestId(record.id)) {
|
|
168
|
+
void this.handleServerRequest(record.id, record.method, record.params);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
this.trackTurnNotification(record.method, record.params);
|
|
172
|
+
this.options.onNotification?.(record.method, record.params);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
147
176
|
if (record.id !== undefined) {
|
|
148
177
|
const id = Number(record.id);
|
|
149
178
|
const pending = Number.isInteger(id) ? this.pending.get(id) : null;
|
|
@@ -161,8 +190,71 @@ export class CodexAppServerClient {
|
|
|
161
190
|
}
|
|
162
191
|
return;
|
|
163
192
|
}
|
|
164
|
-
|
|
165
|
-
|
|
193
|
+
}
|
|
194
|
+
isRequestId(value) {
|
|
195
|
+
return (typeof value === "string" && value.length > 0)
|
|
196
|
+
|| (typeof value === "number" && Number.isFinite(value));
|
|
197
|
+
}
|
|
198
|
+
async handleServerRequest(id, method, params) {
|
|
199
|
+
try {
|
|
200
|
+
if (!this.options.onServerRequest) {
|
|
201
|
+
throw new CodexAppServerRequestError(`Unsupported Codex app-server request: ${method}`, -32601);
|
|
202
|
+
}
|
|
203
|
+
const result = await this.options.onServerRequest(method, params);
|
|
204
|
+
this.writeMessage({ id, result });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const requestError = error instanceof CodexAppServerRequestError
|
|
208
|
+
? error
|
|
209
|
+
: new CodexAppServerRequestError(error instanceof Error ? error.message : String(error));
|
|
210
|
+
this.options.onLog?.(`[codex-app-server] server request failed method=${method} error=${requestError.message}`);
|
|
211
|
+
this.writeMessage({
|
|
212
|
+
id,
|
|
213
|
+
error: {
|
|
214
|
+
code: requestError.code,
|
|
215
|
+
message: requestError.message,
|
|
216
|
+
...(requestError.data === undefined ? {} : { data: requestError.data }),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
writeMessage(message) {
|
|
222
|
+
const child = this.child;
|
|
223
|
+
if (!child || child.killed || child.stdin.destroyed) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
child.stdin.write(`${JSON.stringify(message)}\n`, "utf8");
|
|
227
|
+
}
|
|
228
|
+
trackTurnNotification(method, params) {
|
|
229
|
+
if (method !== "turn/started" && method !== "turn/completed" && method !== "turn/interrupted") {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const record = params && typeof params === "object" && !Array.isArray(params)
|
|
233
|
+
? params
|
|
234
|
+
: null;
|
|
235
|
+
const turn = record?.turn && typeof record.turn === "object" && !Array.isArray(record.turn)
|
|
236
|
+
? record.turn
|
|
237
|
+
: null;
|
|
238
|
+
const threadId = typeof record?.threadId === "string" ? record.threadId : "";
|
|
239
|
+
const turnId = typeof turn?.id === "string"
|
|
240
|
+
? turn.id
|
|
241
|
+
: typeof record?.turnId === "string"
|
|
242
|
+
? record.turnId
|
|
243
|
+
: "";
|
|
244
|
+
if (!threadId || !turnId) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (method === "turn/started") {
|
|
248
|
+
this.activeTurns.set(threadId, turnId);
|
|
249
|
+
}
|
|
250
|
+
else if (this.activeTurns.get(threadId) === turnId) {
|
|
251
|
+
this.activeTurns.delete(threadId);
|
|
252
|
+
}
|
|
253
|
+
this.notifyActiveTurnsChanged();
|
|
254
|
+
}
|
|
255
|
+
notifyActiveTurnsChanged() {
|
|
256
|
+
for (const listener of this.activeTurnsChanged) {
|
|
257
|
+
listener();
|
|
166
258
|
}
|
|
167
259
|
}
|
|
168
260
|
rejectPending(error) {
|
|
@@ -173,6 +265,7 @@ export class CodexAppServerClient {
|
|
|
173
265
|
}
|
|
174
266
|
}
|
|
175
267
|
async stopChild(child) {
|
|
268
|
+
await this.interruptActiveTurns();
|
|
176
269
|
const pid = child.pid;
|
|
177
270
|
if (!pid) {
|
|
178
271
|
child.kill("SIGTERM");
|
|
@@ -189,6 +282,42 @@ export class CodexAppServerClient {
|
|
|
189
282
|
this.signalProcessGroup(pid, "SIGTERM");
|
|
190
283
|
}
|
|
191
284
|
}
|
|
285
|
+
async interruptActiveTurns() {
|
|
286
|
+
const turns = [...this.activeTurns.entries()].map(([threadId, turnId]) => ({ threadId, turnId }));
|
|
287
|
+
if (turns.length === 0) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.options.onLog?.(`[codex-app-server] interrupting ${turns.length} active turn(s) before shutdown`);
|
|
291
|
+
await Promise.allSettled(turns.map(({ threadId, turnId }) => this.requestStarted("turn/interrupt", { threadId, turnId }, 5_000)));
|
|
292
|
+
const timeoutMs = this.options.gracefulShutdownTimeoutMs ?? 10_000;
|
|
293
|
+
const drained = await this.waitForTurnsToFinish(turns, timeoutMs);
|
|
294
|
+
if (!drained) {
|
|
295
|
+
this.options.onLog?.(`[codex-app-server] timed out waiting for active turns to finish before shutdown`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async waitForTurnsToFinish(turns, timeoutMs) {
|
|
299
|
+
const isFinished = () => turns.every(({ threadId, turnId }) => this.activeTurns.get(threadId) !== turnId);
|
|
300
|
+
if (isFinished()) {
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
return await new Promise((resolve) => {
|
|
304
|
+
const onChanged = () => {
|
|
305
|
+
if (isFinished()) {
|
|
306
|
+
cleanup();
|
|
307
|
+
resolve(true);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
cleanup();
|
|
312
|
+
resolve(false);
|
|
313
|
+
}, timeoutMs);
|
|
314
|
+
const cleanup = () => {
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
this.activeTurnsChanged.delete(onChanged);
|
|
317
|
+
};
|
|
318
|
+
this.activeTurnsChanged.add(onChanged);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
192
321
|
registerProcessExitHooks(pid) {
|
|
193
322
|
if (!pid || process.platform === "win32") {
|
|
194
323
|
return () => { };
|
|
@@ -211,14 +340,15 @@ export class CodexAppServerClient {
|
|
|
211
340
|
process.once("exit", cleanup);
|
|
212
341
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
213
342
|
const handler = () => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
343
|
+
void this.stop().finally(() => {
|
|
344
|
+
remove();
|
|
345
|
+
try {
|
|
346
|
+
process.kill(process.pid, signal);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
222
352
|
};
|
|
223
353
|
signalHandlers.set(signal, handler);
|
|
224
354
|
process.once(signal, handler);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { CodexAppServerClient } from "./codex-app-server-client.js";
|
|
7
|
+
const fixtureSource = String.raw `
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const readline = require("node:readline");
|
|
10
|
+
const tracePath = process.env.DOER_CODEX_FIXTURE_TRACE;
|
|
11
|
+
const trace = (value) => fs.appendFileSync(tracePath, value + "\n");
|
|
12
|
+
const send = (value) => process.stdout.write(JSON.stringify(value) + "\n");
|
|
13
|
+
let waitingRequestId = null;
|
|
14
|
+
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
|
15
|
+
const message = JSON.parse(line);
|
|
16
|
+
if (message.method === "initialize") {
|
|
17
|
+
send({ id: message.id, result: {} });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (message.method === "test/server-request") {
|
|
21
|
+
waitingRequestId = message.id;
|
|
22
|
+
send({
|
|
23
|
+
id: "server-request-1",
|
|
24
|
+
method: "item/tool/call",
|
|
25
|
+
params: { tool: "fixture", arguments: {} },
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (message.id === "server-request-1") {
|
|
30
|
+
trace("server-request-response");
|
|
31
|
+
send({ id: waitingRequestId, result: message.result });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (message.method === "test/start-turn") {
|
|
35
|
+
send({
|
|
36
|
+
method: "turn/started",
|
|
37
|
+
params: {
|
|
38
|
+
threadId: "thread-1",
|
|
39
|
+
turn: { id: "turn-1", status: "inProgress" },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
send({ id: message.id, result: {} });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (message.method === "turn/interrupt") {
|
|
46
|
+
trace("turn-interrupt");
|
|
47
|
+
send({ id: message.id, result: {} });
|
|
48
|
+
send({
|
|
49
|
+
method: "turn/completed",
|
|
50
|
+
params: {
|
|
51
|
+
threadId: "thread-1",
|
|
52
|
+
turn: { id: "turn-1", status: "interrupted" },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
process.on("SIGTERM", () => {
|
|
58
|
+
trace("sigterm");
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
61
|
+
`;
|
|
62
|
+
test("handles server requests and drains active turns before shutdown", async () => {
|
|
63
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "doer-app-server-client-"));
|
|
64
|
+
const tracePath = path.join(tempDir, "trace.log");
|
|
65
|
+
const client = new CodexAppServerClient({
|
|
66
|
+
cwd: tempDir,
|
|
67
|
+
args: [],
|
|
68
|
+
executable: process.execPath,
|
|
69
|
+
executableArgs: ["--eval", fixtureSource],
|
|
70
|
+
env: {
|
|
71
|
+
...process.env,
|
|
72
|
+
DOER_CODEX_FIXTURE_TRACE: tracePath,
|
|
73
|
+
},
|
|
74
|
+
gracefulShutdownTimeoutMs: 2_000,
|
|
75
|
+
onServerRequest: async (method, params) => {
|
|
76
|
+
assert.equal(method, "item/tool/call");
|
|
77
|
+
assert.deepEqual(params, { tool: "fixture", arguments: {} });
|
|
78
|
+
return { contentItems: [], success: true };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const serverRequestResult = await client.request("test/server-request");
|
|
82
|
+
assert.deepEqual(serverRequestResult, { contentItems: [], success: true });
|
|
83
|
+
await client.request("test/start-turn");
|
|
84
|
+
await client.stop();
|
|
85
|
+
const trace = (await readFile(tracePath, "utf8")).trim().split("\n");
|
|
86
|
+
assert.deepEqual(trace, [
|
|
87
|
+
"server-request-response",
|
|
88
|
+
"turn-interrupt",
|
|
89
|
+
"sigterm",
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { buildAgentSettingsEnvPatch, readAgentModelInstructions, resolveAgentModelInstructionsFilePath, } from "./agent-settings.js";
|
|
2
2
|
import { buildCustomMcpConfigArgs, buildDaemonMcpConfigArgs, buildMobileMcpConfigArgs, buildThreadsMcpConfigArgs, spawnManagedCodexCommand, } from "./agent-codex-cli.js";
|
|
3
|
-
import { CodexAppServerClient } from "./codex-app-server-client.js";
|
|
3
|
+
import { CodexAppServerClient, CodexAppServerRequestError, } from "./codex-app-server-client.js";
|
|
4
4
|
import { startCodexChatBridge } from "./codex-chat-bridge.js";
|
|
5
5
|
import { allocateMcpOauthCallbackPort, buildMcpOauthCallbackBaseUrl, relayMcpOauthCallbackToLocalListener, } from "./mcp-oauth-relay.js";
|
|
6
6
|
function toTomlStringLiteral(value) {
|
|
@@ -12,6 +12,35 @@ function buildConfigArg(key, tomlValue) {
|
|
|
12
12
|
function buildFeatureArg(enabled, name) {
|
|
13
13
|
return [enabled ? "--enable" : "--disable", name];
|
|
14
14
|
}
|
|
15
|
+
function safeServerRequestResponse(method) {
|
|
16
|
+
switch (method) {
|
|
17
|
+
case "item/commandExecution/requestApproval":
|
|
18
|
+
case "item/fileChange/requestApproval":
|
|
19
|
+
return { decision: "decline" };
|
|
20
|
+
case "item/tool/requestUserInput":
|
|
21
|
+
return { answers: {} };
|
|
22
|
+
case "mcpServer/elicitation/request":
|
|
23
|
+
return { action: "decline", content: null, _meta: null };
|
|
24
|
+
case "item/permissions/requestApproval":
|
|
25
|
+
return { permissions: {}, scope: "turn" };
|
|
26
|
+
case "item/tool/call":
|
|
27
|
+
return {
|
|
28
|
+
contentItems: [{
|
|
29
|
+
type: "inputText",
|
|
30
|
+
text: "This client does not provide a handler for dynamic tools.",
|
|
31
|
+
}],
|
|
32
|
+
success: false,
|
|
33
|
+
};
|
|
34
|
+
case "applyPatchApproval":
|
|
35
|
+
case "execCommandApproval":
|
|
36
|
+
return { decision: { denied: { rejection: "Client approval UI is unavailable." } } };
|
|
37
|
+
case "account/chatgptAuthTokens/refresh":
|
|
38
|
+
case "attestation/generate":
|
|
39
|
+
throw new CodexAppServerRequestError(`Unsupported Codex app-server request: ${method}`, -32601);
|
|
40
|
+
default:
|
|
41
|
+
throw new CodexAppServerRequestError(`Unknown Codex app-server request: ${method}`, -32601);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
15
44
|
function resolveCodexModel(settings) {
|
|
16
45
|
const providerKey = settings.codex.modelProvider || "openai";
|
|
17
46
|
if (providerKey === "zai") {
|
|
@@ -143,6 +172,7 @@ export function createCodexAppServerManager(args) {
|
|
|
143
172
|
let client = null;
|
|
144
173
|
let providerProxy = null;
|
|
145
174
|
let createPromise = null;
|
|
175
|
+
let lifecyclePromise = null;
|
|
146
176
|
let mcpOauthCallbackPortPromise = null;
|
|
147
177
|
let generation = 0;
|
|
148
178
|
const notificationListeners = new Set();
|
|
@@ -189,6 +219,10 @@ export function createCodexAppServerManager(args) {
|
|
|
189
219
|
args: appServerArgs,
|
|
190
220
|
env,
|
|
191
221
|
onLog: args.onLog,
|
|
222
|
+
onServerRequest: async (method) => {
|
|
223
|
+
args.onLog?.(`codex app-server server request method=${method} handled=safe-fallback`);
|
|
224
|
+
return safeServerRequestResponse(method);
|
|
225
|
+
},
|
|
192
226
|
onNotification: (method, params) => {
|
|
193
227
|
for (const listener of notificationListeners) {
|
|
194
228
|
listener(method, params);
|
|
@@ -198,6 +232,9 @@ export function createCodexAppServerManager(args) {
|
|
|
198
232
|
});
|
|
199
233
|
};
|
|
200
234
|
const getClient = async () => {
|
|
235
|
+
if (lifecyclePromise) {
|
|
236
|
+
await lifecyclePromise;
|
|
237
|
+
}
|
|
201
238
|
if (client) {
|
|
202
239
|
return client;
|
|
203
240
|
}
|
|
@@ -238,6 +275,21 @@ export function createCodexAppServerManager(args) {
|
|
|
238
275
|
args.onLog?.(`failed to stop provider proxy: ${error instanceof Error ? error.message : String(error)}`);
|
|
239
276
|
});
|
|
240
277
|
};
|
|
278
|
+
const scheduleRestart = async (reason) => {
|
|
279
|
+
if (lifecyclePromise) {
|
|
280
|
+
return await lifecyclePromise;
|
|
281
|
+
}
|
|
282
|
+
const pendingRestart = restartClient(reason);
|
|
283
|
+
lifecyclePromise = pendingRestart;
|
|
284
|
+
try {
|
|
285
|
+
await pendingRestart;
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
if (lifecyclePromise === pendingRestart) {
|
|
289
|
+
lifecyclePromise = null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
241
293
|
const runMcpLogout = async (name) => {
|
|
242
294
|
const settings = await args.readAgentSettingsConfig({ workspaceRoot: args.workspaceRoot });
|
|
243
295
|
const server = settings.mcp.servers.find((candidate) => candidate.name === name && candidate.transport === "streamable_http");
|
|
@@ -304,7 +356,7 @@ export function createCodexAppServerManager(args) {
|
|
|
304
356
|
await runMcpLogout(normalizedName);
|
|
305
357
|
},
|
|
306
358
|
async restart(reason) {
|
|
307
|
-
await
|
|
359
|
+
await scheduleRestart(reason);
|
|
308
360
|
},
|
|
309
361
|
async stop() {
|
|
310
362
|
const activeClient = client;
|