@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
package/dist/session-manager.js
CHANGED
|
@@ -24,6 +24,13 @@ export class CodexSessionManager {
|
|
|
24
24
|
getThreadId(sessionKey) {
|
|
25
25
|
return this.threadIds.get(sessionKey);
|
|
26
26
|
}
|
|
27
|
+
/** The session (main or fork) that owns a thread the bridge opened. */
|
|
28
|
+
getSessionKey(threadId) {
|
|
29
|
+
for (const [sessionKey, id] of this.threadIds)
|
|
30
|
+
if (id === threadId)
|
|
31
|
+
return sessionKey;
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
27
34
|
recordThreadId(sessionKey, threadId) {
|
|
28
35
|
// An effective-instructions sha is a fact about one specific thread.
|
|
29
36
|
// Recording a DIFFERENT thread id under the same session invalidates it;
|
package/dist/turn-sink.d.ts
CHANGED
|
@@ -10,13 +10,15 @@ export type TurnEventEnvelope = {
|
|
|
10
10
|
kind: 'error';
|
|
11
11
|
message: string;
|
|
12
12
|
};
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Per-turn buffered sink between notification routing and the turn's
|
|
15
|
+
* drain. Envelopes queued before close() still drain (a final error pushed
|
|
16
|
+
* by handleSubprocessClose must reach the consumer before turn_end).
|
|
17
|
+
*/
|
|
14
18
|
export declare class TurnSink {
|
|
15
19
|
private readonly noteActivity?;
|
|
16
20
|
readonly mapper: EventMapper;
|
|
17
21
|
private readonly queue;
|
|
18
|
-
private resolver;
|
|
19
|
-
private closed;
|
|
20
22
|
constructor(noteActivity?: (() => void) | undefined);
|
|
21
23
|
touchActivity(): void;
|
|
22
24
|
push(envelope: TurnEventEnvelope): void;
|
package/dist/turn-sink.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-sink.d.ts","sourceRoot":"","sources":["../src/turn-sink.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"turn-sink.d.ts","sourceRoot":"","sources":["../src/turn-sink.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEjD,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,YAAY,CAAA;CAAE,GACxC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC;;;;GAIG;AACH,qBAAa,QAAQ;IAIP,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAH1C,QAAQ,CAAC,MAAM,cAAqB;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;gBAEhC,YAAY,CAAC,GAAE,MAAM,IAAI,aAAA;IAEtD,aAAa;IAIb,IAAI,CAAC,QAAQ,EAAE,iBAAiB;IAI1B,IAAI,IAAI,OAAO,CAAC,iBAAiB,CAAC;IAKxC,KAAK;IAIL;kCAC8B;IAC9B,IAAI,QAAQ,IAAI,OAAO,CAEtB;CACF"}
|
package/dist/turn-sink.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { AsyncQueue } from '@parall/agent-core/internal/async-queue';
|
|
1
2
|
import { EventMapper } from './event-mapping.js';
|
|
2
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Per-turn buffered sink between notification routing and the turn's
|
|
5
|
+
* drain. Envelopes queued before close() still drain (a final error pushed
|
|
6
|
+
* by handleSubprocessClose must reach the consumer before turn_end).
|
|
7
|
+
*/
|
|
3
8
|
export class TurnSink {
|
|
4
9
|
noteActivity;
|
|
5
10
|
mapper = new EventMapper();
|
|
6
|
-
queue =
|
|
7
|
-
resolver = null;
|
|
8
|
-
closed = false;
|
|
11
|
+
queue = new AsyncQueue();
|
|
9
12
|
constructor(noteActivity) {
|
|
10
13
|
this.noteActivity = noteActivity;
|
|
11
14
|
}
|
|
@@ -13,40 +16,18 @@ export class TurnSink {
|
|
|
13
16
|
this.noteActivity?.();
|
|
14
17
|
}
|
|
15
18
|
push(envelope) {
|
|
16
|
-
if (this.closed)
|
|
17
|
-
return;
|
|
18
|
-
if (this.resolver) {
|
|
19
|
-
const r = this.resolver;
|
|
20
|
-
this.resolver = null;
|
|
21
|
-
r(envelope);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
19
|
this.queue.push(envelope);
|
|
25
20
|
}
|
|
26
|
-
next() {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// handleSubprocessClose) is silently dropped because the consumer would
|
|
30
|
-
// see turn_end before it.
|
|
31
|
-
const pending = this.queue.shift();
|
|
32
|
-
if (pending)
|
|
33
|
-
return Promise.resolve(pending);
|
|
34
|
-
if (this.closed) {
|
|
35
|
-
return Promise.resolve({ kind: 'turn_end' });
|
|
36
|
-
}
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
this.resolver = resolve;
|
|
39
|
-
});
|
|
21
|
+
async next() {
|
|
22
|
+
const result = await this.queue.next();
|
|
23
|
+
return result.done ? { kind: 'turn_end' } : result.value;
|
|
40
24
|
}
|
|
41
25
|
close() {
|
|
42
|
-
this.
|
|
43
|
-
const r = this.resolver;
|
|
44
|
-
this.resolver = null;
|
|
45
|
-
r?.({ kind: 'turn_end' });
|
|
26
|
+
this.queue.close();
|
|
46
27
|
}
|
|
47
28
|
/** True once close() ran — the sink's terminal state (queued envelopes may
|
|
48
29
|
* still drain via next()). */
|
|
49
30
|
get isClosed() {
|
|
50
|
-
return this.
|
|
31
|
+
return this.queue.isClosed;
|
|
51
32
|
}
|
|
52
33
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parall/codex-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.0",
|
|
4
4
|
"description": "Codex CLI bridge runtime for self-hosted Parall agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"src"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@parall/
|
|
29
|
-
"@parall/
|
|
30
|
-
"@parall/sdk": "1.
|
|
28
|
+
"@parall/cli": "1.60.0",
|
|
29
|
+
"@parall/agent-core": "1.60.0",
|
|
30
|
+
"@parall/sdk": "1.60.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/node": "^22.0.0",
|
|
@@ -34,6 +34,43 @@ export function extractTurnId(result: unknown): string | undefined {
|
|
|
34
34
|
return undefined;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The `thread` object a `thread/started` notification carries. Subagent
|
|
39
|
+
* threads (`spawn_agent`) report `threadSource: "subAgent"` plus the parent
|
|
40
|
+
* thread and the agent's nickname/role; other fields are tolerated absent.
|
|
41
|
+
*/
|
|
42
|
+
export type CodexThreadInfo = {
|
|
43
|
+
id: string;
|
|
44
|
+
parentThreadId?: string;
|
|
45
|
+
source?: string;
|
|
46
|
+
nickname?: string;
|
|
47
|
+
role?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function extractThreadInfo(params: unknown): CodexThreadInfo | undefined {
|
|
51
|
+
if (!params || typeof params !== 'object') return undefined;
|
|
52
|
+
const p = params as Record<string, unknown>;
|
|
53
|
+
const thread = (p.thread && typeof p.thread === 'object' ? p.thread : p) as Record<
|
|
54
|
+
string,
|
|
55
|
+
unknown
|
|
56
|
+
>;
|
|
57
|
+
const id =
|
|
58
|
+
typeof thread.id === 'string'
|
|
59
|
+
? thread.id
|
|
60
|
+
: typeof p.threadId === 'string'
|
|
61
|
+
? p.threadId
|
|
62
|
+
: undefined;
|
|
63
|
+
if (!id) return undefined;
|
|
64
|
+
const str = (value: unknown) => (typeof value === 'string' && value.trim() ? value : undefined);
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
parentThreadId: str(thread.parentThreadId),
|
|
68
|
+
source: str(thread.threadSource) ?? str(thread.source),
|
|
69
|
+
nickname: str(thread.agentNickname),
|
|
70
|
+
role: str(thread.agentRole),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
37
74
|
export function extractThreadIdFromNotification(params: unknown): string | undefined {
|
|
38
75
|
if (!params || typeof params !== 'object') return undefined;
|
|
39
76
|
const p = params as Record<string, unknown>;
|
package/src/compact.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { CompactOpts, CompactResult, GatewayLogger } from '@parall/agent-core';
|
|
2
|
+
import type { OpenDispatchOutcome } from './dispatch.js';
|
|
3
|
+
import {
|
|
4
|
+
CompactionStalledError,
|
|
5
|
+
type MainThreadInstructionsRefresher,
|
|
6
|
+
type NotificationTapSource,
|
|
7
|
+
} from './instructions-refresh.js';
|
|
8
|
+
import { JSON_RPC_METHOD_NOT_FOUND, JsonRpcError } from './jsonrpc-client.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Idle auto-compact on the Codex bridge (idle-auto-compact-design.md §3.4):
|
|
12
|
+
* open the MAIN thread exactly the way a dispatch would (start/resume + the
|
|
13
|
+
* instructions reconcile), then run the unconditional compaction primitive
|
|
14
|
+
* as a tap-only turn — no TurnSink, no activeTurnIds entry, so it is neither
|
|
15
|
+
* a user turn nor a steer target (PFB-84 bookkeeping untouched). Outcomes
|
|
16
|
+
* follow the refresh path's posture: a stalled compaction quarantines the
|
|
17
|
+
* main lane and bounces the subprocess; -32601 latches unsupported for the
|
|
18
|
+
* subprocess lifetime.
|
|
19
|
+
*
|
|
20
|
+
* The host is the narrow slice of CodexAppServerAdapter this needs; the
|
|
21
|
+
* adapter's `compact()` is a thin delegate.
|
|
22
|
+
*/
|
|
23
|
+
export interface CodexCompactHost {
|
|
24
|
+
log?: GatewayLogger;
|
|
25
|
+
taps: NotificationTapSource;
|
|
26
|
+
refresher: Pick<
|
|
27
|
+
MainThreadInstructionsRefresher,
|
|
28
|
+
'isCompactUnsupported' | 'runCompaction' | 'recordCompacted'
|
|
29
|
+
>;
|
|
30
|
+
isMainSession(sessionKey: string): boolean;
|
|
31
|
+
isMainLaneQuarantined(): boolean;
|
|
32
|
+
/** Quarantine the main lane and request a subprocess restart (applied when free). */
|
|
33
|
+
quarantineAndBounce(log?: GatewayLogger): Promise<void>;
|
|
34
|
+
applyPendingRestart(log?: GatewayLogger): Promise<void>;
|
|
35
|
+
/** Open the main thread with restart gating (openingDispatches) held. */
|
|
36
|
+
openMainThread(sessionKey: string, log?: GatewayLogger): Promise<OpenDispatchOutcome>;
|
|
37
|
+
hasActiveTurn(threadId: string): boolean;
|
|
38
|
+
/** Run `fn` as tap-only work on the thread: restart-gated, no unrouted-notification warnings. */
|
|
39
|
+
withTapOnlyTurn<T>(threadId: string, fn: () => Promise<T>): Promise<T>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runCodexCompact(
|
|
43
|
+
host: CodexCompactHost,
|
|
44
|
+
{ sessionKey, signal, log }: CompactOpts,
|
|
45
|
+
): Promise<CompactResult> {
|
|
46
|
+
const logger = log ?? host.log;
|
|
47
|
+
if (!host.isMainSession(sessionKey)) {
|
|
48
|
+
return { status: 'unsupported', detail: 'compact is only supported on the main session' };
|
|
49
|
+
}
|
|
50
|
+
if (signal.aborted) return { status: 'timeout' };
|
|
51
|
+
if (host.isMainLaneQuarantined()) {
|
|
52
|
+
await host.applyPendingRestart(logger);
|
|
53
|
+
if (host.isMainLaneQuarantined()) {
|
|
54
|
+
return {
|
|
55
|
+
status: 'failed',
|
|
56
|
+
detail:
|
|
57
|
+
'main lane quarantined after a stalled compaction; the subprocess restart is still deferred behind an active turn',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (host.refresher.isCompactUnsupported()) {
|
|
62
|
+
return {
|
|
63
|
+
status: 'unsupported',
|
|
64
|
+
detail: 'thread/compact/start not supported by this codex CLI',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
await host.applyPendingRestart(logger);
|
|
68
|
+
const opened = await host.openMainThread(sessionKey, logger);
|
|
69
|
+
if (!opened.ok) return { status: 'failed', detail: opened.message };
|
|
70
|
+
if (opened.reconcile === 'stalled') {
|
|
71
|
+
// Same posture as dispatch(): the refresh compaction may still be
|
|
72
|
+
// running on the thread — never rotate it; quarantine and bounce.
|
|
73
|
+
await host.quarantineAndBounce(logger);
|
|
74
|
+
return {
|
|
75
|
+
status: 'timeout',
|
|
76
|
+
detail:
|
|
77
|
+
'instructions-refresh compaction stalled while opening the thread; subprocess bounced',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (opened.reconcile === 'unsupported') {
|
|
81
|
+
return {
|
|
82
|
+
status: 'unsupported',
|
|
83
|
+
detail: 'thread/compact/start not supported by this codex CLI',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (opened.reconcile === 'refreshed') {
|
|
87
|
+
// The open just ran a compaction of this very thread (instructions
|
|
88
|
+
// refresh) — the history is already folded; a second one is waste.
|
|
89
|
+
return { status: 'done', detail: 'compacted by the instructions refresh during open' };
|
|
90
|
+
}
|
|
91
|
+
if (signal.aborted) {
|
|
92
|
+
return { status: 'timeout', detail: 'budget elapsed while opening the thread' };
|
|
93
|
+
}
|
|
94
|
+
const { client, threadId } = opened;
|
|
95
|
+
if (host.hasActiveTurn(threadId)) {
|
|
96
|
+
return { status: 'failed', detail: 'a turn is active on the main thread' };
|
|
97
|
+
}
|
|
98
|
+
let stalled = false;
|
|
99
|
+
try {
|
|
100
|
+
return await host.withTapOnlyTurn(threadId, async () => {
|
|
101
|
+
try {
|
|
102
|
+
await host.refresher.runCompaction({ client, taps: host.taps, threadId, signal });
|
|
103
|
+
host.refresher.recordCompacted(sessionKey, threadId);
|
|
104
|
+
return { status: 'done' } as CompactResult;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err instanceof CompactionStalledError) {
|
|
107
|
+
// Budget elapsed (the gateway's abort or the refresher's own
|
|
108
|
+
// ceiling) and the turn ignored the interrupt: the compaction is
|
|
109
|
+
// torn down with the subprocess below.
|
|
110
|
+
stalled = true;
|
|
111
|
+
return { status: 'timeout', detail: errToString(err) };
|
|
112
|
+
}
|
|
113
|
+
if (err instanceof JsonRpcError && err.code === JSON_RPC_METHOD_NOT_FOUND) {
|
|
114
|
+
return { status: 'unsupported', detail: errToString(err) };
|
|
115
|
+
}
|
|
116
|
+
return { status: 'failed', detail: errToString(err) };
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
} finally {
|
|
120
|
+
if (stalled) {
|
|
121
|
+
// The compaction turn may still be running: quarantine the main lane
|
|
122
|
+
// and bounce the subprocess (deferred behind an active fork turn,
|
|
123
|
+
// retried by the next main dispatch) — exactly the dispatch posture.
|
|
124
|
+
await host.quarantineAndBounce(logger);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function errToString(err: unknown): string {
|
|
130
|
+
if (err instanceof Error) return err.message;
|
|
131
|
+
return String(err);
|
|
132
|
+
}
|
package/src/dispatch.ts
CHANGED
|
@@ -3,14 +3,20 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import type {
|
|
5
5
|
CleanupForkOpts,
|
|
6
|
+
CompactOpts,
|
|
7
|
+
CompactResult,
|
|
6
8
|
DispatchAdapter,
|
|
9
|
+
DispatchInputLifecycle,
|
|
7
10
|
DispatchOpts,
|
|
8
11
|
ForkOpts,
|
|
9
12
|
ForkSessionHandle,
|
|
10
13
|
GatewayLogger,
|
|
14
|
+
RuntimeActivityEvent,
|
|
15
|
+
RuntimeBusyState,
|
|
11
16
|
RuntimeEvent,
|
|
12
17
|
} from '@parall/agent-core';
|
|
13
18
|
import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
|
|
19
|
+
import { isRuntimeBusy, projectRuntimeEvent, RuntimeActivityPort } from '@parall/agent-core';
|
|
14
20
|
import {
|
|
15
21
|
appendPreparedLocalAttachmentRefs,
|
|
16
22
|
pinLocalAttachmentPaths,
|
|
@@ -24,12 +30,16 @@ import {
|
|
|
24
30
|
} from './app-server-protocol.js';
|
|
25
31
|
import type { CodexAgentConfig } from './config.js';
|
|
26
32
|
import { normalizeApprovalPolicy, normalizeSandbox } from './config.js';
|
|
33
|
+
import { runCodexCompact } from './compact.js';
|
|
34
|
+
import { ForeignThreadRegistry } from './foreign-threads.js';
|
|
35
|
+
import { CodexInjectionRegistry } from './injection-registry.js';
|
|
27
36
|
import {
|
|
28
37
|
MainThreadInstructionsRefresher,
|
|
29
38
|
type NotificationTap,
|
|
30
39
|
type RefreshOutcome,
|
|
31
40
|
} from './instructions-refresh.js';
|
|
32
41
|
import { JsonRpcStdioClient } from './jsonrpc-client.js';
|
|
42
|
+
import type { CodexRuntimeTurn } from './runtime-turn.js';
|
|
33
43
|
import { answerServerRequest } from './server-requests.js';
|
|
34
44
|
import type { CodexSessionManager } from './session-manager.js';
|
|
35
45
|
import { buildThreadConfigOverrides } from './thread-config.js';
|
|
@@ -104,7 +114,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
104
114
|
private startPromise: Promise<void> | null = null;
|
|
105
115
|
private readonly activeTurns = new Map<string, TurnSink>();
|
|
106
116
|
private readonly activeTurnIds = new Map<string, string>();
|
|
107
|
-
private readonly
|
|
117
|
+
private readonly injections = new CodexInjectionRegistry();
|
|
108
118
|
private readonly resumedThreadIds = new Set<string>();
|
|
109
119
|
/** Threads whose reconcile compaction is in flight — tap-only traffic. */
|
|
110
120
|
private readonly reconcilingThreadIds = new Set<string>();
|
|
@@ -122,6 +132,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
122
132
|
private readonly instructionsRefresher: MainThreadInstructionsRefresher;
|
|
123
133
|
private stopping = false;
|
|
124
134
|
private lastUnroutedNotificationWarnAt = 0;
|
|
135
|
+
/** Subagent threads and their runtime-initiated turns (foreign-threads.ts). */
|
|
136
|
+
private readonly foreignThreads: ForeignThreadRegistry;
|
|
137
|
+
private readonly activity: RuntimeActivityPort;
|
|
125
138
|
|
|
126
139
|
/**
|
|
127
140
|
* Store an active turn sink keyed by threadId. If a sink already exists for
|
|
@@ -150,6 +163,19 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
150
163
|
compactTimeoutMs: opts.instructionsCompactTimeoutMs,
|
|
151
164
|
interruptGraceMs: opts.instructionsInterruptGraceMs,
|
|
152
165
|
});
|
|
166
|
+
this.activity = new RuntimeActivityPort('CodexAppServerAdapter', opts.log);
|
|
167
|
+
this.foreignThreads = new ForeignThreadRegistry({
|
|
168
|
+
sessionManager: opts.sessionManager,
|
|
169
|
+
log: opts.log,
|
|
170
|
+
activity: this.activity,
|
|
171
|
+
// A restart deferred behind subagent work must not starve once it ends.
|
|
172
|
+
afterTurnClosed: () => {
|
|
173
|
+
if (!this.restartRequested) return;
|
|
174
|
+
void this.applyPendingRestart().catch((err) =>
|
|
175
|
+
this.opts.log?.warn?.(`deferred restart failed: ${errToString(err)}`),
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
/** Register a listener for every server notification; returns unregister. */
|
|
@@ -194,7 +220,13 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
194
220
|
});
|
|
195
221
|
}
|
|
196
222
|
|
|
197
|
-
async enqueueDuringDispatch(
|
|
223
|
+
async enqueueDuringDispatch(
|
|
224
|
+
sessionKey: string,
|
|
225
|
+
body: string,
|
|
226
|
+
inputLifecycle?: DispatchInputLifecycle,
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
// Identity is mandatory — the drain releases the injection by this key (injection-registry.ts).
|
|
229
|
+
if (!inputLifecycle) return false;
|
|
198
230
|
const client = this.client;
|
|
199
231
|
if (!client || client.isDisposed()) return false;
|
|
200
232
|
const threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
@@ -207,7 +239,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
207
239
|
expectedTurnId: turnId,
|
|
208
240
|
input: buildTurnInput(body, []),
|
|
209
241
|
});
|
|
210
|
-
this.
|
|
242
|
+
this.injections.register(sessionKey, inputLifecycle.deliveryKey);
|
|
211
243
|
return true;
|
|
212
244
|
} catch (err) {
|
|
213
245
|
this.opts.log?.warn?.(`turn/steer failed: ${errToString(err)}`);
|
|
@@ -216,7 +248,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
216
248
|
}
|
|
217
249
|
|
|
218
250
|
abortDispatch(sessionKey: string): void {
|
|
219
|
-
|
|
251
|
+
// A torn-down turn owes nothing: the error complete releases its members for fresh turns.
|
|
252
|
+
this.injections.clear(sessionKey);
|
|
220
253
|
const threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
221
254
|
if (!threadId) return;
|
|
222
255
|
const sink = this.activeTurns.get(threadId);
|
|
@@ -240,8 +273,87 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
240
273
|
sink.close();
|
|
241
274
|
}
|
|
242
275
|
|
|
276
|
+
/** Idle auto-compact (compact.ts): the compaction primitive as a tap-only main-thread turn. */
|
|
277
|
+
compact(opts: CompactOpts): Promise<CompactResult> {
|
|
278
|
+
return runCodexCompact(
|
|
279
|
+
{
|
|
280
|
+
log: this.opts.log,
|
|
281
|
+
taps: this,
|
|
282
|
+
refresher: this.instructionsRefresher,
|
|
283
|
+
isMainSession: (sessionKey) => this.opts.sessionManager.isMain(sessionKey),
|
|
284
|
+
isMainLaneQuarantined: () => this.mainLaneQuarantined,
|
|
285
|
+
quarantineAndBounce: async (log) => {
|
|
286
|
+
this.mainLaneQuarantined = true;
|
|
287
|
+
this.requestProcessRestart();
|
|
288
|
+
await this.applyPendingRestart(log);
|
|
289
|
+
},
|
|
290
|
+
applyPendingRestart: (log) => this.applyPendingRestart(log),
|
|
291
|
+
openMainThread: (sessionKey, log) =>
|
|
292
|
+
this.withOpening(() => this.openDispatchTarget(sessionKey, true, log)),
|
|
293
|
+
hasActiveTurn: (threadId) => this.activeTurns.has(threadId),
|
|
294
|
+
withTapOnlyTurn: async (threadId, fn) => {
|
|
295
|
+
this.reconcilingThreadIds.add(threadId);
|
|
296
|
+
try {
|
|
297
|
+
return await this.withOpening(fn);
|
|
298
|
+
} finally {
|
|
299
|
+
this.reconcilingThreadIds.delete(threadId);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
opts,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Real work on the subprocess that predates any TurnSink (thread open,
|
|
309
|
+
* a tap-only compaction turn): counted so applyPendingRestart cannot
|
|
310
|
+
* stop() the subprocess out from under it.
|
|
311
|
+
*/
|
|
312
|
+
private async withOpening<T>(fn: () => Promise<T>): Promise<T> {
|
|
313
|
+
this.openingDispatches += 1;
|
|
314
|
+
try {
|
|
315
|
+
return await fn();
|
|
316
|
+
} finally {
|
|
317
|
+
this.openingDispatches -= 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
243
321
|
hasPendingInjections(sessionKey: string): boolean {
|
|
244
|
-
return
|
|
322
|
+
return this.injections.hasPending(sessionKey);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
hasUnsettledInjections(sessionKey: string): boolean {
|
|
326
|
+
return this.injections.hasUnsettled(sessionKey);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void {
|
|
330
|
+
this.injections.markDrained(sessionKey, deliveryKey);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- runtime-initiated work -------------------------------------------------
|
|
334
|
+
|
|
335
|
+
subscribeRuntimeActivity(handler: (event: RuntimeActivityEvent) => void): () => void {
|
|
336
|
+
return this.activity.subscribe(handler);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Busy = a dispatch turn, a dispatch opening its thread (possibly running
|
|
341
|
+
* the instructions-refresh compaction turn), or a subagent thread's turn
|
|
342
|
+
* is executing. Live subagent threads between turns are reported as
|
|
343
|
+
* background work, not busy.
|
|
344
|
+
*/
|
|
345
|
+
isBusy(): boolean {
|
|
346
|
+
return isRuntimeBusy(this.busyState());
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
busyState(): RuntimeBusyState {
|
|
350
|
+
return {
|
|
351
|
+
activeTurns:
|
|
352
|
+
this.activeTurns.size +
|
|
353
|
+
(this.openingDispatches > 0 || this.reconcilingThreadIds.size > 0 ? 1 : 0) +
|
|
354
|
+
this.foreignThreads.openTurns(),
|
|
355
|
+
backgroundWork: this.foreignThreads.size,
|
|
356
|
+
};
|
|
245
357
|
}
|
|
246
358
|
|
|
247
359
|
async *dispatch({
|
|
@@ -249,27 +361,12 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
249
361
|
bodyForAgent,
|
|
250
362
|
sessionKey,
|
|
251
363
|
context,
|
|
364
|
+
inputLifecycle,
|
|
252
365
|
noteActivity,
|
|
253
366
|
}: DispatchOpts): AsyncIterable<RuntimeEvent> {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const threadId = this.opts.sessionManager.getThreadId(sessionKey);
|
|
258
|
-
if (threadId && this.client && !this.client.isDisposed()) {
|
|
259
|
-
(this.opts.log ?? context.log)?.info?.(
|
|
260
|
-
`${pending} steer injection(s) already sent; skipping turn/start`,
|
|
261
|
-
);
|
|
262
|
-
yield {
|
|
263
|
-
type: 'runtime_session',
|
|
264
|
-
runtimeSessionId: threadId,
|
|
265
|
-
runtimeLaneKey: sessionKey,
|
|
266
|
-
};
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
(this.opts.log ?? context.log)?.warn?.(
|
|
270
|
-
`pending steer invalidated (subprocess died); falling through to normal dispatch`,
|
|
271
|
-
);
|
|
272
|
-
}
|
|
367
|
+
// This dispatch drains the injected copies it lists; it ALWAYS runs its own
|
|
368
|
+
// turn — never skipped on outstanding injections (PFB-84, injection-registry.ts).
|
|
369
|
+
if (inputLifecycle) this.injections.markDrained(sessionKey, inputLifecycle.deliveryKey);
|
|
273
370
|
|
|
274
371
|
const isMainSession = this.opts.sessionManager.isMain(sessionKey);
|
|
275
372
|
if (isMainSession && this.mainLaneQuarantined) {
|
|
@@ -322,13 +419,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
322
419
|
// compaction turn BEFORE any TurnSink exists, and a concurrent fork
|
|
323
420
|
// dispatch hitting a pending restart in that window would otherwise
|
|
324
421
|
// stop() the subprocess out from under it.
|
|
325
|
-
this.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
opened = await this.openDispatchTarget(sessionKey, isMainSession, log);
|
|
329
|
-
} finally {
|
|
330
|
-
this.openingDispatches -= 1;
|
|
331
|
-
}
|
|
422
|
+
const opened = await this.withOpening(() =>
|
|
423
|
+
this.openDispatchTarget(sessionKey, isMainSession, log),
|
|
424
|
+
);
|
|
332
425
|
if (!opened.ok) {
|
|
333
426
|
yield { type: 'error', message: opened.message };
|
|
334
427
|
return;
|
|
@@ -519,39 +612,20 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
519
612
|
sawTurnEnd = true;
|
|
520
613
|
break;
|
|
521
614
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
if (runtimeEvent.type === 'runtime_session') {
|
|
532
|
-
yield runtimeEvent;
|
|
533
|
-
continue;
|
|
534
|
-
}
|
|
535
|
-
if (runtimeEvent.type === 'text') {
|
|
536
|
-
// Layer 0 symmetric output contract: Codex's plain text is never
|
|
537
|
-
// projected as a chat message. Outbound messages must come from the
|
|
538
|
-
// agent explicitly invoking `@parall/cli messages send` / `dm` via
|
|
539
|
-
// the shell/exec tool. Text events are still yielded so agent-core
|
|
540
|
-
// records them as suppressed session steps for audit.
|
|
541
|
-
yield { ...runtimeEvent, project: false, groupKey };
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
if (runtimeEvent.type === 'turn_outcome') {
|
|
545
|
-
// Turn-boundary summary — no groupKey, passed through as-is.
|
|
546
|
-
yield runtimeEvent;
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
yield { ...runtimeEvent, groupKey };
|
|
615
|
+
// Layer 0 symmetric output contract (projectRuntimeEvent): Codex's
|
|
616
|
+
// plain text is never projected as a chat message — outbound
|
|
617
|
+
// messages come from the agent invoking `parall messages send` /
|
|
618
|
+
// `dm`; text is still yielded as a suppressed audit step.
|
|
619
|
+
yield projectRuntimeEvent(
|
|
620
|
+
envelope.kind === 'error' ? { type: 'error', message: envelope.message } : envelope.event,
|
|
621
|
+
groupKey,
|
|
622
|
+
);
|
|
550
623
|
}
|
|
551
624
|
} finally {
|
|
552
625
|
releasePreparedAttachments();
|
|
553
626
|
this.activeTurns.delete(threadId!);
|
|
554
627
|
this.activeTurnIds.delete(threadId!);
|
|
628
|
+
this.injections.settleAll(sessionKey); // the turn consumed (or lost) everything steered into it
|
|
555
629
|
if (!sawTurnEnd) {
|
|
556
630
|
sink.close();
|
|
557
631
|
}
|
|
@@ -754,7 +828,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
754
828
|
private openingDispatches = 0;
|
|
755
829
|
|
|
756
830
|
private async applyPendingRestart(log?: GatewayLogger): Promise<void> {
|
|
757
|
-
|
|
831
|
+
// isBusy folds in subagent turns: a capability change must not SIGTERM
|
|
832
|
+
// the app-server under a running spawn_agent thread.
|
|
833
|
+
if (!this.restartRequested || this.isBusy()) return;
|
|
758
834
|
this.restartRequested = false;
|
|
759
835
|
(log ?? this.opts.log)?.info?.(
|
|
760
836
|
'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)',
|
|
@@ -770,7 +846,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
770
846
|
this.client = null;
|
|
771
847
|
this.initialized = false;
|
|
772
848
|
this.activeTurnIds.clear();
|
|
773
|
-
this.
|
|
849
|
+
this.injections.clear();
|
|
774
850
|
// Resume markers are process-local facts. Clear them HERE, synchronously:
|
|
775
851
|
// the old child's close callback sees the replaced refs and skips its
|
|
776
852
|
// cleanup as stale, so a marker surviving stop() would make the next
|
|
@@ -780,6 +856,9 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
780
856
|
this.instructionsRefresher.clearThreadState();
|
|
781
857
|
// The (possible) zombie compaction turn dies with the subprocess.
|
|
782
858
|
this.mainLaneQuarantined = false;
|
|
859
|
+
// Subagent threads die with the app-server: end their open turns
|
|
860
|
+
// (runtime_crash) and close their child sessions before the client goes.
|
|
861
|
+
this.foreignThreads.dropAll('lost: Codex app-server stopped');
|
|
783
862
|
this.notifyTapsDisposed('Codex app-server stopped');
|
|
784
863
|
if (client) client.dispose(new Error('adapter stopped'));
|
|
785
864
|
if (proc && proc.exitCode === null && proc.signalCode === null) {
|
|
@@ -797,7 +876,8 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
797
876
|
}
|
|
798
877
|
this.activeTurns.clear();
|
|
799
878
|
this.activeTurnIds.clear();
|
|
800
|
-
this.
|
|
879
|
+
this.injections.clear();
|
|
880
|
+
this.foreignThreads.dropAll('lost: Codex app-server disposed');
|
|
801
881
|
this.client = null;
|
|
802
882
|
this.proc = null;
|
|
803
883
|
this.initialized = false;
|
|
@@ -977,10 +1057,11 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
977
1057
|
}
|
|
978
1058
|
this.activeTurns.clear();
|
|
979
1059
|
this.activeTurnIds.clear();
|
|
980
|
-
this.
|
|
1060
|
+
this.injections.clear();
|
|
981
1061
|
this.resumedThreadIds.clear();
|
|
982
1062
|
this.instructionsRefresher.clearThreadState();
|
|
983
1063
|
this.mainLaneQuarantined = false;
|
|
1064
|
+
this.foreignThreads.dropAll(`lost: Codex app-server ${reason}`);
|
|
984
1065
|
this.notifyTapsDisposed(`Codex app-server ${reason}`);
|
|
985
1066
|
this.client = null;
|
|
986
1067
|
this.proc = null;
|
|
@@ -1073,6 +1154,10 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
1073
1154
|
// successful instructions refresh and burn the shared rate-limit
|
|
1074
1155
|
// window that exists to surface REAL orphaned turns.
|
|
1075
1156
|
if (this.reconcilingThreadIds.has(threadId)) return;
|
|
1157
|
+
// Subagent threads and their turns are runtime-initiated work: routed
|
|
1158
|
+
// into their own sinks and surfaced to the gateway as child-session
|
|
1159
|
+
// turns instead of being dropped.
|
|
1160
|
+
if (this.foreignThreads.route(method, params, threadId)) return;
|
|
1076
1161
|
// Notifications for a thread with no registered sink are dropped. A
|
|
1077
1162
|
// sustained stream of these means a turn is running that nothing is
|
|
1078
1163
|
// consuming (threadId mismatch, sink torn down early) — the dispatch
|
|
@@ -1104,7 +1189,7 @@ export class CodexAppServerAdapter implements DispatchAdapter {
|
|
|
1104
1189
|
}
|
|
1105
1190
|
}
|
|
1106
1191
|
|
|
1107
|
-
type OpenDispatchOutcome =
|
|
1192
|
+
export type OpenDispatchOutcome =
|
|
1108
1193
|
| { ok: true; client: JsonRpcStdioClient; threadId: string; reconcile?: RefreshOutcome }
|
|
1109
1194
|
| { ok: false; message: string };
|
|
1110
1195
|
|