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: "thread-handoff",
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
- const result = request.action === "request"
131
- ? applyCodexAppRpcOmitRules(request.method, await args.manager.request(request.method, request.params, request.timeoutMs))
132
- : request.action === "mcp-oauth-callback"
133
- ? await args.manager.relayMcpOauthCallback(request.path, request.search)
134
- : request.action === "mcp-oauth-logout"
135
- ? await args.manager.logoutMcpServer(request.name)
136
- : await createCodexThreadHandoff({
137
- manager: args.manager,
138
- sourceThreadId: request.sourceThreadId,
139
- targetName: request.targetName,
140
- activeTurnId: request.activeTurnId,
141
- latestUserText: request.latestUserText,
142
- sourceGoal: request.sourceGoal,
143
- onLog: args.onInfo,
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", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",