gestalt-mobile 0.16.2 → 0.16.4
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/client/assets/index-D9v-aHYI.css +1 -0
- package/dist/client/assets/index-bKzoif3G.js +25 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/composition.js +21 -17
- package/dist/server/server/features/sessions/application/writer-acquisition.js +59 -0
- package/dist/server/server/features/sessions/get-history/history-mapper.js +56 -5
- package/dist/server/server/features/sessions/model/relay-session.js +8 -0
- package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +8 -2
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +38 -6
- package/dist/server/server/features/sessions/register-routes.js +2 -0
- package/dist/server/server/features/sessions/start-turn/endpoint.js +130 -12
- package/dist/server/server/platform/codex/json-rpc-client.js +13 -3
- package/dist/server/server/platform/codex/session-runtime.js +144 -9
- package/package.json +1 -1
- package/dist/client/assets/index-BotpWcLh.css +0 -1
- package/dist/client/assets/index-DT1VwqSF.js +0 -25
package/dist/client/index.html
CHANGED
|
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
16
16
|
<link rel="icon" href="/icons/gestalt-mobile-192.png" />
|
|
17
17
|
<link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
|
|
18
18
|
<title>Gestalt Mobile</title>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-bKzoif3G.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-D9v-aHYI.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="app"></div>
|
|
@@ -162,7 +162,7 @@ export async function composeRelayApp(options) {
|
|
|
162
162
|
}
|
|
163
163
|
events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
|
|
164
164
|
}
|
|
165
|
-
}, options.planMeasurementBaseUrl)
|
|
165
|
+
}, options.planMeasurementBaseUrl, 30_000, 64, root)
|
|
166
166
|
: null;
|
|
167
167
|
if (runtime && planMeasurementHelperPath) {
|
|
168
168
|
planMeasurementRefresh = new PlanMeasurementRefresh(async (sessionId) => {
|
|
@@ -189,7 +189,14 @@ export async function composeRelayApp(options) {
|
|
|
189
189
|
if (session)
|
|
190
190
|
saveSession(RelaySession.rehydrate(session).requireAttention(new Date().toISOString()).snapshot);
|
|
191
191
|
});
|
|
192
|
-
|
|
192
|
+
// An exited child has released our writer. Do not repeatedly resume a
|
|
193
|
+
// durable thread: another Codex client may acquire it between callbacks.
|
|
194
|
+
recoverExitedSession = (sessionId) => {
|
|
195
|
+
supervisor.cancel(sessionId);
|
|
196
|
+
const session = sessions.find(sessionId);
|
|
197
|
+
if (session)
|
|
198
|
+
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
199
|
+
};
|
|
193
200
|
}
|
|
194
201
|
let authorization;
|
|
195
202
|
if (passkeyAuthEnabled) {
|
|
@@ -283,8 +290,12 @@ export async function composeRelayApp(options) {
|
|
|
283
290
|
? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
|
|
284
291
|
: undefined,
|
|
285
292
|
startTurn: runtime
|
|
286
|
-
? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
|
|
293
|
+
? async (session, text, clientUserMessageId) => runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString())
|
|
294
|
+
: undefined,
|
|
295
|
+
ensureWriter: runtime
|
|
296
|
+
? (session) => runtime.ensureWriter(session, new Date().toISOString())
|
|
287
297
|
: undefined,
|
|
298
|
+
releaseWriter: runtime ? (id) => runtime.release(id) : undefined,
|
|
288
299
|
onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
|
|
289
300
|
models,
|
|
290
301
|
readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
|
|
@@ -301,7 +312,7 @@ export async function composeRelayApp(options) {
|
|
|
301
312
|
now: () => new Date().toISOString(),
|
|
302
313
|
list: () => sessions.list(),
|
|
303
314
|
save: saveSession,
|
|
304
|
-
|
|
315
|
+
read: (session) => runtime.readHistory(session),
|
|
305
316
|
})
|
|
306
317
|
: undefined,
|
|
307
318
|
release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
|
|
@@ -402,25 +413,18 @@ export async function composeRelayApp(options) {
|
|
|
402
413
|
database.close();
|
|
403
414
|
throw error;
|
|
404
415
|
}
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
.filter((session) => session.desiredState === 'active' && session.threadId !== null), 2, async (session) => {
|
|
411
|
-
try {
|
|
412
|
-
saveSession(await runtime.restore(session, new Date().toISOString()));
|
|
413
|
-
}
|
|
414
|
-
catch {
|
|
415
|
-
saveSession(RelaySession.rehydrate(session).requireAttention(new Date().toISOString()).snapshot);
|
|
416
|
-
}
|
|
416
|
+
const detachActiveSessions = async () => {
|
|
417
|
+
await mapWithConcurrency(sessions.list().filter((session) => session.threadId !== null), 2, async (session) => {
|
|
418
|
+
if (session.desiredState === 'active')
|
|
419
|
+
saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
420
|
+
await runtime?.watchPlanStatus(session);
|
|
417
421
|
});
|
|
418
422
|
};
|
|
419
423
|
app.addHook('onListen', async () => {
|
|
420
424
|
const profile = (await options.profiles.list()).find((item) => item.state === 'ok')?.name;
|
|
421
425
|
if (profile)
|
|
422
426
|
await editorSkillCatalog.refresh(profile, root);
|
|
423
|
-
await
|
|
427
|
+
await detachActiveSessions();
|
|
424
428
|
});
|
|
425
429
|
app.addHook('onClose', async () => {
|
|
426
430
|
planMeasurementRefresh?.stopAll();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
export class WriterAcquisitionError extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
constructor(kind) {
|
|
9
|
+
super(kind);
|
|
10
|
+
this.kind = kind;
|
|
11
|
+
this.name = 'WriterAcquisitionError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function writerAcquisitionProblem(kind) {
|
|
15
|
+
switch (kind) {
|
|
16
|
+
case 'writerBusy':
|
|
17
|
+
return {
|
|
18
|
+
status: 409,
|
|
19
|
+
code: 'SESSION_WRITER_BUSY',
|
|
20
|
+
retryable: true,
|
|
21
|
+
detail: 'This thread is active in another Codex client. Release it there, then retry.',
|
|
22
|
+
};
|
|
23
|
+
case 'rolloutMissing':
|
|
24
|
+
return {
|
|
25
|
+
status: 409,
|
|
26
|
+
code: 'SESSION_ROLLOUT_MISSING',
|
|
27
|
+
retryable: false,
|
|
28
|
+
detail: 'This stored thread is no longer available.',
|
|
29
|
+
};
|
|
30
|
+
case 'workspaceUnavailable':
|
|
31
|
+
return {
|
|
32
|
+
status: 409,
|
|
33
|
+
code: 'SESSION_WORKSPACE_UNAVAILABLE',
|
|
34
|
+
retryable: true,
|
|
35
|
+
detail: 'The session workspace is unavailable.',
|
|
36
|
+
};
|
|
37
|
+
case 'runtimeDependencyFailed':
|
|
38
|
+
return {
|
|
39
|
+
status: 502,
|
|
40
|
+
code: 'SESSION_RUNTIME_DEPENDENCY_FAILED',
|
|
41
|
+
retryable: true,
|
|
42
|
+
detail: 'A required Codex runtime dependency is unavailable.',
|
|
43
|
+
};
|
|
44
|
+
case 'protocolIncompatible':
|
|
45
|
+
return {
|
|
46
|
+
status: 503,
|
|
47
|
+
code: 'CODEX_PROTOCOL_INCOMPATIBLE',
|
|
48
|
+
retryable: false,
|
|
49
|
+
detail: 'The installed Codex runtime is incompatible with this relay.',
|
|
50
|
+
};
|
|
51
|
+
case 'runtimeUnavailable':
|
|
52
|
+
return {
|
|
53
|
+
status: 503,
|
|
54
|
+
code: 'SESSION_RUNTIME_UNAVAILABLE',
|
|
55
|
+
retryable: true,
|
|
56
|
+
detail: 'The Codex runtime is unavailable. Retry shortly.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -34,7 +34,16 @@ function toChatItem(item, timestamp, turnId) {
|
|
|
34
34
|
.join('\n')
|
|
35
35
|
: '';
|
|
36
36
|
return text
|
|
37
|
-
? [
|
|
37
|
+
? [
|
|
38
|
+
{
|
|
39
|
+
id,
|
|
40
|
+
kind: 'user',
|
|
41
|
+
text,
|
|
42
|
+
...owner,
|
|
43
|
+
...(typeof item.clientId === 'string' ? { operationId: item.clientId } : {}),
|
|
44
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
45
|
+
},
|
|
46
|
+
]
|
|
38
47
|
: [];
|
|
39
48
|
}
|
|
40
49
|
case 'agentMessage':
|
|
@@ -48,7 +57,7 @@ function toChatItem(item, timestamp, turnId) {
|
|
|
48
57
|
...(item.phase === 'commentary' || item.phase === 'final_answer'
|
|
49
58
|
? { phase: item.phase }
|
|
50
59
|
: {}),
|
|
51
|
-
...(timestamp ? { occurredAt: timestamp } : {}),
|
|
60
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
52
61
|
},
|
|
53
62
|
]
|
|
54
63
|
: [];
|
|
@@ -56,10 +65,28 @@ function toChatItem(item, timestamp, turnId) {
|
|
|
56
65
|
if (!Array.isArray(item.summary))
|
|
57
66
|
return [];
|
|
58
67
|
const summary = reasoningSummary(item.summary);
|
|
59
|
-
return summary.length
|
|
68
|
+
return summary.length
|
|
69
|
+
? [
|
|
70
|
+
{
|
|
71
|
+
id,
|
|
72
|
+
kind: 'reasoning',
|
|
73
|
+
summary,
|
|
74
|
+
...owner,
|
|
75
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
: [];
|
|
60
79
|
case 'plan':
|
|
61
80
|
return typeof item.text === 'string'
|
|
62
|
-
? [
|
|
81
|
+
? [
|
|
82
|
+
{
|
|
83
|
+
id,
|
|
84
|
+
kind: 'plan',
|
|
85
|
+
text: item.text,
|
|
86
|
+
...owner,
|
|
87
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
88
|
+
},
|
|
89
|
+
]
|
|
63
90
|
: [];
|
|
64
91
|
case 'commandExecution':
|
|
65
92
|
return typeof item.command === 'string' && typeof item.status === 'string'
|
|
@@ -70,6 +97,7 @@ function toChatItem(item, timestamp, turnId) {
|
|
|
70
97
|
command: item.command,
|
|
71
98
|
status: item.status,
|
|
72
99
|
...owner,
|
|
100
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
73
101
|
...(typeof item.exitCode === 'number' ? { exitCode: item.exitCode } : {}),
|
|
74
102
|
},
|
|
75
103
|
]
|
|
@@ -79,9 +107,32 @@ function toChatItem(item, timestamp, turnId) {
|
|
|
79
107
|
return [];
|
|
80
108
|
const paths = item.changes.flatMap((change) => isRecord(change) && typeof change.path === 'string' ? [change.path] : []);
|
|
81
109
|
return paths.length
|
|
82
|
-
? [
|
|
110
|
+
? [
|
|
111
|
+
{
|
|
112
|
+
id,
|
|
113
|
+
kind: 'fileChange',
|
|
114
|
+
paths,
|
|
115
|
+
status: item.status,
|
|
116
|
+
...owner,
|
|
117
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
118
|
+
},
|
|
119
|
+
]
|
|
83
120
|
: [];
|
|
84
121
|
}
|
|
122
|
+
case 'mcpToolCall':
|
|
123
|
+
case 'dynamicToolCall':
|
|
124
|
+
return typeof item.tool === 'string' && typeof item.status === 'string'
|
|
125
|
+
? [
|
|
126
|
+
{
|
|
127
|
+
id,
|
|
128
|
+
kind: 'tool',
|
|
129
|
+
name: item.tool,
|
|
130
|
+
status: item.status,
|
|
131
|
+
...owner,
|
|
132
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
: [];
|
|
85
136
|
default:
|
|
86
137
|
return [];
|
|
87
138
|
}
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
import { DomainError } from './errors.js';
|
|
7
7
|
import { createSkillSelection, normalizeSkillProfileName, } from '../../skills/model/skill-profile.js';
|
|
8
8
|
import { interactionId, profileName, sessionId, threadId, turnId, workspaceId, workspacePath, } from './value-objects.js';
|
|
9
|
+
/** A durable thread can be read even when this relay has no live writer. */
|
|
10
|
+
export function isSessionReadable(session) {
|
|
11
|
+
return session.threadId !== null;
|
|
12
|
+
}
|
|
13
|
+
/** Writer ownership is deliberately transient: only a live relay state implies it. */
|
|
14
|
+
export function relayOwnsWriter(session) {
|
|
15
|
+
return session.state === 'ready' || session.state === 'turnActive';
|
|
16
|
+
}
|
|
9
17
|
export function createEffectiveSkillSelection(input) {
|
|
10
18
|
return {
|
|
11
19
|
...(input.selectedProfileName === undefined
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { RecentThreadHistoryUnavailable } from './use-case.js';
|
|
7
8
|
const requestSchema = z.object({
|
|
8
9
|
threadId: z.string().min(1),
|
|
9
10
|
cwd: z.string().startsWith('/'),
|
|
@@ -20,9 +21,14 @@ export function registerPromoteRecentThread(app, deps) {
|
|
|
20
21
|
return reply.code(202).send(await deps.promote(thread));
|
|
21
22
|
}
|
|
22
23
|
catch (error) {
|
|
24
|
+
const historyUnavailable = error instanceof RecentThreadHistoryUnavailable;
|
|
23
25
|
return reply.code(409).send({
|
|
24
|
-
code:
|
|
25
|
-
|
|
26
|
+
code: historyUnavailable
|
|
27
|
+
? 'RECENT_THREAD_HISTORY_UNAVAILABLE'
|
|
28
|
+
: 'RECENT_THREAD_OPEN_FAILED',
|
|
29
|
+
detail: historyUnavailable
|
|
30
|
+
? 'The selected thread history is currently unavailable. Retry after Codex is available.'
|
|
31
|
+
: 'The selected thread could not be opened. Retry shortly.',
|
|
26
32
|
});
|
|
27
33
|
}
|
|
28
34
|
});
|
|
@@ -4,10 +4,38 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { RelaySession } from '../model/relay-session.js';
|
|
7
|
+
export class RecentThreadHistoryUnavailable extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('RECENT_THREAD_HISTORY_UNAVAILABLE');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const inFlightPromotions = new Map();
|
|
7
13
|
export async function promoteRecentThread(thread, deps) {
|
|
14
|
+
const key = `${thread.profile}:${thread.cwd}:${thread.id}`;
|
|
15
|
+
const ongoing = inFlightPromotions.get(key);
|
|
16
|
+
if (ongoing)
|
|
17
|
+
return ongoing;
|
|
18
|
+
const operation = promote(thread, deps);
|
|
19
|
+
inFlightPromotions.set(key, operation);
|
|
20
|
+
try {
|
|
21
|
+
return await operation;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (inFlightPromotions.get(key) === operation)
|
|
25
|
+
inFlightPromotions.delete(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function promote(thread, deps) {
|
|
8
29
|
const existing = deps.list().find((session) => session.threadId === thread.id);
|
|
9
|
-
if (existing
|
|
30
|
+
if (existing) {
|
|
31
|
+
try {
|
|
32
|
+
await deps.read(existing);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new RecentThreadHistoryUnavailable();
|
|
36
|
+
}
|
|
10
37
|
return existing;
|
|
38
|
+
}
|
|
11
39
|
const now = deps.now();
|
|
12
40
|
const imported = existing ??
|
|
13
41
|
RelaySession.fromExistingThread({
|
|
@@ -18,9 +46,13 @@ export async function promoteRecentThread(thread, deps) {
|
|
|
18
46
|
threadId: thread.id,
|
|
19
47
|
now,
|
|
20
48
|
}).snapshot;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
49
|
+
// Validate first: an unavailable thread must never leave an imported stub.
|
|
50
|
+
try {
|
|
51
|
+
await deps.read(imported);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new RecentThreadHistoryUnavailable();
|
|
55
|
+
}
|
|
56
|
+
deps.save(imported);
|
|
57
|
+
return imported;
|
|
26
58
|
}
|
|
@@ -60,6 +60,8 @@ export function registerSessionRoutes(app, deps) {
|
|
|
60
60
|
registerStartTurn(app, {
|
|
61
61
|
find: sessions.find,
|
|
62
62
|
start: sessions.startTurn,
|
|
63
|
+
ensureWriter: sessions.ensureWriter,
|
|
64
|
+
releaseWriter: sessions.releaseWriter,
|
|
63
65
|
save: sessions.save,
|
|
64
66
|
onStarted: sessions.onTurnStarted,
|
|
65
67
|
idempotency: sessions.idempotency,
|
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { RelaySession } from '../model/relay-session.js';
|
|
8
|
+
import { WriterAcquisitionError, writerAcquisitionProblem, } from '../application/writer-acquisition.js';
|
|
7
9
|
export function registerStartTurn(app, deps) {
|
|
8
10
|
const inflight = new Map();
|
|
11
|
+
const sessionOperations = new Map();
|
|
9
12
|
// Durable idempotency results are session-scoped and replay for as long as the
|
|
10
13
|
// store retains them; a reused key with a different prompt is rejected.
|
|
11
14
|
// Text is capped at 100,000 characters; leave room for JSON encoding only.
|
|
@@ -16,7 +19,7 @@ export function registerStartTurn(app, deps) {
|
|
|
16
19
|
const text = request.body.text?.trim() ?? '';
|
|
17
20
|
if (text.length > 100_000)
|
|
18
21
|
return reply.code(400).send({ code: 'TURN_INPUT_TOO_LONG' });
|
|
19
|
-
if (!text
|
|
22
|
+
if (!text)
|
|
20
23
|
return reply.code(409).send({ code: 'SESSION_NOT_READY' });
|
|
21
24
|
const key = request.headers['idempotency-key'];
|
|
22
25
|
if (typeof key === 'string' && key && deps.idempotency) {
|
|
@@ -28,15 +31,31 @@ export function registerStartTurn(app, deps) {
|
|
|
28
31
|
const inflightKey = `${scope}:${key}`;
|
|
29
32
|
const operation = inflight.get(inflightKey) ??
|
|
30
33
|
(async () => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
return serializeSession(sessionOperations, session.id, async () => {
|
|
35
|
+
const current = deps.find(session.id);
|
|
36
|
+
if (!current)
|
|
37
|
+
return { statusCode: 404, body: JSON.stringify({ code: 'SESSION_NOT_FOUND' }) };
|
|
38
|
+
const durable = deps.idempotency.get(scope, key);
|
|
39
|
+
if (durable)
|
|
40
|
+
return durable;
|
|
41
|
+
if (current.state === 'turnActive')
|
|
42
|
+
return { statusCode: 409, body: JSON.stringify({ code: 'SESSION_TURN_ACTIVE' }) };
|
|
43
|
+
let started;
|
|
44
|
+
try {
|
|
45
|
+
started = await startWithWriter(deps, current, text, key);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof StartTurnProblem)
|
|
49
|
+
return error.result;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const result = {
|
|
53
|
+
statusCode: 202,
|
|
54
|
+
body: JSON.stringify({ fingerprint, response: started }),
|
|
55
|
+
};
|
|
56
|
+
deps.idempotency.put(scope, key, result.statusCode, result.body);
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
40
59
|
})();
|
|
41
60
|
inflight.set(inflightKey, operation);
|
|
42
61
|
try {
|
|
@@ -46,14 +65,113 @@ export function registerStartTurn(app, deps) {
|
|
|
46
65
|
inflight.delete(inflightKey);
|
|
47
66
|
}
|
|
48
67
|
}
|
|
49
|
-
|
|
68
|
+
return serializeSession(sessionOperations, session.id, async () => {
|
|
69
|
+
const current = deps.find(session.id);
|
|
70
|
+
if (!current)
|
|
71
|
+
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
72
|
+
if (current.state === 'turnActive')
|
|
73
|
+
return reply.code(409).send({ code: 'SESSION_TURN_ACTIVE' });
|
|
74
|
+
try {
|
|
75
|
+
return reply
|
|
76
|
+
.code(202)
|
|
77
|
+
.send(await startWithWriter(deps, current, text, typeof key === 'string' ? key : undefined));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof StartTurnProblem)
|
|
81
|
+
return reply.code(error.result.statusCode).send(JSON.parse(error.result.body));
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function startWithWriter(deps, session, text, key) {
|
|
88
|
+
const writable = await ensureWriter(deps, session);
|
|
89
|
+
if ('body' in writable)
|
|
90
|
+
throw new StartTurnProblem(writable);
|
|
91
|
+
try {
|
|
92
|
+
if (writable.session !== session)
|
|
93
|
+
deps.save(writable.session);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
await cleanupAcquiredWriter(deps, writable.session, session);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const started = await deps.start(writable.session, text, key);
|
|
50
101
|
deps.save(started);
|
|
51
102
|
deps.onStarted?.(started);
|
|
52
|
-
return
|
|
103
|
+
return started;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (writable.session !== session)
|
|
107
|
+
await cleanupAcquiredWriter(deps, writable.session, session);
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
class StartTurnProblem extends Error {
|
|
112
|
+
result;
|
|
113
|
+
constructor(result) {
|
|
114
|
+
super('START_TURN_PROBLEM');
|
|
115
|
+
this.result = result;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function serializeSession(operations, sessionId, operation) {
|
|
119
|
+
const prior = operations.get(sessionId) ?? Promise.resolve();
|
|
120
|
+
let release;
|
|
121
|
+
const current = new Promise((resolve) => {
|
|
122
|
+
release = resolve;
|
|
53
123
|
});
|
|
124
|
+
operations.set(sessionId, current);
|
|
125
|
+
await prior;
|
|
126
|
+
try {
|
|
127
|
+
return await operation();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
release();
|
|
131
|
+
if (operations.get(sessionId) === current)
|
|
132
|
+
operations.delete(sessionId);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Cleanup is best effort: never replace the original start/persistence error. */
|
|
136
|
+
async function cleanupAcquiredWriter(deps, acquired, original) {
|
|
137
|
+
try {
|
|
138
|
+
await deps.releaseWriter?.(acquired.id);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Process loss is already a valid release outcome.
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
deps.save(original);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// The original I/O failure remains the observable failure.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function ensureWriter(deps, session) {
|
|
151
|
+
if (!deps.ensureWriter) {
|
|
152
|
+
if (session.state === 'ready')
|
|
153
|
+
return { session, replacementCreated: false };
|
|
154
|
+
return failure('runtimeUnavailable');
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return await deps.ensureWriter(session);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error instanceof WriterAcquisitionError &&
|
|
161
|
+
error.kind === 'writerBusy' &&
|
|
162
|
+
session.state !== 'stopped')
|
|
163
|
+
deps.save(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
|
|
164
|
+
return failure(error instanceof WriterAcquisitionError ? error.kind : 'runtimeUnavailable');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function failure(kind) {
|
|
168
|
+
const problem = writerAcquisitionProblem(kind);
|
|
169
|
+
return { statusCode: problem.status, body: JSON.stringify(problem) };
|
|
54
170
|
}
|
|
55
171
|
function replay(result, fingerprint, reply) {
|
|
56
172
|
const cached = JSON.parse(result.body);
|
|
173
|
+
if (cached.fingerprint === undefined)
|
|
174
|
+
return reply.code(result.statusCode).send(cached);
|
|
57
175
|
if (cached.fingerprint !== fingerprint)
|
|
58
176
|
return reply.code(409).send({ code: 'IDEMPOTENCY_KEY_REUSED' });
|
|
59
177
|
return reply.code(result.statusCode).send(cached.response);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { createInterface } from 'node:readline';
|
|
7
7
|
export const CODEX_THREAD_NOT_FOUND = 'CODEX_THREAD_NOT_FOUND';
|
|
8
8
|
export const CODEX_JSON_RPC_ERROR = 'CODEX_JSON_RPC_ERROR';
|
|
9
|
+
export const CODEX_THREAD_WRITER_BUSY = 'CODEX_THREAD_WRITER_BUSY';
|
|
9
10
|
/** A bounded representation of an app-server JSON-RPC failure. */
|
|
10
11
|
export class CodexJsonRpcError extends Error {
|
|
11
12
|
code;
|
|
@@ -25,10 +26,19 @@ export class CodexJsonRpcError extends Error {
|
|
|
25
26
|
export function isMissingCodexThreadRollout(error) {
|
|
26
27
|
return error instanceof CodexJsonRpcError && error.kind === CODEX_THREAD_NOT_FOUND;
|
|
27
28
|
}
|
|
29
|
+
/** Compatibility shim for the confirmed Codex 0.146 active-writer response. */
|
|
30
|
+
export function isCodexThreadWriterBusy(error) {
|
|
31
|
+
return error instanceof CodexJsonRpcError && error.kind === CODEX_THREAD_WRITER_BUSY;
|
|
32
|
+
}
|
|
28
33
|
function classifyCodexJsonRpcError(code, message) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
if (code !== -32600)
|
|
35
|
+
return CODEX_JSON_RPC_ERROR;
|
|
36
|
+
if (/^no rollout found for thread id\b/i.test(message))
|
|
37
|
+
return CODEX_THREAD_NOT_FOUND;
|
|
38
|
+
// Fixture-backed exact protocol wording: do not broaden this into a heuristic.
|
|
39
|
+
if (/^thread .* already has an active writer$/i.test(message))
|
|
40
|
+
return CODEX_THREAD_WRITER_BUSY;
|
|
41
|
+
return CODEX_JSON_RPC_ERROR;
|
|
32
42
|
}
|
|
33
43
|
function boundMessage(message) {
|
|
34
44
|
return message
|