@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.
- package/dist/app-server-protocol.d.ts +13 -0
- package/dist/app-server-protocol.d.ts.map +1 -1
- package/dist/app-server-protocol.js +21 -0
- package/dist/compact.d.ts +33 -0
- package/dist/compact.d.ts.map +1 -0
- package/dist/compact.js +92 -0
- package/dist/dispatch.d.ts +32 -2
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +99 -37
- package/dist/foreign-threads.d.ts +49 -0
- package/dist/foreign-threads.d.ts.map +1 -0
- package/dist/foreign-threads.js +143 -0
- package/dist/index.js +12 -2
- package/dist/instructions-refresh.d.ts +25 -0
- package/dist/instructions-refresh.d.ts.map +1 -1
- package/dist/instructions-refresh.js +79 -19
- package/dist/runtime-turn.d.ts +23 -0
- package/dist/runtime-turn.d.ts.map +1 -0
- package/dist/runtime-turn.js +56 -0
- package/dist/session-manager.d.ts +2 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +7 -0
- package/dist/turn-sink.d.ts +5 -3
- package/dist/turn-sink.d.ts.map +1 -1
- package/dist/turn-sink.js +12 -31
- package/package.json +4 -4
- package/src/app-server-protocol.ts +37 -0
- package/src/compact.ts +132 -0
- package/src/dispatch.ts +119 -37
- package/src/foreign-threads.ts +187 -0
- package/src/index.ts +12 -1
- package/src/instructions-refresh.ts +90 -18
- package/src/runtime-turn.ts +64 -0
- package/src/session-manager.ts +6 -0
- package/src/turn-sink.ts +12 -29
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
?
|
|
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
|
+
}
|
package/src/session-manager.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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.
|
|
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.
|
|
41
|
+
return this.queue.isClosed;
|
|
59
42
|
}
|
|
60
43
|
}
|