@parall/codex-agent 1.45.0 → 1.46.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/dispatch.d.ts +58 -13
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +343 -67
- package/dist/index.js +10 -8
- package/dist/instructions-refresh.d.ts +136 -0
- package/dist/instructions-refresh.d.ts.map +1 -0
- package/dist/instructions-refresh.js +244 -0
- package/dist/jsonrpc-client.d.ts +49 -1
- package/dist/jsonrpc-client.d.ts.map +1 -1
- package/dist/jsonrpc-client.js +77 -5
- package/dist/server-requests.d.ts +10 -0
- package/dist/server-requests.d.ts.map +1 -0
- package/dist/server-requests.js +39 -0
- package/dist/session-manager.d.ts +12 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +53 -3
- package/dist/turn-sink.d.ts +3 -0
- package/dist/turn-sink.d.ts.map +1 -1
- package/dist/turn-sink.js +5 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +5 -6
- package/package.json +4 -4
- package/src/dispatch.ts +388 -83
- package/src/index.ts +10 -7
- package/src/instructions-refresh.ts +367 -0
- package/src/jsonrpc-client.ts +109 -7
- package/src/server-requests.ts +40 -0
- package/src/session-manager.ts +74 -6
- package/src/turn-sink.ts +6 -0
- package/src/workspace.ts +5 -6
package/dist/dispatch.js
CHANGED
|
@@ -2,10 +2,12 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { appendPreparedLocalAttachmentRefs, pinLocalAttachmentPaths, } from '@parall/agent-core/internal/attachment-input';
|
|
5
|
-
import {
|
|
5
|
+
import { ensureGitRepo, IS_WIN32, killWin32Tree, quoteWin32Arg } from './app-server-process.js';
|
|
6
6
|
import { buildTurnInput, extractThreadId, extractThreadIdFromNotification, extractTurnId, } from './app-server-protocol.js';
|
|
7
7
|
import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
|
|
8
|
+
import { MainThreadInstructionsRefresher, } from './instructions-refresh.js';
|
|
8
9
|
import { JsonRpcStdioClient } from './jsonrpc-client.js';
|
|
10
|
+
import { answerServerRequest } from './server-requests.js';
|
|
9
11
|
import { TurnSink } from './turn-sink.js';
|
|
10
12
|
/**
|
|
11
13
|
* Bridge driver backed by `codex app-server --listen stdio://`.
|
|
@@ -31,7 +33,22 @@ export class CodexAppServerAdapter {
|
|
|
31
33
|
activeTurnIds = new Map();
|
|
32
34
|
pendingInjections = new Map();
|
|
33
35
|
resumedThreadIds = new Set();
|
|
36
|
+
/** Threads whose reconcile compaction is in flight — tap-only traffic. */
|
|
37
|
+
reconcilingThreadIds = new Set();
|
|
38
|
+
/**
|
|
39
|
+
* Set when a stalled compaction left (possibly) a zombie turn on the main
|
|
40
|
+
* thread. Until the subprocess ACTUALLY restarts, main-lane redrives must
|
|
41
|
+
* not open/compact/turn-start the thread: re-entering the zombie process
|
|
42
|
+
* lets a second compaction's watcher misattribute the zombie's close as a
|
|
43
|
+
* plain failure, dropping the stalled marker and re-arming the
|
|
44
|
+
* fresh-thread rotation this state exists to prevent. Cleared where the
|
|
45
|
+
* zombie dies: subprocess teardown.
|
|
46
|
+
*/
|
|
47
|
+
mainLaneQuarantined = false;
|
|
48
|
+
notificationTaps = new Set();
|
|
49
|
+
instructionsRefresher;
|
|
34
50
|
stopping = false;
|
|
51
|
+
lastUnroutedNotificationWarnAt = 0;
|
|
35
52
|
/**
|
|
36
53
|
* Store an active turn sink keyed by threadId. If a sink already exists for
|
|
37
54
|
* the same threadId, log a warning and fail the existing sink — this
|
|
@@ -51,6 +68,33 @@ export class CodexAppServerAdapter {
|
|
|
51
68
|
}
|
|
52
69
|
constructor(opts) {
|
|
53
70
|
this.opts = opts;
|
|
71
|
+
this.instructionsRefresher = new MainThreadInstructionsRefresher({
|
|
72
|
+
sessionManager: opts.sessionManager,
|
|
73
|
+
log: opts.log,
|
|
74
|
+
compactTimeoutMs: opts.instructionsCompactTimeoutMs,
|
|
75
|
+
interruptGraceMs: opts.instructionsInterruptGraceMs,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Register a listener for every server notification; returns unregister. */
|
|
79
|
+
addNotificationTap(tap) {
|
|
80
|
+
this.notificationTaps.add(tap);
|
|
81
|
+
return () => this.notificationTaps.delete(tap);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Tell tap waiters the subprocess is gone so they abort immediately —
|
|
85
|
+
* no further notifications will ever arrive, and a waiter left to its
|
|
86
|
+
* own timeout would stall its dispatch for the full compaction budget.
|
|
87
|
+
* Mirrors the global-error broadcast routeNotification does for sinks.
|
|
88
|
+
*/
|
|
89
|
+
notifyTapsDisposed(reason) {
|
|
90
|
+
for (const tap of this.notificationTaps) {
|
|
91
|
+
try {
|
|
92
|
+
tap('error', { message: reason });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Taps must never break teardown.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
54
98
|
}
|
|
55
99
|
updateConfig(config) {
|
|
56
100
|
if (config.model !== undefined)
|
|
@@ -90,8 +134,23 @@ export class CodexAppServerAdapter {
|
|
|
90
134
|
if (!threadId)
|
|
91
135
|
return;
|
|
92
136
|
const sink = this.activeTurns.get(threadId);
|
|
93
|
-
|
|
137
|
+
// Idempotence guard (DispatchAdapter contract): the deadline timer and
|
|
138
|
+
// the lane-flow error path can both abort the same dispatch before the
|
|
139
|
+
// generator's cleanup empties activeTurns — keying on the sink's own
|
|
140
|
+
// terminal state makes the repeat a full no-op (one turn/interrupt on
|
|
141
|
+
// the wire, not two) and also dedupes against dispose/process-exit
|
|
142
|
+
// having already closed the sink.
|
|
143
|
+
if (!sink || sink.isClosed)
|
|
94
144
|
return;
|
|
145
|
+
// Best-effort runtime-side stop: closing only the local sink leaves the
|
|
146
|
+
// app-server turn running (or parked on a server request) after the
|
|
147
|
+
// gateway has abandoned the dispatch.
|
|
148
|
+
const turnId = this.activeTurnIds.get(threadId);
|
|
149
|
+
if (turnId && this.client && !this.client.isDisposed()) {
|
|
150
|
+
this.client
|
|
151
|
+
.sendRequest('turn/interrupt', { threadId, turnId })
|
|
152
|
+
.catch((err) => this.opts.log?.warn?.(`turn/interrupt failed: ${errToString(err)}`));
|
|
153
|
+
}
|
|
95
154
|
sink.push({ kind: 'error', message: 'dispatch deadline exceeded' });
|
|
96
155
|
sink.close();
|
|
97
156
|
}
|
|
@@ -114,69 +173,85 @@ export class CodexAppServerAdapter {
|
|
|
114
173
|
}
|
|
115
174
|
(this.opts.log ?? context.log)?.warn?.(`pending steer invalidated (subprocess died); falling through to normal dispatch`);
|
|
116
175
|
}
|
|
117
|
-
await this.applyPendingRestart(context.log);
|
|
118
|
-
await this.ensureStarted(context.log);
|
|
119
|
-
// After ensureStarted resolves the subprocess could still die before we
|
|
120
|
-
// capture the client (handleSubprocessClose nulls this.client). Yield a
|
|
121
|
-
// clean error event instead of relying on a non-null assertion that would
|
|
122
|
-
// throw a TypeError on the next sendRequest call.
|
|
123
|
-
const client = this.client;
|
|
124
|
-
if (!client) {
|
|
125
|
-
yield {
|
|
126
|
-
type: 'error',
|
|
127
|
-
message: 'Codex app-server not available (subprocess died during dispatch start)',
|
|
128
|
-
};
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
const log = this.opts.log ?? context.log;
|
|
132
176
|
const isMainSession = this.opts.sessionManager.isMain(sessionKey);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
yield {
|
|
177
|
+
if (isMainSession && this.mainLaneQuarantined) {
|
|
178
|
+
// Try the pending restart now — it may have been deferred behind an
|
|
179
|
+
// active fork turn when the quarantine was raised. If it still cannot
|
|
180
|
+
// run, fail this redrive WITHOUT touching the thread.
|
|
181
|
+
await this.applyPendingRestart(context.log);
|
|
182
|
+
if (this.mainLaneQuarantined) {
|
|
183
|
+
const quarantinedThreadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
184
|
+
if (quarantinedThreadId) {
|
|
185
|
+
yield {
|
|
186
|
+
type: 'runtime_session',
|
|
187
|
+
runtimeSessionId: quarantinedThreadId,
|
|
188
|
+
runtimeLaneKey: sessionKey,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
yield {
|
|
192
|
+
type: 'error',
|
|
193
|
+
message: 'main lane quarantined after a stalled compaction; the subprocess restart is still deferred behind an active turn — the thread is retained and this dispatch will be redriven',
|
|
194
|
+
};
|
|
148
195
|
return;
|
|
149
196
|
}
|
|
150
197
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
198
|
+
const liveThreadId = isMainSession
|
|
199
|
+
? this.opts.sessionManager.getThreadId(sessionKey)
|
|
200
|
+
: undefined;
|
|
201
|
+
if (liveThreadId &&
|
|
202
|
+
this.opts.developerInstructions &&
|
|
203
|
+
this.resumedThreadIds.has(liveThreadId) &&
|
|
204
|
+
this.instructionsRefresher.canonicalFor(liveThreadId) !== this.opts.developerInstructions) {
|
|
205
|
+
// Live thread opened with an older prompt: compacting now would
|
|
206
|
+
// rebuild that STALE text, so bounce the subprocess — only a
|
|
207
|
+
// fresh-process resume updates the canonical configuration. The
|
|
208
|
+
// platform-config refresh normally schedules this restart itself;
|
|
209
|
+
// this guard makes convergence independent of that ordering.
|
|
210
|
+
this.requestProcessRestart();
|
|
211
|
+
}
|
|
212
|
+
const log = this.opts.log ?? context.log;
|
|
213
|
+
let client;
|
|
214
|
+
let threadId;
|
|
215
|
+
let reconcileStalled = false;
|
|
216
|
+
for (let attempt = 0;; attempt++) {
|
|
217
|
+
await this.applyPendingRestart(context.log);
|
|
218
|
+
// Opening counts as active work for restart gating (see
|
|
219
|
+
// applyPendingRestart): the main-session reconcile can run a real
|
|
220
|
+
// compaction turn BEFORE any TurnSink exists, and a concurrent fork
|
|
221
|
+
// dispatch hitting a pending restart in that window would otherwise
|
|
222
|
+
// stop() the subprocess out from under it.
|
|
223
|
+
this.openingDispatches += 1;
|
|
224
|
+
let opened;
|
|
157
225
|
try {
|
|
158
|
-
|
|
159
|
-
this.opts.sessionManager.recordThreadId(sessionKey, threadId);
|
|
160
|
-
this.resumedThreadIds.add(threadId);
|
|
226
|
+
opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
|
|
161
227
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
228
|
+
finally {
|
|
229
|
+
this.openingDispatches -= 1;
|
|
230
|
+
}
|
|
231
|
+
if (!opened.ok) {
|
|
232
|
+
yield { type: 'error', message: opened.message };
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
client = opened.client;
|
|
236
|
+
threadId = opened.threadId;
|
|
237
|
+
reconcileStalled = opened.reconcile === 'stalled';
|
|
238
|
+
// Re-check convergence AFTER the async open/reconcile work: on the cold
|
|
239
|
+
// path (spawn + resume + compaction) that window is seconds, and a
|
|
240
|
+
// platform-config update landing inside it would otherwise run this
|
|
241
|
+
// turn on the prompt captured at open time. One bounded retry loops
|
|
242
|
+
// back through the restart path with the newer prompt. If the restart
|
|
243
|
+
// stays deferred (a fork turn is active) or a second update lands
|
|
244
|
+
// during the retry, the turn proceeds and the next dispatch converges —
|
|
245
|
+
// an update after this point is equivalent to one arriving mid-turn.
|
|
246
|
+
if (isMainSession &&
|
|
247
|
+
attempt === 0 &&
|
|
248
|
+
this.opts.developerInstructions &&
|
|
249
|
+
this.instructionsRefresher.canonicalFor(threadId) !== this.opts.developerInstructions) {
|
|
250
|
+
log?.info?.('platform instructions changed while the dispatch was opening the thread; bouncing the subprocess to converge before this turn');
|
|
251
|
+
this.requestProcessRestart();
|
|
252
|
+
continue;
|
|
179
253
|
}
|
|
254
|
+
break;
|
|
180
255
|
}
|
|
181
256
|
const sink = new TurnSink();
|
|
182
257
|
this.setActiveTurn(threadId, sink, log);
|
|
@@ -255,8 +330,33 @@ export class CodexAppServerAdapter {
|
|
|
255
330
|
yield { type: 'error', message: `Codex turn/start failed: ${message}` };
|
|
256
331
|
return;
|
|
257
332
|
}
|
|
333
|
+
if (reconcileStalled) {
|
|
334
|
+
// A stalled compaction may STILL be running on this thread — a
|
|
335
|
+
// busy-thread rejection here is expected, not evidence of a stale
|
|
336
|
+
// thread, and the fresh-thread retry below would ROTATE the
|
|
337
|
+
// persisted main thread (continuity loss). Quarantine the main
|
|
338
|
+
// lane (redrives must not touch the thread until the subprocess
|
|
339
|
+
// actually restarts — a deferred restart would otherwise send the
|
|
340
|
+
// NEXT dispatch back into the zombie, where a second compaction's
|
|
341
|
+
// watcher can misattribute the zombie's close as a plain failure
|
|
342
|
+
// and lose the stalled marker) and bounce the subprocess.
|
|
343
|
+
this.mainLaneQuarantined = true;
|
|
344
|
+
this.requestProcessRestart();
|
|
345
|
+
this.activeTurns.delete(threadId);
|
|
346
|
+
yield {
|
|
347
|
+
type: 'runtime_session',
|
|
348
|
+
runtimeSessionId: threadId,
|
|
349
|
+
runtimeLaneKey: sessionKey,
|
|
350
|
+
};
|
|
351
|
+
yield {
|
|
352
|
+
type: 'error',
|
|
353
|
+
message: `Codex turn/start failed after a stalled compaction (thread retained; subprocess will restart): ${message}`,
|
|
354
|
+
};
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
258
357
|
log?.warn?.(`turn/start on thread ${threadId} failed (${message}); attempting one-shot fresh-thread retry`);
|
|
259
358
|
this.activeTurns.delete(threadId);
|
|
359
|
+
const sentInstructions = this.opts.developerInstructions;
|
|
260
360
|
let freshThreadId;
|
|
261
361
|
try {
|
|
262
362
|
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
@@ -296,7 +396,7 @@ export class CodexAppServerAdapter {
|
|
|
296
396
|
// Retry accepted — the original thread really was unusable. Now safe
|
|
297
397
|
// to discard the old persisted id and persist the fresh one.
|
|
298
398
|
this.opts.sessionManager.clearMainThread();
|
|
299
|
-
this.
|
|
399
|
+
this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
|
|
300
400
|
this.resumedThreadIds.add(freshThreadId);
|
|
301
401
|
threadId = freshThreadId;
|
|
302
402
|
}
|
|
@@ -348,6 +448,109 @@ export class CodexAppServerAdapter {
|
|
|
348
448
|
}
|
|
349
449
|
}
|
|
350
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Bring the app-server up and open (start or resume) the thread for this
|
|
453
|
+
* dispatch, then reconcile the main thread's EFFECTIVE instructions plane.
|
|
454
|
+
* Extracted from dispatch() so the convergence re-check there can loop back
|
|
455
|
+
* through the restart path without duplicating the open logic. Errors are
|
|
456
|
+
* returned, not thrown — the generator yields them as runtime events.
|
|
457
|
+
*/
|
|
458
|
+
async openDispatchTarget(sessionKey, isMainSession, log) {
|
|
459
|
+
try {
|
|
460
|
+
await this.ensureStarted(log);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
// Spawn/handshake failures surface as a clean error event like every
|
|
464
|
+
// other open failure, instead of rejecting the dispatch generator.
|
|
465
|
+
return { ok: false, message: `Codex app-server failed to start: ${errToString(err)}` };
|
|
466
|
+
}
|
|
467
|
+
// After ensureStarted resolves the subprocess could still die before we
|
|
468
|
+
// capture the client (handleSubprocessClose nulls this.client). Return a
|
|
469
|
+
// clean error instead of relying on a non-null assertion that would
|
|
470
|
+
// throw a TypeError on the next sendRequest call.
|
|
471
|
+
const client = this.client;
|
|
472
|
+
if (!client) {
|
|
473
|
+
return {
|
|
474
|
+
ok: false,
|
|
475
|
+
message: 'Codex app-server not available (subprocess died during dispatch start)',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
let threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
479
|
+
if (!threadId) {
|
|
480
|
+
try {
|
|
481
|
+
// Capture what openThread will send in the same tick as the call, so
|
|
482
|
+
// the recorded canonical/effective values can never reflect a NEWER
|
|
483
|
+
// instructions text swapped in by updateConfig() mid-flight.
|
|
484
|
+
const sentInstructions = this.opts.developerInstructions;
|
|
485
|
+
threadId = await this.openThread(client, { resumeId: undefined });
|
|
486
|
+
// recordBaked persists thread id + effective sha in ONE write.
|
|
487
|
+
this.instructionsRefresher.recordBaked(sessionKey, threadId, sentInstructions);
|
|
488
|
+
// Mark this freshly-started thread as already live in the current
|
|
489
|
+
// app-server process. Without this, the second dispatch after a cold
|
|
490
|
+
// start would enter the thread/resume branch below for a thread this
|
|
491
|
+
// process just created — and if the server treats resume-of-just-
|
|
492
|
+
// created-thread as an attach-to-detached-thread operation, it could
|
|
493
|
+
// fail the resume path and replace the thread, losing the first turn.
|
|
494
|
+
this.resumedThreadIds.add(threadId);
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
return { ok: false, message: `Codex thread/start failed: ${errToString(err)}` };
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
|
|
501
|
+
// We have a persisted threadId from a previous bridge run — resume it.
|
|
502
|
+
// Mirror the deferred-clear pattern from the turn/start retry in
|
|
503
|
+
// dispatch(): only discard the persisted id once a fresh-thread start
|
|
504
|
+
// has actually succeeded, so a transient resume failure (network/
|
|
505
|
+
// timeout/upstream hiccup) doesn't permanently throw away the prior
|
|
506
|
+
// conversation context.
|
|
507
|
+
const sentInstructions = this.opts.developerInstructions;
|
|
508
|
+
try {
|
|
509
|
+
threadId = await this.openThread(client, { resumeId: threadId });
|
|
510
|
+
this.opts.sessionManager.recordThreadId(sessionKey, threadId);
|
|
511
|
+
this.instructionsRefresher.recordResumed(threadId, sentInstructions);
|
|
512
|
+
this.resumedThreadIds.add(threadId);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
log?.warn?.(`thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`);
|
|
516
|
+
let freshThreadId;
|
|
517
|
+
try {
|
|
518
|
+
freshThreadId = await this.openThread(client, { resumeId: undefined });
|
|
519
|
+
}
|
|
520
|
+
catch (innerErr) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
this.opts.sessionManager.clearMainThread();
|
|
527
|
+
this.instructionsRefresher.recordBaked(sessionKey, freshThreadId, sentInstructions);
|
|
528
|
+
this.resumedThreadIds.add(freshThreadId);
|
|
529
|
+
threadId = freshThreadId;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
let reconcile;
|
|
533
|
+
if (isMainSession) {
|
|
534
|
+
// Converge the EFFECTIVE plane before the turn starts (posture incl.
|
|
535
|
+
// the stalled exception: instructions-refresh.ts). Cheap compare when
|
|
536
|
+
// nothing changed; one thread/compact/start when the last-known
|
|
537
|
+
// effective instructions differ from the thread's canonical config.
|
|
538
|
+
this.reconcilingThreadIds.add(threadId);
|
|
539
|
+
try {
|
|
540
|
+
reconcile = await this.instructionsRefresher.reconcileAfterOpen({
|
|
541
|
+
client,
|
|
542
|
+
taps: this,
|
|
543
|
+
sessionKey,
|
|
544
|
+
threadId,
|
|
545
|
+
log,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
this.reconcilingThreadIds.delete(threadId);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { ok: true, client, threadId, reconcile };
|
|
553
|
+
}
|
|
351
554
|
getBranchPoint(_sessionKey) {
|
|
352
555
|
// Codex app-server does not expose a branch-point API; fork scope prefix
|
|
353
556
|
// provides the behavioral fallback for this runtime.
|
|
@@ -365,6 +568,14 @@ export class CodexAppServerAdapter {
|
|
|
365
568
|
const forkParams = {
|
|
366
569
|
threadId: parentThreadId,
|
|
367
570
|
ephemeral: true,
|
|
571
|
+
// Forks must inherit the bridge's approval/sandbox contract. thread/fork
|
|
572
|
+
// does NOT inherit them from the parent thread — omitting them falls
|
|
573
|
+
// back to codex defaults (on-request approval, workspace-write), and an
|
|
574
|
+
// escalated command then emits an approval request the headless bridge
|
|
575
|
+
// can only deny, killing the fork's ability to run privileged commands
|
|
576
|
+
// the main session runs freely.
|
|
577
|
+
approvalPolicy: normalizeApprovalPolicy(this.opts.approvalPolicy),
|
|
578
|
+
sandbox: normalizeSandbox(this.opts.sandbox),
|
|
368
579
|
};
|
|
369
580
|
if (this.opts.useParallProvider) {
|
|
370
581
|
forkParams.modelProvider = 'parall';
|
|
@@ -406,19 +617,28 @@ export class CodexAppServerAdapter {
|
|
|
406
617
|
* to the next dispatch with no active turns — never kills an in-flight turn;
|
|
407
618
|
* thread state survives via thread/resume.
|
|
408
619
|
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
620
|
+
* For the persisted main thread this restart is also the CANONICAL half of
|
|
621
|
+
* the instructions refresh: only a fresh-process thread/resume applies the
|
|
622
|
+
* updated developerInstructions to the thread's configuration (a resume of
|
|
623
|
+
* a still-running thread ignores it). The EFFECTIVE half is the explicit
|
|
624
|
+
* compaction in instructions-refresh.ts. The capability shim dir on PATH is
|
|
625
|
+
* what makes a grant/revocation's TOOLS effective immediately.
|
|
412
626
|
*/
|
|
413
627
|
requestProcessRestart() {
|
|
414
628
|
this.restartRequested = true;
|
|
415
629
|
}
|
|
416
630
|
restartRequested = false;
|
|
631
|
+
/**
|
|
632
|
+
* Dispatches currently inside their open/reconcile phase — real work on
|
|
633
|
+
* the subprocess (thread open, possibly a compaction turn) that predates
|
|
634
|
+
* any TurnSink registration, so activeTurns alone cannot gate the restart.
|
|
635
|
+
*/
|
|
636
|
+
openingDispatches = 0;
|
|
417
637
|
async applyPendingRestart(log) {
|
|
418
|
-
if (!this.restartRequested || this.activeTurns.size > 0)
|
|
638
|
+
if (!this.restartRequested || this.activeTurns.size > 0 || this.openingDispatches > 0)
|
|
419
639
|
return;
|
|
420
640
|
this.restartRequested = false;
|
|
421
|
-
(log ?? this.opts.log)?.info?.('restarting codex app-server after a capability change (
|
|
641
|
+
(log ?? this.opts.log)?.info?.('restarting codex app-server after a capability change (the fresh-process thread/resume applies the refreshed developerInstructions to the persisted thread configuration; the model-visible context is reconciled before the next turn)');
|
|
422
642
|
await this.stop();
|
|
423
643
|
}
|
|
424
644
|
async stop() {
|
|
@@ -436,6 +656,10 @@ export class CodexAppServerAdapter {
|
|
|
436
656
|
// dispatch treat the persisted thread as already resumed in the NEW
|
|
437
657
|
// process and lose conversation continuity when turn/start rejects.
|
|
438
658
|
this.resumedThreadIds.clear();
|
|
659
|
+
this.instructionsRefresher.clearThreadState();
|
|
660
|
+
// The (possible) zombie compaction turn dies with the subprocess.
|
|
661
|
+
this.mainLaneQuarantined = false;
|
|
662
|
+
this.notifyTapsDisposed('Codex app-server stopped');
|
|
439
663
|
if (client)
|
|
440
664
|
client.dispose(new Error('adapter stopped'));
|
|
441
665
|
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
@@ -457,6 +681,7 @@ export class CodexAppServerAdapter {
|
|
|
457
681
|
this.proc = null;
|
|
458
682
|
this.initialized = false;
|
|
459
683
|
this.resumedThreadIds.clear();
|
|
684
|
+
this.instructionsRefresher.clearThreadState();
|
|
460
685
|
}
|
|
461
686
|
if (this.initialized && this.client)
|
|
462
687
|
return;
|
|
@@ -478,6 +703,7 @@ export class CodexAppServerAdapter {
|
|
|
478
703
|
// previous stop() — otherwise handleSubprocessClose would mislabel the next
|
|
479
704
|
// unexpected exit as "during graceful stop".
|
|
480
705
|
this.stopping = false;
|
|
706
|
+
this.instructionsRefresher.resetForNewSubprocess();
|
|
481
707
|
ensureGitRepo(this.opts.workspaceDir);
|
|
482
708
|
// Only steer Codex's own state via CODEX_HOME. Leaving HOME untouched
|
|
483
709
|
// preserves the user's real dotfiles for any subprocess Codex spawns
|
|
@@ -544,6 +770,24 @@ export class CodexAppServerAdapter {
|
|
|
544
770
|
}
|
|
545
771
|
});
|
|
546
772
|
client.setNotificationHandler((method, params) => this.routeNotification(method, params));
|
|
773
|
+
// Server→client requests (approvals, elicitations) can never be answered
|
|
774
|
+
// interactively by a headless bridge, but each one MUST get a response —
|
|
775
|
+
// the app-server parks the requesting turn until one arrives. Known
|
|
776
|
+
// approval methods get an explicit denial; anything else falls through to
|
|
777
|
+
// the client's method-not-found error. With approvalPolicy "never" these
|
|
778
|
+
// should not occur at all, so surface every occurrence at warn.
|
|
779
|
+
client.setServerRequestHandler((method, params) => {
|
|
780
|
+
const denial = answerServerRequest(method);
|
|
781
|
+
// Log stable identifiers only — approval params carry command lines /
|
|
782
|
+
// file changes / patch content that must not reach stderr or OTLP.
|
|
783
|
+
const p = (params ?? {});
|
|
784
|
+
const ids = ['threadId', 'turnId', 'itemId', 'callId']
|
|
785
|
+
.filter((k) => typeof p[k] === 'string')
|
|
786
|
+
.map((k) => `${k}=${p[k]}`)
|
|
787
|
+
.join(' ');
|
|
788
|
+
(log ?? this.opts.log)?.warn?.(`answering app-server request "${method}" with ${denial === undefined ? 'method-not-found error' : 'denial'} (headless bridge cannot approve)${ids ? ` [${ids}]` : ''}`);
|
|
789
|
+
return denial === undefined ? undefined : { result: denial };
|
|
790
|
+
});
|
|
547
791
|
proc.once('close', (code, signal) => this.handleSubprocessClose(proc, code, signal, log));
|
|
548
792
|
proc.once('error', (err) => this.handleSubprocessClose(proc, null, null, log, err));
|
|
549
793
|
try {
|
|
@@ -592,6 +836,9 @@ export class CodexAppServerAdapter {
|
|
|
592
836
|
this.activeTurnIds.clear();
|
|
593
837
|
this.pendingInjections.clear();
|
|
594
838
|
this.resumedThreadIds.clear();
|
|
839
|
+
this.instructionsRefresher.clearThreadState();
|
|
840
|
+
this.mainLaneQuarantined = false;
|
|
841
|
+
this.notifyTapsDisposed(`Codex app-server ${reason}`);
|
|
595
842
|
this.client = null;
|
|
596
843
|
this.proc = null;
|
|
597
844
|
this.initialized = false;
|
|
@@ -616,8 +863,9 @@ export class CodexAppServerAdapter {
|
|
|
616
863
|
if (this.opts.developerInstructions) {
|
|
617
864
|
// Typed top-level param (camelCase), NOT a raw config.toml override —
|
|
618
865
|
// trust-independent, so the platform prompt loads regardless of any codex
|
|
619
|
-
// workspace-trust state. thread/start
|
|
620
|
-
// thread
|
|
866
|
+
// workspace-trust state. thread/start bakes it in; a fresh-process
|
|
867
|
+
// thread/resume applies it to the canonical configuration — see the
|
|
868
|
+
// option doc for the two-plane semantics.
|
|
621
869
|
commonParams.developerInstructions = this.opts.developerInstructions;
|
|
622
870
|
}
|
|
623
871
|
if (this.opts.model)
|
|
@@ -644,6 +892,17 @@ export class CodexAppServerAdapter {
|
|
|
644
892
|
return threadId;
|
|
645
893
|
}
|
|
646
894
|
routeNotification(method, params) {
|
|
895
|
+
// Taps observe the raw stream (instructions refresh awaits its compaction
|
|
896
|
+
// turn through one); the sink routing below is unaffected — including by
|
|
897
|
+
// a tap that throws, which must never break turn delivery.
|
|
898
|
+
for (const tap of this.notificationTaps) {
|
|
899
|
+
try {
|
|
900
|
+
tap(method, params);
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
this.opts.log?.warn?.(`notification tap threw: ${errToString(err)}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
647
906
|
const threadId = extractThreadIdFromNotification(params);
|
|
648
907
|
if (!threadId) {
|
|
649
908
|
// Surface server-initiated generic errors to every active turn.
|
|
@@ -656,8 +915,25 @@ export class CodexAppServerAdapter {
|
|
|
656
915
|
return;
|
|
657
916
|
}
|
|
658
917
|
const sink = this.activeTurns.get(threadId);
|
|
659
|
-
if (!sink)
|
|
918
|
+
if (!sink) {
|
|
919
|
+
// A reconciling thread's compaction turn is INTENTIONALLY tap-only —
|
|
920
|
+
// it runs before any TurnSink exists. Warning here would pollute every
|
|
921
|
+
// successful instructions refresh and burn the shared rate-limit
|
|
922
|
+
// window that exists to surface REAL orphaned turns.
|
|
923
|
+
if (this.reconcilingThreadIds.has(threadId))
|
|
924
|
+
return;
|
|
925
|
+
// Notifications for a thread with no registered sink are dropped. A
|
|
926
|
+
// sustained stream of these means a turn is running that nothing is
|
|
927
|
+
// consuming (threadId mismatch, sink torn down early) — the dispatch
|
|
928
|
+
// side would see indefinite silence, so keep the drop visible
|
|
929
|
+
// (rate-limited: these arrive per-delta during an orphaned turn).
|
|
930
|
+
const now = Date.now();
|
|
931
|
+
if (now - this.lastUnroutedNotificationWarnAt > 10_000) {
|
|
932
|
+
this.lastUnroutedNotificationWarnAt = now;
|
|
933
|
+
this.opts.log?.warn?.(`dropping notification "${method}" for unknown thread ${threadId}`);
|
|
934
|
+
}
|
|
660
935
|
return;
|
|
936
|
+
}
|
|
661
937
|
// For turn/completed we still run the mapper first — it emits a
|
|
662
938
|
// RuntimeEvent error if `turn.status === "failed"`. Enqueue those events
|
|
663
939
|
// before the turn_end sentinel so the dispatch loop can yield them.
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as os from 'node:os';
|
|
3
|
-
import { ParallAgentGateway, capabilityBinDir, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, initAgentTelemetry, } from '@parall/agent-core';
|
|
3
|
+
import { ParallAgentGateway, capabilityBinDir, configureHttpKeepAlive, createPlatformConfigManager, createLogger, createOtelLogger, childLogger, deriveModelIsPin, materializeChannelCapabilities, resolveRuntimeModel, parseShutdownDeadlineMs, parseForkDeadlineMs, parseDispatchDeadlineMs, parseProviderConfig, clearAllProviderCreds, llmSource, initAgentTelemetry, } from '@parall/agent-core';
|
|
4
4
|
import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
|
|
5
5
|
import { buildCodexRuntimeKey, contextFilePathForSession, dispatchContextDirPath, resolveCodexAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from './config.js';
|
|
6
6
|
import { CodexAppServerAdapter } from './dispatch.js';
|
|
@@ -38,6 +38,8 @@ function resolveProviderEnv() {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
async function main() {
|
|
41
|
+
// Before any fetch: long-lived HTTP connections for every bridge→api call.
|
|
42
|
+
configureHttpKeepAlive();
|
|
41
43
|
const telemetry = await initAgentTelemetry('parall-codex-agent', 'codex');
|
|
42
44
|
activeLog = createOtelLogger('agent', 'codex-agent');
|
|
43
45
|
try {
|
|
@@ -155,13 +157,13 @@ async function main() {
|
|
|
155
157
|
//
|
|
156
158
|
// What each half of the refresh actually reaches: the shim dir on PATH
|
|
157
159
|
// makes the granted TOOL work on the agent's next shell command, live,
|
|
158
|
-
// no restart needed. The refreshed PROMPT reaches
|
|
159
|
-
//
|
|
160
|
-
// thread
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
160
|
+
// no restart needed. The refreshed PROMPT reaches (a) every thread
|
|
161
|
+
// started from here on (thread/start bakes it in), and (b) the
|
|
162
|
+
// persisted main thread on its next safe dispatch: the restart below
|
|
163
|
+
// makes the next open a fresh-process thread/resume, which applies the
|
|
164
|
+
// new value to the thread's canonical configuration, and the adapter
|
|
165
|
+
// then compacts the thread so the model-visible context is rebuilt from
|
|
166
|
+
// it (two-plane semantics: src/instructions-refresh.ts).
|
|
165
167
|
const fragments = applyChannelCapabilities();
|
|
166
168
|
const joinedFragments = fragments.join('\n\n');
|
|
167
169
|
let promptWritten = true;
|