@parall/codex-agent 1.58.2 → 1.60.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.
Files changed (39) hide show
  1. package/dist/app-server-protocol.d.ts +13 -0
  2. package/dist/app-server-protocol.d.ts.map +1 -1
  3. package/dist/app-server-protocol.js +21 -0
  4. package/dist/compact.d.ts +33 -0
  5. package/dist/compact.d.ts.map +1 -0
  6. package/dist/compact.js +92 -0
  7. package/dist/dispatch.d.ts +37 -5
  8. package/dist/dispatch.d.ts.map +1 -1
  9. package/dist/dispatch.js +124 -61
  10. package/dist/foreign-threads.d.ts +49 -0
  11. package/dist/foreign-threads.d.ts.map +1 -0
  12. package/dist/foreign-threads.js +143 -0
  13. package/dist/index.js +12 -2
  14. package/dist/injection-registry.d.ts +39 -0
  15. package/dist/injection-registry.d.ts.map +1 -0
  16. package/dist/injection-registry.js +103 -0
  17. package/dist/instructions-refresh.d.ts +25 -0
  18. package/dist/instructions-refresh.d.ts.map +1 -1
  19. package/dist/instructions-refresh.js +79 -19
  20. package/dist/runtime-turn.d.ts +23 -0
  21. package/dist/runtime-turn.d.ts.map +1 -0
  22. package/dist/runtime-turn.js +56 -0
  23. package/dist/session-manager.d.ts +2 -0
  24. package/dist/session-manager.d.ts.map +1 -1
  25. package/dist/session-manager.js +7 -0
  26. package/dist/turn-sink.d.ts +5 -3
  27. package/dist/turn-sink.d.ts.map +1 -1
  28. package/dist/turn-sink.js +12 -31
  29. package/package.json +4 -4
  30. package/src/app-server-protocol.ts +37 -0
  31. package/src/compact.ts +132 -0
  32. package/src/dispatch.ts +149 -64
  33. package/src/foreign-threads.ts +187 -0
  34. package/src/index.ts +12 -1
  35. package/src/injection-registry.ts +100 -0
  36. package/src/instructions-refresh.ts +90 -18
  37. package/src/runtime-turn.ts +64 -0
  38. package/src/session-manager.ts +6 -0
  39. package/src/turn-sink.ts +12 -29
@@ -0,0 +1,187 @@
1
+ import type { GatewayLogger, RuntimeActivityPort } from '@parall/agent-core';
2
+ import { extractThreadInfo } from './app-server-protocol.js';
3
+ import { CodexRuntimeTurn } from './runtime-turn.js';
4
+ import type { CodexSessionManager } from './session-manager.js';
5
+ import type { TurnEventEnvelope } from './turn-sink.js';
6
+
7
+ /**
8
+ * Threads the app-server runs that no dispatch opened — `spawn_agent`
9
+ * subagent threads (`thread/started` with `threadSource: "subAgent"`), or a
10
+ * thread whose turn showed up with no `thread/started` (bridge restarted
11
+ * mid-subagent). Each is a child session of its parent thread's session;
12
+ * every `turn/started`…`turn/completed` on it is a RuntimeInitiatedTurn
13
+ * (runtime-initiated-turns-design.md §5).
14
+ */
15
+ export type ForeignThread = {
16
+ threadId: string;
17
+ sessionKey: string;
18
+ parentThreadId?: string;
19
+ parentSessionKey: string;
20
+ nickname?: string;
21
+ role?: string;
22
+ turn?: CodexRuntimeTurn;
23
+ };
24
+
25
+ export type ForeignThreadHost = {
26
+ sessionManager: CodexSessionManager;
27
+ log?: GatewayLogger;
28
+ /** Turns opened and child sessions closed go to the gateway through it. */
29
+ activity: RuntimeActivityPort;
30
+ /** A subagent turn ended — a deferred restart may apply now. */
31
+ afterTurnClosed(): void;
32
+ };
33
+
34
+ export class ForeignThreadRegistry {
35
+ private readonly threads = new Map<string, ForeignThread>();
36
+
37
+ constructor(private readonly host: ForeignThreadHost) {}
38
+
39
+ /** Live (registered, not closed) subagent threads. */
40
+ get size(): number {
41
+ return this.threads.size;
42
+ }
43
+
44
+ openTurns(): number {
45
+ let count = 0;
46
+ for (const thread of this.threads.values()) if (thread.turn) count += 1;
47
+ return count;
48
+ }
49
+
50
+ /**
51
+ * Notifications for a thread no dispatch owns. Returns false when the
52
+ * caller should keep treating the frame as unroutable.
53
+ */
54
+ route(method: string, params: unknown, threadId: string): boolean {
55
+ if (method === 'thread/started') {
56
+ if (this.host.sessionManager.getSessionKey(threadId) || this.threads.has(threadId)) {
57
+ return true;
58
+ }
59
+ const info = extractThreadInfo(params);
60
+ // Only subagent spawns become child sessions; our own thread/start and
61
+ // thread/fork echoes (source appServer) are bound by their responses.
62
+ if (!info || info.source !== 'subAgent') return true;
63
+ this.register(info);
64
+ return true;
65
+ }
66
+ let thread = this.threads.get(threadId);
67
+ if (method === 'thread/closed' || method === 'thread/archived' || method === 'thread/deleted') {
68
+ if (!thread) return false;
69
+ this.close(thread, method.slice('thread/'.length));
70
+ return true;
71
+ }
72
+ if (!thread) {
73
+ // A turn on a thread we never saw start: our own thread without a
74
+ // sink is a genuine orphan (keep the warn); anything else is adopted
75
+ // under the main session (bridge restarted mid-subagent).
76
+ if (this.host.sessionManager.getSessionKey(threadId)) return false;
77
+ if (method !== 'turn/started' && method !== 'turn/completed' && !method.startsWith('item/')) {
78
+ return false;
79
+ }
80
+ thread = this.register({ id: threadId });
81
+ }
82
+ if (method === 'turn/started') {
83
+ if (thread.turn) thread.turn.touch();
84
+ else this.openTurn(thread);
85
+ return true;
86
+ }
87
+ if (!thread.turn) {
88
+ if (method !== 'turn/completed' && !method.startsWith('item/')) return true;
89
+ this.openTurn(thread);
90
+ }
91
+ const turn = thread.turn as CodexRuntimeTurn;
92
+ turn.sink.touchActivity();
93
+ for (const event of turn.sink.mapper.map(method, params)) {
94
+ turn.sink.push({ kind: 'runtime', event });
95
+ }
96
+ if (method === 'turn/completed') {
97
+ this.endTurn(thread, { kind: 'turn_end', threadId });
98
+ this.host.log?.info?.(
99
+ `runtime-initiated turn ${turn.groupKey} on subagent thread ${threadId} completed`,
100
+ );
101
+ }
102
+ return true;
103
+ }
104
+
105
+ /** Subagent threads die with the app-server: end open turns, close child sessions. */
106
+ dropAll(reason: string): void {
107
+ for (const thread of [...this.threads.values()]) {
108
+ this.close(thread, reason);
109
+ }
110
+ }
111
+
112
+ private register(info: {
113
+ id: string;
114
+ parentThreadId?: string;
115
+ nickname?: string;
116
+ role?: string;
117
+ }): ForeignThread {
118
+ // A subagent may itself spawn subagents: resolve the parent against the
119
+ // live foreign threads first, then main / fork threads, else main.
120
+ const parentSessionKey =
121
+ (info.parentThreadId
122
+ ? (this.threads.get(info.parentThreadId)?.sessionKey ??
123
+ this.host.sessionManager.getSessionKey(info.parentThreadId))
124
+ : undefined) ?? this.host.sessionManager.mainSessionKey;
125
+ const thread: ForeignThread = {
126
+ threadId: info.id,
127
+ sessionKey: `codex-sub:${info.id}`,
128
+ parentThreadId: info.parentThreadId,
129
+ parentSessionKey,
130
+ nickname: info.nickname,
131
+ role: info.role,
132
+ };
133
+ this.threads.set(info.id, thread);
134
+ this.host.log?.info?.(
135
+ `subagent thread ${info.id} registered (parent ${info.parentThreadId ?? 'unknown'} → ${parentSessionKey}${info.nickname ? `, ${info.nickname}` : ''}${info.role ? ` / ${info.role}` : ''}); ${this.threads.size} live`,
136
+ );
137
+ return thread;
138
+ }
139
+
140
+ private openTurn(thread: ForeignThread): void {
141
+ const turn = new CodexRuntimeTurn(
142
+ thread.sessionKey,
143
+ {
144
+ kind: 'subagent',
145
+ threadId: thread.threadId,
146
+ ...(thread.parentThreadId ? { parentThreadId: thread.parentThreadId } : {}),
147
+ ...(thread.nickname ? { nickname: thread.nickname } : {}),
148
+ ...(thread.role ? { role: thread.role } : {}),
149
+ },
150
+ {
151
+ parentSessionKey: thread.parentSessionKey,
152
+ onDetach: (reason) => {
153
+ if (thread.turn !== turn) return;
154
+ this.endTurn(thread, { kind: 'error', message: `subagent turn detached: ${reason}` });
155
+ },
156
+ },
157
+ );
158
+ thread.turn = turn;
159
+ this.host.log?.info?.(
160
+ `runtime-initiated turn ${turn.groupKey} opened on subagent thread ${thread.threadId}`,
161
+ );
162
+ this.host.activity.surfaceTurn(turn);
163
+ }
164
+
165
+ /** The thread's open turn is over: last envelope, sink closed, restart re-driven. */
166
+ private endTurn(thread: ForeignThread, last: TurnEventEnvelope): void {
167
+ const turn = thread.turn;
168
+ if (!turn) return;
169
+ thread.turn = undefined;
170
+ turn.sink.push(last);
171
+ turn.sink.close();
172
+ this.host.afterTurnClosed();
173
+ }
174
+
175
+ private close(thread: ForeignThread, reason: string): void {
176
+ if (this.threads.get(thread.threadId) !== thread) return;
177
+ this.threads.delete(thread.threadId);
178
+ this.endTurn(thread, {
179
+ kind: 'error',
180
+ message: `subagent thread ${thread.threadId} ${reason}`,
181
+ });
182
+ this.host.log?.info?.(
183
+ `subagent thread ${thread.threadId} ${reason}; ${this.threads.size} live`,
184
+ );
185
+ this.host.activity.emit({ kind: 'session_closed', sessionKey: thread.sessionKey, reason });
186
+ }
187
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  llmSource,
21
21
  identityFromMe,
22
22
  initAgentTelemetry,
23
+ resolveServiceVersion,
23
24
  } from '@parall/agent-core';
24
25
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
25
26
  import {
@@ -74,7 +75,11 @@ function resolveProviderEnv(): void {
74
75
  async function main() {
75
76
  // Before any fetch: long-lived HTTP connections for every bridge→api call.
76
77
  configureHttpKeepAlive();
77
- const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex');
78
+ const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex', {
79
+ apiUrl: process.env.PRLL_API_URL,
80
+ apiKey: process.env.PRLL_API_KEY,
81
+ serviceVersion: resolveServiceVersion(import.meta.url),
82
+ });
78
83
  activeLog = createOtelLogger('agent', 'codex-agent');
79
84
  try {
80
85
  resolveProviderEnv();
@@ -291,6 +296,12 @@ async function main() {
291
296
  onSessionStale: () => {
292
297
  sessionManager.clearMainThread();
293
298
  },
299
+ // The app-server outlives one dispatch (subagent threads keep running
300
+ // after the parent turn): stop it INSIDE the gateway's shutdown so the
301
+ // runtime turns it ends still land their steps and idle/close writes
302
+ // before the step and lifecycle flushes — the finally below runs after
303
+ // those windows have closed.
304
+ onBeforeDisconnect: () => adapter.stop(),
294
305
  });
295
306
 
296
307
  const abortController = new AbortController();
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Bookkeeping for mid-turn steer injections (`turn/steer`), per session key.
3
+ *
4
+ * Codex gives no per-input runtime receipt: a successful `turn/steer` only
5
+ * says the app-server accepted the text into the running turn. So each
6
+ * injection carries two independent facts with two different owners:
7
+ *
8
+ * - settled — the turn it was steered into has ended (the adapter's own
9
+ * dispatch() generator closing on turn/completed, an interrupt, or the
10
+ * subprocess dying). Only an UNSETTLED injection may hold a lane's
11
+ * complete open (DispatchAdapter.hasUnsettledInjections).
12
+ * - drained — the gateway consumed the buffered copy that backs it: either
13
+ * the already-rendered drain branch (acknowledgeDiscardedInjection) or a
14
+ * group dispatch() whose inputLifecycle lists the WorkItem. Until then the
15
+ * copy still owes frame-boundary bookkeeping (hasPendingInjections).
16
+ *
17
+ * Entries are keyed by WorkItem id — the deliveryKey the gateway uses on both
18
+ * sides — never counted. The pre-PFB-84 design kept a per-session COUNT and
19
+ * made the next dispatch() skip turn/start while it was non-zero: a copy the
20
+ * gateway discarded without a dispatch() never decremented it, so the count
21
+ * leaked onto the NEXT unrelated message, whose dispatch then ran no turn and
22
+ * was complete(ok)-swept as no_action — the model never saw it.
23
+ */
24
+ export class CodexInjectionRegistry {
25
+ private readonly sessions = new Map<string, Map<string, InjectionState>>();
26
+
27
+ /** Record a successful steer. `deliveryKey` may join several WorkItem ids with ','. */
28
+ register(sessionKey: string, deliveryKey: string): void {
29
+ let entries = this.sessions.get(sessionKey);
30
+ if (!entries) {
31
+ entries = new Map();
32
+ this.sessions.set(sessionKey, entries);
33
+ }
34
+ for (const id of splitDeliveryKey(deliveryKey)) {
35
+ if (!entries.has(id)) entries.set(id, { settled: false, drained: false });
36
+ }
37
+ }
38
+
39
+ /** The buffered copies behind these WorkItems drained (group dispatch or discard). */
40
+ markDrained(sessionKey: string, deliveryKey: string): void {
41
+ const entries = this.sessions.get(sessionKey);
42
+ if (!entries) return;
43
+ for (const id of splitDeliveryKey(deliveryKey)) {
44
+ const entry = entries.get(id);
45
+ if (!entry) continue;
46
+ entry.drained = true;
47
+ if (entry.settled) entries.delete(id);
48
+ }
49
+ if (entries.size === 0) this.sessions.delete(sessionKey);
50
+ }
51
+
52
+ /** The turn every injection of this session was steered into has ended. */
53
+ settleAll(sessionKey: string): void {
54
+ const entries = this.sessions.get(sessionKey);
55
+ if (!entries) return;
56
+ for (const [id, entry] of entries) {
57
+ entry.settled = true;
58
+ if (entry.drained) entries.delete(id);
59
+ }
60
+ if (entries.size === 0) this.sessions.delete(sessionKey);
61
+ }
62
+
63
+ /** An injection whose buffered copy has not drained yet (bookkeeping owed). */
64
+ hasPending(sessionKey: string): boolean {
65
+ const entries = this.sessions.get(sessionKey);
66
+ if (!entries) return false;
67
+ for (const entry of entries.values()) {
68
+ if (!entry.drained) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** An injection whose turn is still running (the only state that may defer a complete). */
74
+ hasUnsettled(sessionKey: string): boolean {
75
+ const entries = this.sessions.get(sessionKey);
76
+ if (!entries) return false;
77
+ for (const entry of entries.values()) {
78
+ if (!entry.settled) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /** Drop every entry (turn aborted / subprocess gone): nothing is owed anymore. */
84
+ clear(sessionKey?: string): void {
85
+ if (sessionKey === undefined) {
86
+ this.sessions.clear();
87
+ return;
88
+ }
89
+ this.sessions.delete(sessionKey);
90
+ }
91
+ }
92
+
93
+ type InjectionState = { settled: boolean; drained: boolean };
94
+
95
+ function splitDeliveryKey(deliveryKey: string): string[] {
96
+ return deliveryKey
97
+ .split(',')
98
+ .map((id) => id.trim())
99
+ .filter((id) => id.length > 0);
100
+ }
@@ -80,7 +80,7 @@ export type RefreshOutcome =
80
80
  // turn/start rejection rotate the persisted main thread in this state.
81
81
  | 'stalled';
82
82
 
83
- class CompactionStalledError extends Error {}
83
+ export class CompactionStalledError extends Error {}
84
84
 
85
85
  export class MainThreadInstructionsRefresher {
86
86
  /**
@@ -188,23 +188,14 @@ export class MainThreadInstructionsRefresher {
188
188
  return 'adopted-baseline';
189
189
  }
190
190
  if (this.compactUnsupported) return 'unsupported';
191
- // The waiter registers its notification tap BEFORE the request goes out:
192
- // the response and the compaction-turn notifications can arrive in one
193
- // stdout chunk, and the client's line loop dispatches notifications
194
- // synchronously — a tap registered only after `await sendRequest` resolves
195
- // (a queued microtask) would miss every one of them and hang on the
196
- // timeout.
197
- const compaction = this.watchForCompaction(client, taps, threadId);
198
191
  try {
199
- await client.sendRequest('thread/compact/start', { threadId });
200
- await compaction.done;
192
+ await this.runCompaction({ client, taps, threadId });
201
193
  this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, canonicalSha);
202
194
  log?.info?.(
203
195
  `platform instructions refreshed on persisted thread ${threadId} (compaction rebuilt initial context from the resumed configuration)`,
204
196
  );
205
197
  return 'refreshed';
206
198
  } catch (err) {
207
- compaction.cancel();
208
199
  if (err instanceof CompactionStalledError) {
209
200
  log?.warn?.(
210
201
  `platform instructions refresh stalled (${errToString(err)}); bouncing the subprocess before the next turn`,
@@ -212,7 +203,6 @@ export class MainThreadInstructionsRefresher {
212
203
  return 'stalled';
213
204
  }
214
205
  if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
215
- this.compactUnsupported = true;
216
206
  log?.warn?.(
217
207
  'thread/compact/start not supported by this codex CLI; the persisted thread keeps its previous platform instructions until it is replaced or the CLI is upgraded (tools still refresh live via the capability shim dir)',
218
208
  );
@@ -225,6 +215,57 @@ export class MainThreadInstructionsRefresher {
225
215
  }
226
216
  }
227
217
 
218
+ /** True once this subprocess rejected thread/compact/start with -32601. */
219
+ isCompactUnsupported(): boolean {
220
+ return this.compactUnsupported;
221
+ }
222
+
223
+ /**
224
+ * The unconditional compaction primitive — one `thread/compact/start`
225
+ * awaited to its turn close — shared by the instructions refresh (which
226
+ * decides WHETHER to run it by sha) and the idle auto-compact (which runs
227
+ * it whenever the server asks). Throws like the refresh's inner path:
228
+ * CompactionStalledError past budget/abort + interrupt grace, JsonRpcError
229
+ * -32601 (also latches compactUnsupported for this subprocess), or a plain
230
+ * Error for a turn that closed without compacting.
231
+ */
232
+ async runCompaction(args: {
233
+ client: JsonRpcStdioClient;
234
+ taps: NotificationTapSource;
235
+ threadId: string;
236
+ signal?: AbortSignal;
237
+ }): Promise<void> {
238
+ const { client, taps, threadId, signal } = args;
239
+ // The waiter registers its notification tap BEFORE the request goes out:
240
+ // the response and the compaction-turn notifications can arrive in one
241
+ // stdout chunk, and the client's line loop dispatches notifications
242
+ // synchronously — a tap registered only after `await sendRequest` resolves
243
+ // (a queued microtask) would miss every one of them and hang on the
244
+ // timeout.
245
+ const compaction = this.watchForCompaction(client, taps, threadId, signal);
246
+ try {
247
+ await client.sendRequest('thread/compact/start', { threadId });
248
+ await compaction.done;
249
+ } catch (err) {
250
+ compaction.cancel();
251
+ if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
252
+ this.compactUnsupported = true;
253
+ }
254
+ throw err;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * A compaction that completed outside the refresh path (idle auto-compact)
260
+ * rebuilt the model-visible context from the canonical configuration: the
261
+ * effective plane now equals whatever this process opened the thread with.
262
+ */
263
+ recordCompacted(sessionKey: string, threadId: string): void {
264
+ const canonical = this.canonicalByThread.get(threadId);
265
+ if (canonical === undefined) return;
266
+ this.opts.sessionManager.recordEffectiveInstructionsSha(sessionKey, sha256Hex(canonical));
267
+ }
268
+
228
269
  /**
229
270
  * Compaction runs as its own turn on the thread:
230
271
  * turn/started → item/started{contextCompaction} →
@@ -250,6 +291,7 @@ export class MainThreadInstructionsRefresher {
250
291
  client: JsonRpcStdioClient,
251
292
  taps: NotificationTapSource,
252
293
  threadId: string,
294
+ signal?: AbortSignal,
253
295
  ): { done: Promise<void>; cancel: () => void } {
254
296
  const timeoutMs = this.opts.compactTimeoutMs ?? DEFAULT_COMPACT_TIMEOUT_MS;
255
297
  const graceMs = this.opts.interruptGraceMs ?? COMPACT_INTERRUPT_GRACE_MS;
@@ -261,17 +303,24 @@ export class MainThreadInstructionsRefresher {
261
303
  let settled = false;
262
304
  let unregister = () => {};
263
305
  let graceTimer: NodeJS.Timeout | undefined;
306
+ const onAbort = () => expire('aborted by the caller');
264
307
  const finish = (err?: Error) => {
265
308
  if (settled) return;
266
309
  settled = true;
267
310
  clearTimeout(timer);
268
311
  if (graceTimer) clearTimeout(graceTimer);
312
+ signal?.removeEventListener('abort', onAbort);
269
313
  unregister();
270
314
  if (err) reject(err);
271
315
  else resolve();
272
316
  };
273
- const timer = setTimeout(() => {
317
+ // Budget exhausted (timer) or the caller aborted (idle auto-compact
318
+ // budget): interrupt best-effort and hold the grace for the turn to
319
+ // close; past it the turn may still be running → stalled.
320
+ const expire = (why: string) => {
321
+ if (settled || interrupted) return;
274
322
  interrupted = true;
323
+ clearTimeout(timer);
275
324
  if (compactionTurnId) {
276
325
  // Best-effort and non-lethal: an unanswered interrupt must expire
277
326
  // with the grace window, not arm the client's default assume-hung
@@ -288,12 +337,15 @@ export class MainThreadInstructionsRefresher {
288
337
  () =>
289
338
  finish(
290
339
  new CompactionStalledError(
291
- `compaction did not complete within ${timeoutMs}ms (interrupt grace elapsed; the compaction turn may still be running)`,
340
+ `compaction did not complete (${why}; interrupt grace elapsed; the compaction turn may still be running)`,
292
341
  ),
293
342
  ),
294
343
  graceMs,
295
344
  );
296
- }, timeoutMs);
345
+ };
346
+ const timer = setTimeout(() => expire(`within ${timeoutMs}ms`), timeoutMs);
347
+ if (signal?.aborted) onAbort();
348
+ else signal?.addEventListener('abort', onAbort, { once: true });
297
349
  // Cancellation resolves (never rejects): the caller cancels only when
298
350
  // the request itself already failed, and that error is what it reports.
299
351
  cancel = () => finish();
@@ -305,8 +357,28 @@ export class MainThreadInstructionsRefresher {
305
357
  // notifications can arrive after that; waiting out the timeout
306
358
  // would stall the dispatch for the full budget on a dead client.
307
359
  if (notificationThreadId === undefined || notificationThreadId === threadId) {
308
- const msg = (params as { message?: unknown })?.message;
309
- finish(new Error(`app-server error during compaction: ${String(msg ?? 'unknown')}`));
360
+ // v2 ErrorNotification: { threadId?, turnId?, error: { message,
361
+ // codexErrorInfo? }, willRetry }. The adapter's synthetic
362
+ // disposal broadcast still carries a top-level `message`.
363
+ const p = params as {
364
+ message?: unknown;
365
+ willRetry?: unknown;
366
+ error?: { message?: unknown; codexErrorInfo?: unknown };
367
+ };
368
+ // A retryable error keeps the turn alive — codex retries the
369
+ // model call itself and the compaction still completes or fails
370
+ // through turn/completed; bailing here would report a failure
371
+ // for a compaction that is still running.
372
+ if (p?.willRetry === true) return;
373
+ const msg = p?.error?.message ?? p?.message;
374
+ const info = p?.error?.codexErrorInfo;
375
+ const infoText =
376
+ info == null ? '' : ` (${typeof info === 'string' ? info : JSON.stringify(info)})`;
377
+ finish(
378
+ new Error(
379
+ `app-server error during compaction: ${String(msg ?? 'unknown')}${infoText}`,
380
+ ),
381
+ );
310
382
  }
311
383
  return;
312
384
  }
@@ -326,7 +398,7 @@ export class MainThreadInstructionsRefresher {
326
398
  finish(
327
399
  new Error(
328
400
  interrupted
329
- ? `compaction did not complete within ${timeoutMs}ms (turn closed after interrupt)`
401
+ ? 'compaction did not complete within budget (turn closed after interrupt)'
330
402
  : `compaction turn ended without completing (status=${status ?? 'unknown'})`,
331
403
  ),
332
404
  );
@@ -0,0 +1,64 @@
1
+ import {
2
+ projectRuntimeEvent,
3
+ type RuntimeEvent,
4
+ RuntimeTurnBase,
5
+ type RuntimeTurnTrigger,
6
+ } from '@parall/agent-core';
7
+ import { TurnSink } from './turn-sink.js';
8
+
9
+ /**
10
+ * One turn of a thread the app-server runs that no dispatch started — a
11
+ * `spawn_agent` subagent thread. Its notifications are routed into `sink`
12
+ * by the adapter; the gateway drains `events` as a turn of the subagent's
13
+ * child session (the first event announces that session with its parent).
14
+ */
15
+ export class CodexRuntimeTurn extends RuntimeTurnBase {
16
+ readonly sink: TurnSink;
17
+
18
+ constructor(
19
+ sessionKey: string,
20
+ readonly trigger: Extract<RuntimeTurnTrigger, { kind: 'subagent' }>,
21
+ private readonly opts: {
22
+ parentSessionKey?: string;
23
+ onDetach: (reason: string) => void;
24
+ },
25
+ ) {
26
+ super(sessionKey, trigger, opts.onDetach);
27
+ this.sink = new TurnSink(() => this.touch());
28
+ }
29
+
30
+ protected async *drain(): AsyncGenerator<RuntimeEvent> {
31
+ yield {
32
+ type: 'runtime_session',
33
+ runtimeSessionId: this.trigger.threadId,
34
+ runtimeLaneKey: this.sessionKey,
35
+ ...(this.opts.parentSessionKey ? { parentSessionKey: this.opts.parentSessionKey } : {}),
36
+ };
37
+ let sawError = false;
38
+ let sawOutcome = false;
39
+ while (true) {
40
+ const envelope = await this.sink.next();
41
+ if (envelope.kind === 'turn_end') {
42
+ // A real turn/completed carries its threadId; the sink's close()
43
+ // sentinel does not — the thread died or the bridge stopped.
44
+ if (envelope.threadId) return;
45
+ if (!sawError) {
46
+ yield {
47
+ type: 'error',
48
+ message: 'subagent turn ended without turn/completed',
49
+ groupKey: this.groupKey,
50
+ };
51
+ }
52
+ if (!sawOutcome) yield { type: 'turn_outcome', outcome: 'runtime_crash' };
53
+ return;
54
+ }
55
+ const event =
56
+ envelope.kind === 'error'
57
+ ? { type: 'error' as const, message: envelope.message }
58
+ : envelope.event;
59
+ if (event.type === 'error') sawError = true;
60
+ if (event.type === 'turn_outcome') sawOutcome = true;
61
+ yield projectRuntimeEvent(event, this.groupKey);
62
+ }
63
+ }
64
+ }
@@ -51,6 +51,12 @@ export class CodexSessionManager {
51
51
  return this.threadIds.get(sessionKey);
52
52
  }
53
53
 
54
+ /** The session (main or fork) that owns a thread the bridge opened. */
55
+ getSessionKey(threadId: string): string | undefined {
56
+ for (const [sessionKey, id] of this.threadIds) if (id === threadId) return sessionKey;
57
+ return undefined;
58
+ }
59
+
54
60
  recordThreadId(sessionKey: string, threadId: string) {
55
61
  // An effective-instructions sha is a fact about one specific thread.
56
62
  // Recording a DIFFERENT thread id under the same session invalidates it;
package/src/turn-sink.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeEvent } from '@parall/agent-core';
2
+ import { AsyncQueue } from '@parall/agent-core/internal/async-queue';
2
3
  import { EventMapper } from './event-mapping.js';
3
4
 
4
5
  export type TurnEventEnvelope =
@@ -6,12 +7,14 @@ export type TurnEventEnvelope =
6
7
  | { kind: 'turn_end'; threadId?: string }
7
8
  | { kind: 'error'; message: string };
8
9
 
9
- /** Per-turn buffered sink backed by an unbounded promise queue. */
10
+ /**
11
+ * Per-turn buffered sink between notification routing and the turn's
12
+ * drain. Envelopes queued before close() still drain (a final error pushed
13
+ * by handleSubprocessClose must reach the consumer before turn_end).
14
+ */
10
15
  export class TurnSink {
11
16
  readonly mapper = new EventMapper();
12
- private readonly queue: TurnEventEnvelope[] = [];
13
- private resolver: ((value: TurnEventEnvelope) => void) | null = null;
14
- private closed = false;
17
+ private readonly queue = new AsyncQueue<TurnEventEnvelope>();
15
18
 
16
19
  constructor(private readonly noteActivity?: () => void) {}
17
20
 
@@ -20,41 +23,21 @@ export class TurnSink {
20
23
  }
21
24
 
22
25
  push(envelope: TurnEventEnvelope) {
23
- if (this.closed) return;
24
- if (this.resolver) {
25
- const r = this.resolver;
26
- this.resolver = null;
27
- r(envelope);
28
- return;
29
- }
30
26
  this.queue.push(envelope);
31
27
  }
32
28
 
33
- next(): Promise<TurnEventEnvelope> {
34
- // Drain any queued envelopes first, even after close(). Otherwise a final
35
- // error envelope enqueued right before close() (e.g. by
36
- // handleSubprocessClose) is silently dropped because the consumer would
37
- // see turn_end before it.
38
- const pending = this.queue.shift();
39
- if (pending) return Promise.resolve(pending);
40
- if (this.closed) {
41
- return Promise.resolve({ kind: 'turn_end' });
42
- }
43
- return new Promise((resolve) => {
44
- this.resolver = resolve;
45
- });
29
+ async next(): Promise<TurnEventEnvelope> {
30
+ const result = await this.queue.next();
31
+ return result.done ? { kind: 'turn_end' } : result.value;
46
32
  }
47
33
 
48
34
  close() {
49
- this.closed = true;
50
- const r = this.resolver;
51
- this.resolver = null;
52
- r?.({ kind: 'turn_end' });
35
+ this.queue.close();
53
36
  }
54
37
 
55
38
  /** True once close() ran — the sink's terminal state (queued envelopes may
56
39
  * still drain via next()). */
57
40
  get isClosed(): boolean {
58
- return this.closed;
41
+ return this.queue.isClosed;
59
42
  }
60
43
  }