doer-agent 0.9.9 → 0.9.10
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.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { StringCodec } from "nats";
|
|
2
2
|
import { createCodexThreadHandoff, } from "./codex-thread-handoff.js";
|
|
3
|
+
import { CodexThreadHandoffJobManager } from "./codex-thread-handoff-jobs.js";
|
|
3
4
|
const codexAppRpcCodec = StringCodec();
|
|
4
5
|
const codexAppRpcOmitRules = [
|
|
5
6
|
{
|
|
@@ -84,7 +85,7 @@ function normalizeCodexAppRpcRequest(args) {
|
|
|
84
85
|
}
|
|
85
86
|
return { requestId, action: "mcp-oauth-logout", name };
|
|
86
87
|
}
|
|
87
|
-
if (actionRaw === "thread-handoff") {
|
|
88
|
+
if (actionRaw === "thread-handoff" || actionRaw === "thread-handoff-start") {
|
|
88
89
|
const sourceThreadId = typeof args.request.sourceThreadId === "string" ? args.request.sourceThreadId.trim() : "";
|
|
89
90
|
const targetName = typeof args.request.targetName === "string" ? args.request.targetName.trim() : "";
|
|
90
91
|
const activeTurnId = typeof args.request.activeTurnId === "string" ? args.request.activeTurnId.trim() : "";
|
|
@@ -102,7 +103,7 @@ function normalizeCodexAppRpcRequest(args) {
|
|
|
102
103
|
}
|
|
103
104
|
return {
|
|
104
105
|
requestId,
|
|
105
|
-
action:
|
|
106
|
+
action: actionRaw,
|
|
106
107
|
sourceThreadId,
|
|
107
108
|
targetName,
|
|
108
109
|
activeTurnId,
|
|
@@ -110,6 +111,13 @@ function normalizeCodexAppRpcRequest(args) {
|
|
|
110
111
|
sourceGoal,
|
|
111
112
|
};
|
|
112
113
|
}
|
|
114
|
+
if (actionRaw === "thread-handoff-status") {
|
|
115
|
+
const jobId = typeof args.request.jobId === "string" ? args.request.jobId.trim() : "";
|
|
116
|
+
if (!jobId) {
|
|
117
|
+
throw new Error("invalid thread handoff status request");
|
|
118
|
+
}
|
|
119
|
+
return { requestId, action: "thread-handoff-status", jobId };
|
|
120
|
+
}
|
|
113
121
|
if (actionRaw !== "request" || !method) {
|
|
114
122
|
throw new Error("invalid codex app rpc request");
|
|
115
123
|
}
|
|
@@ -127,21 +135,43 @@ async function handleCodexAppRpcMessage(args) {
|
|
|
127
135
|
const payload = JSON.parse(codexAppRpcCodec.decode(args.msg.data));
|
|
128
136
|
const request = normalizeCodexAppRpcRequest({ request: payload, agentId: args.agentId });
|
|
129
137
|
requestId = request.requestId;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
let result;
|
|
139
|
+
if (request.action === "request") {
|
|
140
|
+
result = applyCodexAppRpcOmitRules(request.method, await args.manager.request(request.method, request.params, request.timeoutMs));
|
|
141
|
+
}
|
|
142
|
+
else if (request.action === "mcp-oauth-callback") {
|
|
143
|
+
result = await args.manager.relayMcpOauthCallback(request.path, request.search);
|
|
144
|
+
}
|
|
145
|
+
else if (request.action === "mcp-oauth-logout") {
|
|
146
|
+
result = await args.manager.logoutMcpServer(request.name);
|
|
147
|
+
}
|
|
148
|
+
else if (request.action === "thread-handoff-status") {
|
|
149
|
+
const job = args.handoffJobs.get(request.jobId);
|
|
150
|
+
if (!job) {
|
|
151
|
+
throw new Error("thread handoff job not found");
|
|
152
|
+
}
|
|
153
|
+
result = job;
|
|
154
|
+
}
|
|
155
|
+
else if (request.action === "thread-handoff-start") {
|
|
156
|
+
result = args.handoffJobs.start({
|
|
157
|
+
sourceThreadId: request.sourceThreadId,
|
|
158
|
+
targetName: request.targetName,
|
|
159
|
+
activeTurnId: request.activeTurnId,
|
|
160
|
+
latestUserText: request.latestUserText,
|
|
161
|
+
sourceGoal: request.sourceGoal,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
result = await createCodexThreadHandoff({
|
|
166
|
+
manager: args.manager,
|
|
167
|
+
sourceThreadId: request.sourceThreadId,
|
|
168
|
+
targetName: request.targetName,
|
|
169
|
+
activeTurnId: request.activeTurnId,
|
|
170
|
+
latestUserText: request.latestUserText,
|
|
171
|
+
sourceGoal: request.sourceGoal,
|
|
172
|
+
onLog: args.onInfo,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
145
175
|
args.msg.respond(codexAppRpcCodec.encode(JSON.stringify({
|
|
146
176
|
requestId,
|
|
147
177
|
ok: true,
|
|
@@ -159,6 +189,22 @@ async function handleCodexAppRpcMessage(args) {
|
|
|
159
189
|
}
|
|
160
190
|
}
|
|
161
191
|
export function subscribeToCodexAppRpc(args) {
|
|
192
|
+
const handoffJobs = new CodexThreadHandoffJobManager(async (input, onProgress) => {
|
|
193
|
+
try {
|
|
194
|
+
const result = await createCodexThreadHandoff({
|
|
195
|
+
manager: args.manager,
|
|
196
|
+
...input,
|
|
197
|
+
onProgress,
|
|
198
|
+
onLog: args.onInfo,
|
|
199
|
+
});
|
|
200
|
+
args.onInfo(`thread handoff completed sourceThreadId=${input.sourceThreadId} targetThreadId=${result.threadId}`);
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
args.onError(`thread handoff failed sourceThreadId=${input.sourceThreadId} error=${error instanceof Error ? error.message : String(error)}`);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
162
208
|
args.nc.closed().finally(() => {
|
|
163
209
|
void args.manager.stop().catch(() => undefined);
|
|
164
210
|
});
|
|
@@ -174,6 +220,7 @@ export function subscribeToCodexAppRpc(args) {
|
|
|
174
220
|
nc: args.nc,
|
|
175
221
|
agentId: args.agentId,
|
|
176
222
|
manager: args.manager,
|
|
223
|
+
handoffJobs,
|
|
177
224
|
onInfo: args.onInfo,
|
|
178
225
|
onError: args.onError,
|
|
179
226
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const TERMINAL_PHASES = new Set(["completed", "failed"]);
|
|
3
|
+
function copySnapshot(job) {
|
|
4
|
+
return {
|
|
5
|
+
...job,
|
|
6
|
+
...(job.result ? { result: { ...job.result } } : {}),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export class CodexThreadHandoffJobManager {
|
|
10
|
+
run;
|
|
11
|
+
terminalTtlMs;
|
|
12
|
+
maxJobs;
|
|
13
|
+
jobs = new Map();
|
|
14
|
+
activeJobBySourceThreadId = new Map();
|
|
15
|
+
constructor(run, terminalTtlMs = 30 * 60_000, maxJobs = 100) {
|
|
16
|
+
this.run = run;
|
|
17
|
+
this.terminalTtlMs = terminalTtlMs;
|
|
18
|
+
this.maxJobs = maxJobs;
|
|
19
|
+
}
|
|
20
|
+
start(input) {
|
|
21
|
+
this.cleanup();
|
|
22
|
+
const existingJobId = this.activeJobBySourceThreadId.get(input.sourceThreadId);
|
|
23
|
+
const existing = existingJobId ? this.jobs.get(existingJobId) : undefined;
|
|
24
|
+
if (existing && !TERMINAL_PHASES.has(existing.phase)) {
|
|
25
|
+
return { job: copySnapshot(existing), deduplicated: true };
|
|
26
|
+
}
|
|
27
|
+
if (this.jobs.size >= this.maxJobs) {
|
|
28
|
+
throw new Error("too many thread handoff jobs");
|
|
29
|
+
}
|
|
30
|
+
const now = new Date().toISOString();
|
|
31
|
+
const job = {
|
|
32
|
+
jobId: randomUUID(),
|
|
33
|
+
sourceThreadId: input.sourceThreadId,
|
|
34
|
+
phase: "queued",
|
|
35
|
+
createdAt: now,
|
|
36
|
+
updatedAt: now,
|
|
37
|
+
};
|
|
38
|
+
this.jobs.set(job.jobId, job);
|
|
39
|
+
this.activeJobBySourceThreadId.set(input.sourceThreadId, job.jobId);
|
|
40
|
+
setImmediate(() => {
|
|
41
|
+
void this.execute(job.jobId, input);
|
|
42
|
+
});
|
|
43
|
+
return { job: copySnapshot(job), deduplicated: false };
|
|
44
|
+
}
|
|
45
|
+
get(jobId) {
|
|
46
|
+
this.cleanup();
|
|
47
|
+
const job = this.jobs.get(jobId);
|
|
48
|
+
return job ? copySnapshot(job) : null;
|
|
49
|
+
}
|
|
50
|
+
async execute(jobId, input) {
|
|
51
|
+
const updatePhase = (phase) => {
|
|
52
|
+
const current = this.jobs.get(jobId);
|
|
53
|
+
if (!current || TERMINAL_PHASES.has(current.phase)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
current.phase = phase;
|
|
57
|
+
current.updatedAt = new Date().toISOString();
|
|
58
|
+
};
|
|
59
|
+
try {
|
|
60
|
+
const result = await this.run(input, updatePhase);
|
|
61
|
+
const current = this.jobs.get(jobId);
|
|
62
|
+
if (current) {
|
|
63
|
+
current.phase = "completed";
|
|
64
|
+
current.result = result;
|
|
65
|
+
current.updatedAt = new Date().toISOString();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const current = this.jobs.get(jobId);
|
|
70
|
+
if (current) {
|
|
71
|
+
current.phase = "failed";
|
|
72
|
+
current.error = error instanceof Error ? error.message : String(error);
|
|
73
|
+
current.updatedAt = new Date().toISOString();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
if (this.activeJobBySourceThreadId.get(input.sourceThreadId) === jobId) {
|
|
78
|
+
this.activeJobBySourceThreadId.delete(input.sourceThreadId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
cleanup() {
|
|
83
|
+
const cutoff = Date.now() - this.terminalTtlMs;
|
|
84
|
+
for (const [jobId, job] of this.jobs) {
|
|
85
|
+
if (TERMINAL_PHASES.has(job.phase) && Date.parse(job.updatedAt) < cutoff) {
|
|
86
|
+
this.jobs.delete(jobId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (this.jobs.size < this.maxJobs) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const terminalJobs = [...this.jobs.values()]
|
|
93
|
+
.filter((job) => TERMINAL_PHASES.has(job.phase))
|
|
94
|
+
.sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt));
|
|
95
|
+
while (this.jobs.size >= this.maxJobs && terminalJobs.length > 0) {
|
|
96
|
+
const oldest = terminalJobs.shift();
|
|
97
|
+
if (oldest) {
|
|
98
|
+
this.jobs.delete(oldest.jobId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { CodexThreadHandoffJobManager } from "./codex-thread-handoff-jobs.js";
|
|
4
|
+
function handoffResult(sourceThreadId) {
|
|
5
|
+
return {
|
|
6
|
+
sourceThreadId,
|
|
7
|
+
thread: { id: "target-thread" },
|
|
8
|
+
threadId: "target-thread",
|
|
9
|
+
handoff: "summary",
|
|
10
|
+
turnsCollected: 2,
|
|
11
|
+
turnsOmitted: 0,
|
|
12
|
+
warnings: [],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function nextTick() {
|
|
16
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
17
|
+
}
|
|
18
|
+
test("starts handoff work asynchronously and reports progress", async () => {
|
|
19
|
+
let finish;
|
|
20
|
+
const manager = new CodexThreadHandoffJobManager(async (input, onProgress) => {
|
|
21
|
+
onProgress("collecting");
|
|
22
|
+
return await new Promise((resolve) => {
|
|
23
|
+
finish = resolve;
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
const started = manager.start({ sourceThreadId: "source-thread" });
|
|
27
|
+
assert.equal(started.job.phase, "queued");
|
|
28
|
+
assert.equal(started.deduplicated, false);
|
|
29
|
+
await nextTick();
|
|
30
|
+
assert.equal(manager.get(started.job.jobId)?.phase, "collecting");
|
|
31
|
+
finish?.(handoffResult("source-thread"));
|
|
32
|
+
await nextTick();
|
|
33
|
+
const completed = manager.get(started.job.jobId);
|
|
34
|
+
assert.equal(completed?.phase, "completed");
|
|
35
|
+
assert.equal(completed?.result?.threadId, "target-thread");
|
|
36
|
+
});
|
|
37
|
+
test("deduplicates active jobs for the same source thread", () => {
|
|
38
|
+
const manager = new CodexThreadHandoffJobManager(async () => {
|
|
39
|
+
return await new Promise(() => undefined);
|
|
40
|
+
});
|
|
41
|
+
const first = manager.start({ sourceThreadId: "source-thread" });
|
|
42
|
+
const second = manager.start({ sourceThreadId: "source-thread" });
|
|
43
|
+
assert.equal(second.deduplicated, true);
|
|
44
|
+
assert.equal(second.job.jobId, first.job.jobId);
|
|
45
|
+
});
|
|
46
|
+
test("keeps a failed job status for polling", async () => {
|
|
47
|
+
const manager = new CodexThreadHandoffJobManager(async () => {
|
|
48
|
+
throw new Error("summary failed");
|
|
49
|
+
});
|
|
50
|
+
const started = manager.start({ sourceThreadId: "source-thread" });
|
|
51
|
+
await nextTick();
|
|
52
|
+
await nextTick();
|
|
53
|
+
const failed = manager.get(started.job.jobId);
|
|
54
|
+
assert.equal(failed?.phase, "failed");
|
|
55
|
+
assert.equal(failed?.error, "summary failed");
|
|
56
|
+
});
|
|
@@ -265,6 +265,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
265
265
|
}
|
|
266
266
|
const warnings = [];
|
|
267
267
|
const sourceGoal = normalizeGoal(args.sourceGoal);
|
|
268
|
+
args.onProgress?.("preparing");
|
|
268
269
|
if (sourceGoal?.status === "active") {
|
|
269
270
|
await args.manager.request("thread/goal/set", {
|
|
270
271
|
threadId: sourceThreadId,
|
|
@@ -282,6 +283,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
282
283
|
warnings.push(`Could not interrupt source turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
283
284
|
});
|
|
284
285
|
}
|
|
286
|
+
args.onProgress?.("collecting");
|
|
285
287
|
args.onLog?.(`collecting handoff history sourceThreadId=${sourceThreadId}`);
|
|
286
288
|
const sourceRead = recordValue(await args.manager.request("thread/read", {
|
|
287
289
|
threadId: sourceThreadId,
|
|
@@ -304,6 +306,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
304
306
|
}
|
|
305
307
|
const turnsOmitted = Math.max(0, collected.turnCount - bounded.includedTurns)
|
|
306
308
|
+ (collected.truncated ? 1 : 0);
|
|
309
|
+
args.onProgress?.("summarizing");
|
|
307
310
|
args.onLog?.(`summarizing handoff sourceThreadId=${sourceThreadId} turns=${collected.turnCount}`);
|
|
308
311
|
const handoffRaw = await runCodexTextTask({
|
|
309
312
|
manager: args.manager,
|
|
@@ -321,6 +324,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
321
324
|
if (!handoff) {
|
|
322
325
|
throw new Error("Codex did not return a thread handoff summary");
|
|
323
326
|
}
|
|
327
|
+
args.onProgress?.("creating");
|
|
324
328
|
args.onLog?.(`creating handoff target sourceThreadId=${sourceThreadId}`);
|
|
325
329
|
const startResult = recordValue(await args.manager.request("thread/start", {
|
|
326
330
|
cwd: sourceCwd || null,
|
|
@@ -333,6 +337,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
333
337
|
throw new Error("Codex app-server did not return a handoff target thread");
|
|
334
338
|
}
|
|
335
339
|
try {
|
|
340
|
+
args.onProgress?.("injecting");
|
|
336
341
|
await args.manager.request("thread/inject_items", {
|
|
337
342
|
threadId,
|
|
338
343
|
items: [{
|
|
@@ -349,6 +354,7 @@ export async function createCodexThreadHandoff(args) {
|
|
|
349
354
|
await args.manager.request("thread/delete", { threadId }, 30_000).catch(() => undefined);
|
|
350
355
|
throw error;
|
|
351
356
|
}
|
|
357
|
+
args.onProgress?.("finalizing");
|
|
352
358
|
const targetName = stringValue(args.targetName).slice(0, 200);
|
|
353
359
|
if (targetName) {
|
|
354
360
|
await args.manager.request("thread/name/set", {
|