@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.
- 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 +37 -5
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +124 -61
- 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/injection-registry.d.ts +39 -0
- package/dist/injection-registry.d.ts.map +1 -0
- package/dist/injection-registry.js +103 -0
- 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 +149 -64
- package/src/foreign-threads.ts +187 -0
- package/src/index.ts +12 -1
- package/src/injection-registry.ts +100 -0
- 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
|
@@ -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
|
|
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
|
}
|