@excitedjs/agent-runtime-codex 0.3.5-beta.168 → 0.4.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.
Files changed (56) hide show
  1. package/README.md +39 -1
  2. package/dist/events.d.ts +8 -0
  3. package/dist/events.d.ts.map +1 -1
  4. package/dist/events.js +119 -47
  5. package/dist/events.js.map +1 -1
  6. package/dist/provider.d.ts +3 -0
  7. package/dist/provider.d.ts.map +1 -1
  8. package/dist/provider.js +6 -3
  9. package/dist/provider.js.map +1 -1
  10. package/dist/rpc.d.ts +1 -0
  11. package/dist/rpc.d.ts.map +1 -1
  12. package/dist/rpc.js +9 -1
  13. package/dist/rpc.js.map +1 -1
  14. package/dist/runtime.d.ts +15 -7
  15. package/dist/runtime.d.ts.map +1 -1
  16. package/dist/runtime.js +242 -80
  17. package/dist/runtime.js.map +1 -1
  18. package/dist/supervisor.d.ts +7 -0
  19. package/dist/supervisor.d.ts.map +1 -1
  20. package/dist/supervisor.js +99 -43
  21. package/dist/supervisor.js.map +1 -1
  22. package/dist/transcript/budget.d.ts +8 -0
  23. package/dist/transcript/budget.d.ts.map +1 -0
  24. package/dist/transcript/budget.js +9 -0
  25. package/dist/transcript/budget.js.map +1 -0
  26. package/dist/transcript/cursor.d.ts +23 -0
  27. package/dist/transcript/cursor.d.ts.map +1 -0
  28. package/dist/transcript/cursor.js +57 -0
  29. package/dist/transcript/cursor.js.map +1 -0
  30. package/dist/transcript/error.d.ts +9 -0
  31. package/dist/transcript/error.d.ts.map +1 -0
  32. package/dist/transcript/error.js +9 -0
  33. package/dist/transcript/error.js.map +1 -0
  34. package/dist/transcript/opened-file.d.ts +10 -0
  35. package/dist/transcript/opened-file.d.ts.map +1 -0
  36. package/dist/transcript/opened-file.js +61 -0
  37. package/dist/transcript/opened-file.js.map +1 -0
  38. package/dist/transcript/path.d.ts +28 -0
  39. package/dist/transcript/path.d.ts.map +1 -0
  40. package/dist/transcript/path.js +422 -0
  41. package/dist/transcript/path.js.map +1 -0
  42. package/dist/transcript/projection.d.ts +6 -0
  43. package/dist/transcript/projection.d.ts.map +1 -0
  44. package/dist/transcript/projection.js +170 -0
  45. package/dist/transcript/projection.js.map +1 -0
  46. package/dist/transcript/reader.d.ts +7 -0
  47. package/dist/transcript/reader.d.ts.map +1 -0
  48. package/dist/transcript/reader.js +421 -0
  49. package/dist/transcript/reader.js.map +1 -0
  50. package/dist/turn-manager.d.ts +34 -56
  51. package/dist/turn-manager.d.ts.map +1 -1
  52. package/dist/turn-manager.js +369 -303
  53. package/dist/turn-manager.js.map +1 -1
  54. package/dist/types.d.ts +7 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +5 -4
@@ -1,14 +1,5 @@
1
- /**
2
- * Per-dispatcher in-memory inbound submitter.
3
- *
4
- * Contract:
5
- * - accepted inbound messages are not persisted;
6
- * - Feishu message_id redelivery is deduped within this server process;
7
- * - one accepted inbound message becomes one Codex `turn/start` submission;
8
- * - Codex text output alone does not send anything to Feishu.
9
- */
10
1
  import { extractAssistantText, subscribeTurnCollection, submitTurnStart, } from './events.js';
11
- import { compileCodexOutputSchema, } from './output-schema-codec.js';
2
+ import { compileCodexOutputSchema } from './output-schema-codec.js';
12
3
  import { DEFAULT_MESSAGE_ID_DEDUPE_WINDOW, unsupportedFeatureError, } from '@excitedjs/dreamux-utils';
13
4
  export class TurnManager {
14
5
  opts;
@@ -16,332 +7,364 @@ export class TurnManager {
16
7
  seenMessageIdOrder = [];
17
8
  seenTextInputIds = new Set();
18
9
  seenTextInputIdOrder = [];
10
+ pendingMessageIds = new Map();
11
+ pendingTextInputIds = new Map();
12
+ pendingAdmissions = new Set();
13
+ inFlightNativeAdmissions = new Set();
14
+ nextNativeAdmission = 0;
15
+ nativeTurns = new Map();
16
+ pendingActivity = new Map();
17
+ unboundObservedTurnIds = new Set();
18
+ terminalOrder = [];
19
+ protocolFailure = null;
20
+ collector = null;
21
+ collectorThreadId = null;
22
+ decisionTail = Promise.resolve();
19
23
  stopped = false;
20
- activeTurnSlot = null;
21
- activeTurnId = null;
22
- /**
23
- * Turn ids submitted to Codex that have not yet reached `turn/completed`. On
24
- * `stop()` each still-pending turn is settled as `stopped` so a teammate turn
25
- * interrupted by teardown is not lost.
26
- */
27
- pendingTurns = new Map();
28
24
  idlePromise = null;
29
25
  idleResolve = null;
30
26
  log;
31
27
  messageIdDedupeWindow;
32
28
  constructor(opts) {
33
29
  this.opts = opts;
34
- this.log = opts.log ?? ((lvl, msg, err) => {
35
- const prefix = `[turn-manager ${opts.dispatcherId}] ${lvl}`;
36
- if (err !== undefined)
37
- console.error(prefix, msg, err);
30
+ this.log = opts.log ?? ((level, message, error) => {
31
+ const prefix = `[turn-manager ${opts.dispatcherId}] ${level}`;
32
+ if (error === undefined)
33
+ console.error(prefix, message);
38
34
  else
39
- console.error(prefix, msg);
35
+ console.error(prefix, message, error);
40
36
  });
41
37
  this.messageIdDedupeWindow = Math.max(0, opts.messageIdDedupeWindow ?? DEFAULT_MESSAGE_ID_DEDUPE_WINDOW);
42
38
  }
43
39
  isBusy() {
44
- return this.activeTurnSlot !== null || this.pendingTurns.size > 0;
40
+ return this.pendingAdmissions.size > 0 || [...this.nativeTurns.values()].some((record) => record.completion === null);
45
41
  }
46
42
  waitIdle() {
47
43
  if (!this.isBusy())
48
44
  return Promise.resolve();
49
- // All concurrent waiters share one promise for the current busy period; it
50
- // is replaced with a fresh one the next time the runtime goes busy.
51
- if (this.idlePromise === null) {
52
- this.idlePromise = new Promise((resolve) => {
53
- this.idleResolve = resolve;
54
- });
55
- }
45
+ this.idlePromise ??= new Promise((resolve) => { this.idleResolve = resolve; });
56
46
  return this.idlePromise;
57
47
  }
58
- /**
59
- * Submit one accepted inbound message to Codex. Returns duplicate when this
60
- * process already saw the message_id.
61
- */
62
- async enqueue(input) {
63
- if (this.stopped)
64
- return { status: 'stopped' };
65
- if (!this.rememberMessageId(input.sourceId)) {
66
- return { status: 'duplicate' };
67
- }
68
- const threadId = this.opts.getThreadId();
69
- if (threadId === null) {
70
- const error = new Error('inbound submitted without thread_id');
71
- this.log('error', error.message);
72
- return { status: 'failed', error };
73
- }
74
- let activeTurn;
75
- try {
76
- activeTurn = this.claimActiveTurnSlot(threadId, null);
77
- }
78
- catch (err) {
79
- const error = asError(err);
80
- this.log('error', error.message, error);
81
- return { status: 'failed', error };
82
- }
83
- activeTurn.slot.pendingSubmissions += 1;
84
- let res;
85
- try {
86
- res = await submitTurnStart(this.opts.client, threadId, input.text, this.opts.turnCwd ?? null);
87
- }
88
- catch (err) {
89
- const error = err instanceof Error ? err : new Error(String(err));
90
- this.recordTurnStartFailure(activeTurn.slot, error, activeTurn.primary);
91
- this.log('error', `turn/start submission failed for message ${input.sourceId === '' ? '<none>' : input.sourceId}: ${error.message}`, error);
92
- return { status: 'failed', error };
93
- }
94
- const turnId = this.recordTurnStartSuccess(activeTurn.slot, res.turn.id, activeTurn.primary);
95
- try {
96
- return {
97
- status: 'submitted',
98
- turnId: turnId ?? await activeTurn.slot.turnIdPromise,
99
- };
48
+ enqueue(input) {
49
+ return this.trackAdmission(this.reserveSource(input.sourceId, this.seenMessageIds, this.seenMessageIdOrder, this.pendingMessageIds, () => this.enqueueDecision(() => this.submit(input.text, null, `message ${input.sourceId || '<none>'}`))));
50
+ }
51
+ submitTextInput(input) {
52
+ return this.trackAdmission(this.reserveSource(input.sourceId, this.seenTextInputIds, this.seenTextInputIdOrder, this.pendingTextInputIds, async () => {
53
+ let codec = null;
54
+ if (input.outputSchema !== undefined) {
55
+ try {
56
+ codec = compileCodexOutputSchema(input.outputSchema);
57
+ }
58
+ catch (error) {
59
+ return { status: 'failed', error: asError(error) };
60
+ }
61
+ }
62
+ return this.enqueueDecision(() => this.submit(input.text, codec, 'text input'));
63
+ }));
64
+ }
65
+ async stop() {
66
+ this.stopped = true;
67
+ this.collector?.dispose();
68
+ this.collector = null;
69
+ while (this.pendingAdmissions.size > 0)
70
+ await Promise.allSettled([...this.pendingAdmissions]);
71
+ this.drainTerminalOrder();
72
+ for (const record of this.nativeTurns.values()) {
73
+ if (record.completion !== null)
74
+ continue;
75
+ for (const member of record.members)
76
+ member.settle({ kind: 'stopped' });
100
77
  }
101
- catch (err) {
102
- const error = err instanceof Error ? err : new Error(String(err));
103
- return { status: 'failed', error };
78
+ for (const [turnId, record] of this.nativeTurns) {
79
+ if (record.completion === null)
80
+ this.nativeTurns.delete(turnId);
104
81
  }
82
+ this.terminalOrder.length = 0;
83
+ this.pendingActivity.clear();
84
+ this.unboundObservedTurnIds.clear();
85
+ this.resolveIdleWaitersIfIdle();
105
86
  }
106
- async submitTextInput(input) {
87
+ async submit(text, codec, description) {
107
88
  if (this.stopped)
108
89
  return { status: 'stopped' };
109
- if (input.sourceId !== undefined &&
110
- input.sourceId !== '' &&
111
- !this.rememberTextInputId(input.sourceId)) {
112
- return { status: 'duplicate' };
113
- }
90
+ if (this.protocolFailure !== null)
91
+ return { status: 'failed', error: this.protocolFailure };
114
92
  const threadId = this.opts.getThreadId();
115
- if (threadId === null) {
116
- const error = new Error('text input submitted without thread_id');
117
- this.log('error', error.message);
118
- return { status: 'failed', error };
119
- }
120
- let codec = null;
121
- if (input.outputSchema !== undefined) {
122
- try {
123
- codec = compileCodexOutputSchema(input.outputSchema);
124
- }
125
- catch (err) {
126
- const error = asError(err);
127
- this.log('error', error.message, error);
128
- return { status: 'failed', error };
129
- }
130
- }
131
- let activeTurn;
93
+ if (threadId === null)
94
+ return { status: 'failed', error: new Error('input submitted without thread_id') };
95
+ const deferred = createRuntimeSubmission();
96
+ this.ensureCollector(threadId);
97
+ const incompatible = this.incompatibleCodecError(codec);
98
+ if (incompatible !== null)
99
+ return { status: 'failed', error: incompatible };
100
+ const admissionId = this.nextNativeAdmission++;
101
+ this.inFlightNativeAdmissions.add(admissionId);
102
+ let response;
132
103
  try {
133
- activeTurn = this.claimActiveTurnSlot(threadId, codec);
104
+ response = await submitTurnStart(this.opts.client, threadId, text, this.opts.turnCwd ?? null, codec?.wireSchema);
134
105
  }
135
- catch (err) {
136
- const error = asError(err);
137
- this.log('error', error.message, error);
138
- return { status: 'failed', error };
106
+ catch (error) {
107
+ const normalized = asError(error);
108
+ this.log('error', `turn/start submission failed for ${description}: ${normalized.message}`, normalized);
109
+ this.inFlightNativeAdmissions.delete(admissionId);
110
+ this.releaseCompletedRecords(admissionId);
111
+ this.dropOrphanActivityIfIdle();
112
+ return { status: 'ambiguous', error: normalized };
139
113
  }
140
- activeTurn.slot.pendingSubmissions += 1;
141
- let res;
142
- try {
143
- res = await submitTurnStart(this.opts.client, threadId, input.text, this.opts.turnCwd ?? null, activeTurn.slot.codec?.wireSchema);
114
+ const observed = this.nativeTurns.get(response.turn.id);
115
+ if (this.stopped && (observed === undefined || observed.terminal === null)) {
116
+ deferred.settle({ kind: 'stopped' });
144
117
  }
145
- catch (err) {
146
- const error = err instanceof Error ? err : new Error(String(err));
147
- this.recordTurnStartFailure(activeTurn.slot, error, activeTurn.primary);
148
- this.log('error', `text turn/start submission failed: ${error.message}`, error);
149
- return { status: 'failed', error };
118
+ else {
119
+ this.bindSubmission(response.turn.id, deferred, codec);
150
120
  }
151
- const turnId = this.recordTurnStartSuccess(activeTurn.slot, res.turn.id, activeTurn.primary);
152
- try {
153
- return {
154
- status: 'submitted',
155
- turnId: turnId ?? await activeTurn.slot.turnIdPromise,
156
- };
121
+ this.inFlightNativeAdmissions.delete(admissionId);
122
+ this.releaseCompletedRecords(admissionId);
123
+ this.dropOrphanActivityIfIdle();
124
+ return { status: 'submitted', submission: deferred.submission };
125
+ }
126
+ enqueueDecision(operation) {
127
+ const task = this.decisionTail.then(operation, operation);
128
+ this.decisionTail = task.then(() => undefined, () => undefined);
129
+ return task;
130
+ }
131
+ incompatibleCodecError(candidate) {
132
+ for (const record of this.nativeTurns.values()) {
133
+ if (record.completion !== null || record.terminal !== null)
134
+ continue;
135
+ if (sameCodec(record.codec, candidate))
136
+ continue;
137
+ return unsupportedFeatureError('outputSchema', 'codex cannot submit an incompatible outputSchema while a native turn is active');
157
138
  }
158
- catch (err) {
159
- const error = err instanceof Error ? err : new Error(String(err));
160
- return { status: 'failed', error };
139
+ return null;
140
+ }
141
+ ensureCollector(threadId) {
142
+ if (this.collector !== null && this.collectorThreadId === threadId)
143
+ return;
144
+ this.collector?.dispose();
145
+ this.collectorThreadId = threadId;
146
+ this.collector = subscribeTurnCollection(this.opts.client, threadId, {
147
+ retainAfterTerminal: true,
148
+ onItemStarted: (turnId, item) => this.observeItem(turnId, item, 'started', Date.now()),
149
+ onItemCompleted: (turnId, item, occurredAt) => this.observeItem(turnId, item, 'completed', occurredAt),
150
+ onTerminal: (turnId, terminal) => this.observeTerminal(turnId, terminal),
151
+ onUnscopedFailure: (error) => this.failProtocol(error),
152
+ onProtocolViolation: (error) => this.failProtocol(error),
153
+ });
154
+ }
155
+ bindSubmission(turnId, deferred, codec) {
156
+ this.unboundObservedTurnIds.delete(turnId);
157
+ const record = this.nativeTurns.get(turnId) ?? {
158
+ representative: null,
159
+ members: [],
160
+ codec,
161
+ completion: null,
162
+ terminal: null,
163
+ releaseAfterAdmissions: null,
164
+ };
165
+ if (record.representative === null && record.members.length === 0)
166
+ record.codec = codec;
167
+ record.representative ??= deferred.submission;
168
+ if (record.completion === null)
169
+ record.members.push(deferred);
170
+ this.nativeTurns.set(turnId, record);
171
+ for (const fact of this.pendingActivity.get(turnId) ?? [])
172
+ this.emitActivity(record, fact.activity, fact.occurredAt);
173
+ this.pendingActivity.delete(turnId);
174
+ if (this.protocolFailure !== null) {
175
+ this.failRecord(turnId, record, this.protocolFailure);
176
+ return;
161
177
  }
178
+ if (record.completion !== null)
179
+ deferred.settle({ kind: 'completion', completion: record.completion });
180
+ this.drainTerminalOrder();
162
181
  }
163
- async stop() {
164
- this.stopped = true;
165
- const activeSlot = this.activeTurnSlot;
166
- this.activeTurnSlot = null;
167
- if (activeSlot !== null) {
168
- activeSlot.collector.dispose();
169
- activeSlot.codec = null;
182
+ observeTerminal(turnId, terminal) {
183
+ this.unboundObservedTurnIds.delete(turnId);
184
+ const record = this.nativeTurns.get(turnId) ?? {
185
+ representative: null, members: [], codec: null, completion: null, terminal: null,
186
+ releaseAfterAdmissions: null,
187
+ };
188
+ if (record.terminal !== null || record.completion !== null)
189
+ return;
190
+ record.terminal = terminal;
191
+ record.releaseAfterAdmissions = new Set(this.inFlightNativeAdmissions);
192
+ this.nativeTurns.set(turnId, record);
193
+ this.terminalOrder.push(turnId);
194
+ this.drainTerminalOrder();
195
+ }
196
+ drainTerminalOrder() {
197
+ while (this.terminalOrder.length > 0) {
198
+ const turnId = this.terminalOrder[0];
199
+ const record = this.nativeTurns.get(turnId);
200
+ if (record === undefined || record.terminal === null)
201
+ return;
202
+ if (record.representative === null) {
203
+ if (this.pendingAdmissions.size > 0)
204
+ return;
205
+ this.terminalOrder.shift();
206
+ this.nativeTurns.delete(turnId);
207
+ this.pendingActivity.delete(turnId);
208
+ this.unboundObservedTurnIds.delete(turnId);
209
+ this.collector?.releaseTurn(turnId);
210
+ this.log('warn', `dropping native terminal ${turnId} without an accepted submission`);
211
+ continue;
212
+ }
213
+ this.terminalOrder.shift();
214
+ this.finalize(turnId, record, record.terminal);
170
215
  }
171
- if (activeSlot !== null && activeSlot.turnId === null) {
172
- activeSlot.rejectTurnId(new Error('codex turn stopped before acceptance'));
216
+ }
217
+ finalize(turnId, record, terminal) {
218
+ if (record.completion !== null)
219
+ return;
220
+ if (record.representative === null)
221
+ return;
222
+ let completion;
223
+ if (terminal instanceof Error) {
224
+ completion = Object.freeze({ status: 'failed', displaySubmission: record.representative, error: terminal });
173
225
  }
174
- // Any turn still in flight at teardown will never reach `turn/completed`
175
- // (the WS is closing). Settle each as `stopped` so an interrupted teammate
176
- // turn is delivered with a status rather than vanishing.
177
- for (const turnId of this.pendingTurns.keys()) {
178
- this.opts.onTurnSettled?.({
179
- turnId,
180
- status: 'stopped',
181
- result: { text: null },
226
+ else {
227
+ let completedTurn = terminal;
228
+ try {
229
+ if (record.codec !== null)
230
+ completedTurn = restoreCollectedTurn(terminal, record.codec);
231
+ }
232
+ catch (error) {
233
+ completion = Object.freeze({ status: 'failed', displaySubmission: record.representative, error: asError(error) });
234
+ record.completion = completion;
235
+ for (const member of record.members)
236
+ member.settle({ kind: 'completion', completion });
237
+ record.members.length = 0;
238
+ record.terminal = null;
239
+ this.releaseRecordIfReady(turnId, record);
240
+ this.resolveIdleWaitersIfIdle();
241
+ return;
242
+ }
243
+ this.opts.onTurnCompleted?.(completedTurn);
244
+ completion = Object.freeze({
245
+ status: 'completed',
246
+ displaySubmission: record.representative,
247
+ resultText: extractAssistantText(completedTurn),
248
+ truncated: false,
182
249
  });
183
250
  }
184
- this.pendingTurns.clear();
185
- this.activeTurnId = null;
251
+ record.completion = completion;
252
+ for (const member of record.members)
253
+ member.settle({ kind: 'completion', completion });
254
+ record.members.length = 0;
255
+ record.terminal = null;
256
+ this.releaseRecordIfReady(turnId, record);
186
257
  this.resolveIdleWaitersIfIdle();
187
258
  }
188
- claimActiveTurnSlot(threadId, codec) {
189
- const active = this.activeTurnSlot;
190
- if (active !== null) {
191
- assertCompatibleCodec(active.codec, codec);
192
- return { slot: active, primary: false };
259
+ failProtocol(error) {
260
+ this.protocolFailure ??= error;
261
+ this.log('error', error.message, error);
262
+ this.terminalOrder.length = 0;
263
+ for (const [turnId, record] of this.nativeTurns) {
264
+ if (record.completion !== null)
265
+ continue;
266
+ this.failRecord(turnId, record, this.protocolFailure);
193
267
  }
194
- let resolveTurnId;
195
- let rejectTurnId;
196
- const turnIdPromise = new Promise((resolve, reject) => {
197
- resolveTurnId = resolve;
198
- rejectTurnId = reject;
199
- });
200
- // The primary submitter returns its own failure directly. The shared promise
201
- // only exists for concurrent followers waiting for that primary turn id, so
202
- // reject it without producing an unhandled rejection when there are none.
203
- turnIdPromise.catch(() => undefined);
204
- const slot = {
205
- collector: subscribeTurnCollection(this.opts.client, threadId),
206
- codec,
207
- turnId: null,
208
- candidateTurnId: null,
209
- primaryFailed: false,
210
- pendingSubmissions: 0,
211
- turnIdPromise,
212
- resolveTurnId,
213
- rejectTurnId,
214
- };
215
- this.activeTurnSlot = slot;
216
- return { slot, primary: true };
268
+ this.resolveIdleWaitersIfIdle();
217
269
  }
218
- recordTurnStartSuccess(slot, turnId, primary) {
219
- slot.pendingSubmissions = Math.max(0, slot.pendingSubmissions - 1);
220
- if (slot.turnId !== null)
221
- return slot.turnId;
222
- if (this.stopped) {
223
- slot.rejectTurnId(new Error('codex turn stopped before acceptance'));
224
- return null;
225
- }
226
- if (primary || slot.primaryFailed) {
227
- this.activateTurnSlot(slot, turnId);
228
- return turnId;
270
+ failRecord(turnId, record, error) {
271
+ for (const member of record.members)
272
+ member.settle({ kind: 'failed', error });
273
+ record.members.length = 0;
274
+ this.nativeTurns.delete(turnId);
275
+ this.pendingActivity.delete(turnId);
276
+ this.unboundObservedTurnIds.delete(turnId);
277
+ this.collector?.releaseTurn(turnId);
278
+ }
279
+ releaseCompletedRecords(admissionId) {
280
+ for (const [turnId, record] of this.nativeTurns) {
281
+ record.releaseAfterAdmissions?.delete(admissionId);
282
+ this.releaseRecordIfReady(turnId, record);
229
283
  }
230
- slot.candidateTurnId ??= turnId;
231
- return null;
232
284
  }
233
- recordTurnStartFailure(slot, error, primary) {
234
- slot.pendingSubmissions = Math.max(0, slot.pendingSubmissions - 1);
235
- if (slot.turnId !== null)
285
+ releaseRecordIfReady(turnId, record) {
286
+ if (record.completion === null || (record.releaseAfterAdmissions?.size ?? 0) > 0)
236
287
  return;
237
- if (primary)
238
- slot.primaryFailed = true;
239
- if (slot.primaryFailed && slot.candidateTurnId !== null) {
240
- this.activateTurnSlot(slot, slot.candidateTurnId);
288
+ this.nativeTurns.delete(turnId);
289
+ this.pendingActivity.delete(turnId);
290
+ this.unboundObservedTurnIds.delete(turnId);
291
+ this.collector?.releaseTurn(turnId);
292
+ }
293
+ dropOrphanActivityIfIdle() {
294
+ if (this.inFlightNativeAdmissions.size !== 0)
241
295
  return;
296
+ for (const turnId of this.unboundObservedTurnIds) {
297
+ if (this.nativeTurns.has(turnId))
298
+ continue;
299
+ this.pendingActivity.delete(turnId);
300
+ this.collector?.releaseTurn(turnId);
242
301
  }
243
- if (slot.primaryFailed && slot.pendingSubmissions === 0) {
244
- if (this.activeTurnSlot === slot)
245
- this.activeTurnSlot = null;
246
- slot.collector.dispose();
247
- slot.codec = null;
248
- slot.rejectTurnId(error);
249
- this.resolveIdleWaitersIfIdle();
250
- }
302
+ this.unboundObservedTurnIds.clear();
251
303
  }
252
- activateTurnSlot(slot, turnId) {
253
- if (slot.turnId !== null)
304
+ observeItem(turnId, item, phase, occurredAt) {
305
+ const record = this.nativeTurns.get(turnId);
306
+ if (record === undefined || record.representative === null) {
307
+ this.unboundObservedTurnIds.add(turnId);
308
+ if (this.inFlightNativeAdmissions.size === 0) {
309
+ this.dropOrphanActivityIfIdle();
310
+ return;
311
+ }
312
+ const activity = itemActivity(turnId, item, phase);
313
+ if (activity === null)
314
+ return;
315
+ const pending = this.pendingActivity.get(turnId) ?? [];
316
+ pending.push({ activity, occurredAt });
317
+ this.pendingActivity.set(turnId, pending);
318
+ return;
319
+ }
320
+ const activity = itemActivity(turnId, item, phase);
321
+ if (activity === null)
254
322
  return;
255
- slot.turnId = turnId;
256
- this.trackTurn(turnId, slot.collector, slot);
257
- slot.resolveTurnId(turnId);
323
+ this.emitActivity(record, activity, occurredAt);
258
324
  }
259
- /**
260
- * Record a submitted turn as pending and wire its completion. On
261
- * `turn/completed` the turn is removed from the pending set and the snapshot
262
- * hook fires (which is where the runtime emits the `completed` settlement). On
263
- * a terminal turn failure (collector rejects: codex `error` with
264
- * `willRetry: false`, or a `turn/completed` carrying `turn.error`) the turn is
265
- * settled as `failed` here, so a teammate turn that errors at the model level
266
- * is delivered with a status instead of hanging until teardown.
267
- */
268
- trackTurn(turnId, collector, slot) {
269
- if (this.pendingTurns.has(turnId))
325
+ emitActivity(record, activity, occurredAt) {
326
+ if (record.representative === null)
270
327
  return;
271
- this.pendingTurns.set(turnId, { codec: slot.codec });
272
- this.activeTurnId = turnId;
273
- void collector.awaitTurn(turnId).then((turn) => {
274
- // Only forward completion if this turn was still pending. If `stop()`
275
- // already settled it as `stopped`, the delete returns false and we drop
276
- // the late completion so a turn is never settled twice.
277
- const pending = this.pendingTurns.get(turnId);
278
- if (pending !== undefined && this.pendingTurns.delete(turnId)) {
279
- if (this.activeTurnSlot === slot)
280
- this.activeTurnSlot = null;
281
- if (this.activeTurnId === turnId)
282
- this.activeTurnId = null;
283
- let completedTurn;
284
- try {
285
- completedTurn = pending.codec === null
286
- ? turn
287
- : restoreCollectedTurn(turn, pending.codec);
288
- }
289
- catch (err) {
290
- this.opts.onTurnSettled?.({
291
- turnId,
292
- status: 'failed',
293
- result: { text: null },
294
- error: asError(err),
295
- });
296
- this.resolveIdleWaitersIfIdle();
297
- return;
328
+ try {
329
+ this.opts.activitySink(Object.freeze({ submission: record.representative, activity: Object.freeze(activity), occurredAt }));
330
+ }
331
+ catch (error) {
332
+ this.log('warn', 'codex activity projection failed', error);
333
+ }
334
+ }
335
+ reserveSource(sourceId, committed, order, pending, operation) {
336
+ if (sourceId === undefined || sourceId === '')
337
+ return operation();
338
+ if (committed.has(sourceId))
339
+ return Promise.resolve({ status: 'duplicate' });
340
+ const existing = pending.get(sourceId);
341
+ if (existing !== undefined)
342
+ return existing;
343
+ const task = Promise.resolve().then(operation).catch((error) => ({ status: 'ambiguous', error: asError(error) }));
344
+ pending.set(sourceId, task);
345
+ void task.then((admission) => {
346
+ if (admission.status === 'submitted' || admission.status === 'ambiguous') {
347
+ committed.add(sourceId);
348
+ order.push(sourceId);
349
+ while (order.length > this.messageIdDedupeWindow) {
350
+ const evicted = order.shift();
351
+ if (evicted !== undefined)
352
+ committed.delete(evicted);
298
353
  }
299
- this.opts.onTurnCompleted?.(completedTurn);
300
- this.resolveIdleWaitersIfIdle();
301
- }
302
- }, (err) => {
303
- // Same mutual-exclusion guard as the completed path: only settle as
304
- // `failed` if `stop()` did not already settle it as `stopped`.
305
- if (this.pendingTurns.delete(turnId)) {
306
- if (this.activeTurnSlot === slot)
307
- this.activeTurnSlot = null;
308
- if (this.activeTurnId === turnId)
309
- this.activeTurnId = null;
310
- this.opts.onTurnSettled?.({
311
- turnId,
312
- status: 'failed',
313
- result: { text: null },
314
- error: err instanceof Error ? err : new Error(String(err)),
315
- });
316
- this.resolveIdleWaitersIfIdle();
317
354
  }
355
+ if (pending.get(sourceId) === task)
356
+ pending.delete(sourceId);
318
357
  });
358
+ return task;
319
359
  }
320
- rememberMessageId(messageId) {
321
- if (messageId === '')
322
- return true;
323
- if (this.seenMessageIds.has(messageId))
324
- return false;
325
- this.seenMessageIds.add(messageId);
326
- this.seenMessageIdOrder.push(messageId);
327
- while (this.seenMessageIdOrder.length > this.messageIdDedupeWindow) {
328
- const evicted = this.seenMessageIdOrder.shift();
329
- if (evicted !== undefined)
330
- this.seenMessageIds.delete(evicted);
331
- }
332
- return true;
333
- }
334
- rememberTextInputId(id) {
335
- if (this.seenTextInputIds.has(id))
336
- return false;
337
- this.seenTextInputIds.add(id);
338
- this.seenTextInputIdOrder.push(id);
339
- while (this.seenTextInputIdOrder.length > this.messageIdDedupeWindow) {
340
- const evicted = this.seenTextInputIdOrder.shift();
341
- if (evicted !== undefined)
342
- this.seenTextInputIds.delete(evicted);
343
- }
344
- return true;
360
+ trackAdmission(admission) {
361
+ this.pendingAdmissions.add(admission);
362
+ void admission.finally(() => {
363
+ this.pendingAdmissions.delete(admission);
364
+ this.drainTerminalOrder();
365
+ this.resolveIdleWaitersIfIdle();
366
+ }).catch(() => undefined);
367
+ return admission;
345
368
  }
346
369
  resolveIdleWaitersIfIdle() {
347
370
  if (this.isBusy())
@@ -352,37 +375,80 @@ export class TurnManager {
352
375
  resolve?.();
353
376
  }
354
377
  }
355
- function assertCompatibleCodec(active, candidate) {
356
- if (active === null && candidate === null)
357
- return;
358
- if (active !== null && candidate !== null) {
359
- if (active.fingerprint === candidate.fingerprint)
360
- return;
361
- throw unsupportedFeatureError('outputSchema', 'codex active turn has an incompatible outputSchema');
378
+ function sameCodec(left, right) {
379
+ return left === null ? right === null : right !== null && left.fingerprint === right.fingerprint;
380
+ }
381
+ function createRuntimeSubmission() {
382
+ let resolve;
383
+ let settled = false;
384
+ const submission = Object.freeze({ settled: new Promise((value) => { resolve = value; }) });
385
+ return { submission, settle(settlement) { if (settled)
386
+ return false; settled = true; resolve(settlement); return true; } };
387
+ }
388
+ function itemActivity(turnId, item, phase) {
389
+ const itemId = typeof item.id === 'string' && item.id !== '' ? item.id : null;
390
+ if (itemId === null)
391
+ return null;
392
+ if (item.type === 'agentMessage') {
393
+ if (phase !== 'completed' || typeof item.text !== 'string' || item.text === '')
394
+ return null;
395
+ return { kind: 'assistant.message', id: `${turnId}:${itemId}:completed`, text: item.text, truncated: false };
396
+ }
397
+ const toolName = toolNameFor(item);
398
+ if (toolName === null)
399
+ return null;
400
+ const failed = phase === 'completed' && (item['status'] === 'failed' || item['error'] != null);
401
+ return {
402
+ kind: 'tool.call',
403
+ id: `${turnId}:${itemId}:${phase}`,
404
+ callId: itemId,
405
+ toolName,
406
+ status: phase === 'started' ? 'started' : failed ? 'failed' : 'completed',
407
+ arguments: toJsonValue(item['arguments'] ?? item['input'] ?? item['command'] ?? null),
408
+ result: phase === 'completed' ? toJsonValue(item['result'] ?? item['output'] ?? item['aggregatedOutput'] ?? null) : null,
409
+ error: failed ? String(item['error'] ?? 'tool call failed') : null,
410
+ };
411
+ }
412
+ function toolNameFor(item) {
413
+ if (item.type === 'commandExecution')
414
+ return 'exec_command';
415
+ if (item.type === 'fileChange')
416
+ return 'apply_patch';
417
+ if (item.type === 'mcpToolCall') {
418
+ const server = typeof item['server'] === 'string' ? item['server'] : null;
419
+ const tool = typeof item['tool'] === 'string' ? item['tool'] : null;
420
+ return server !== null && tool !== null ? `${server}.${tool}` : null;
421
+ }
422
+ if (typeof item['name'] === 'string')
423
+ return item['name'];
424
+ if (typeof item['tool'] === 'string')
425
+ return item['tool'];
426
+ return null;
427
+ }
428
+ function toJsonValue(value) {
429
+ if (value === undefined)
430
+ return null;
431
+ try {
432
+ return JSON.parse(JSON.stringify(value));
433
+ }
434
+ catch {
435
+ return String(value);
362
436
  }
363
- throw unsupportedFeatureError('outputSchema', active === null
364
- ? 'codex cannot fold structured output into an active unstructured turn'
365
- : 'codex cannot fold unstructured input into an active structured turn');
366
437
  }
367
438
  function restoreCollectedTurn(turn, codec) {
368
439
  const text = extractAssistantText(turn);
369
- if (text === null) {
370
- throw new Error(`codex outputSchema restoration for turn ${turn.turnId}: ` +
371
- 'completed turn has no assistant JSON text');
372
- }
440
+ if (text === null)
441
+ throw new Error('codex outputSchema restoration failed: completed turn has no assistant JSON text');
373
442
  const restoredText = codec.restore(text);
374
443
  let replaced = false;
375
444
  const items = [...turn.items].reverse().map((item) => {
376
- if (replaced || item.type !== 'agentMessage' || item.text !== text) {
445
+ if (replaced || item.type !== 'agentMessage' || item.text !== text)
377
446
  return item;
378
- }
379
447
  replaced = true;
380
448
  return { ...item, text: restoredText };
381
449
  }).reverse();
382
- if (!replaced) {
383
- throw new Error(`codex outputSchema restoration for turn ${turn.turnId}: ` +
384
- 'assistant JSON text was not found');
385
- }
450
+ if (!replaced)
451
+ throw new Error('codex outputSchema restoration failed: assistant JSON text was not found');
386
452
  return { ...turn, items };
387
453
  }
388
454
  function asError(error) {