@parall/codex-agent 1.59.0 → 1.61.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.
@@ -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
  }