gestalt-mobile 0.16.3 → 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 +20 -16
- package/dist/server/server/features/sessions/application/writer-acquisition.js +59 -0
- 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 +94 -8
- package/package.json +1 -1
- package/dist/client/assets/index-BotpWcLh.css +0 -1
- package/dist/client/assets/index-DWHYFaoK.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) {
|
|
@@ -285,6 +292,10 @@ export async function composeRelayApp(options) {
|
|
|
285
292
|
startTurn: runtime
|
|
286
293
|
? async (session, text, clientUserMessageId) => runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString())
|
|
287
294
|
: undefined,
|
|
295
|
+
ensureWriter: runtime
|
|
296
|
+
? (session) => runtime.ensureWriter(session, new Date().toISOString())
|
|
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
|
+
}
|
|
@@ -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
|
|
@@ -9,6 +9,8 @@ import { createPlanMeasurementSnapshot, } from '../../features/plans/application
|
|
|
9
9
|
import { canRebindMissingRollout, rebindMissingRollout, } from '../../features/sessions/restore-session/use-case.js';
|
|
10
10
|
import { gestaltQuizDynamicTool } from '../../../shared/contracts/quiz.js';
|
|
11
11
|
import { threadPlanName } from './thread-plan-name.js';
|
|
12
|
+
import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
|
|
13
|
+
import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
|
|
12
14
|
/** One private owner for every resource acquired for a live Codex child. */
|
|
13
15
|
class SessionResource {
|
|
14
16
|
sessionId;
|
|
@@ -65,10 +67,11 @@ export class CodexSessionRuntime {
|
|
|
65
67
|
planMeasurementBaseUrl;
|
|
66
68
|
requestTimeoutMs;
|
|
67
69
|
maxPendingRequests;
|
|
70
|
+
readerCwd;
|
|
68
71
|
constructor(launch,
|
|
69
72
|
// Kept as an ignored compatibility slot while callers migrate from the old
|
|
70
73
|
// correlated-map constructor shape. Live ownership is `sessions` only.
|
|
71
|
-
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl, requestTimeoutMs = 30_000, maxPendingRequests = 64) {
|
|
74
|
+
_legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl, requestTimeoutMs = 30_000, maxPendingRequests = 64, readerCwd) {
|
|
72
75
|
this.launch = launch;
|
|
73
76
|
this.onNotification = onNotification;
|
|
74
77
|
this.onServerRequest = onServerRequest;
|
|
@@ -79,9 +82,12 @@ export class CodexSessionRuntime {
|
|
|
79
82
|
this.planMeasurementBaseUrl = planMeasurementBaseUrl;
|
|
80
83
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
81
84
|
this.maxPendingRequests = maxPendingRequests;
|
|
85
|
+
this.readerCwd = readerCwd;
|
|
82
86
|
void _legacyProcesses;
|
|
83
87
|
}
|
|
84
88
|
sessions = new Map();
|
|
89
|
+
historyReads = new Map();
|
|
90
|
+
writerAcquisitions = new Map();
|
|
85
91
|
async start(session, now, settings = {}) {
|
|
86
92
|
const resource = await this.createResource(session);
|
|
87
93
|
try {
|
|
@@ -120,6 +126,12 @@ export class CodexSessionRuntime {
|
|
|
120
126
|
for (const resource of [...this.sessions.values()])
|
|
121
127
|
resource.dispose();
|
|
122
128
|
}
|
|
129
|
+
/** Keeps a detached session's supervised-plan status observable without launching Codex. */
|
|
130
|
+
async watchPlanStatus(session) {
|
|
131
|
+
if (!this.planStatusSource)
|
|
132
|
+
return;
|
|
133
|
+
await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update));
|
|
134
|
+
}
|
|
123
135
|
resolveServerRequest(sessionId, requestId, result) {
|
|
124
136
|
const resource = this.sessions.get(sessionId);
|
|
125
137
|
const pending = resource?.pendingRequests.get(requestId);
|
|
@@ -142,6 +154,35 @@ export class CodexSessionRuntime {
|
|
|
142
154
|
}));
|
|
143
155
|
return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
|
|
144
156
|
}
|
|
157
|
+
/** The sole authoritative in-process ownership probe. It never launches a child. */
|
|
158
|
+
ownsWriter(sessionId) {
|
|
159
|
+
return this.sessions.get(sessionId)?.active === true;
|
|
160
|
+
}
|
|
161
|
+
async ensureWriter(session, now) {
|
|
162
|
+
if (this.ownsWriter(session.id))
|
|
163
|
+
return { session, replacementCreated: false };
|
|
164
|
+
const inflight = this.writerAcquisitions.get(session.id);
|
|
165
|
+
if (inflight)
|
|
166
|
+
return inflight;
|
|
167
|
+
const acquisition = this.acquireWriter(session, now);
|
|
168
|
+
this.writerAcquisitions.set(session.id, acquisition);
|
|
169
|
+
try {
|
|
170
|
+
return await acquisition;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (this.writerAcquisitions.get(session.id) === acquisition)
|
|
174
|
+
this.writerAcquisitions.delete(session.id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async acquireWriter(session, now) {
|
|
178
|
+
try {
|
|
179
|
+
const restored = await this.restoreWithOutcome(session, now);
|
|
180
|
+
return { session: restored.session, replacementCreated: restored.replacementCreated };
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
throw classifyWriterAcquisitionFailure(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
145
186
|
async interruptTurn(session, turnId) {
|
|
146
187
|
const resource = this.sessions.get(session.id);
|
|
147
188
|
if (!resource || !session.threadId)
|
|
@@ -170,13 +211,44 @@ export class CodexSessionRuntime {
|
|
|
170
211
|
return Boolean(token && authorization === `Bearer ${token}`);
|
|
171
212
|
}
|
|
172
213
|
async readHistory(session) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
214
|
+
if (!session.threadId)
|
|
215
|
+
throw new Error('CODEX_THREAD_ID_MISSING');
|
|
216
|
+
const owned = this.sessions.get(session.id);
|
|
217
|
+
if (owned)
|
|
218
|
+
return this.decodeHistory(owned.process, session.threadId);
|
|
219
|
+
const existing = this.historyReads.get(session.id);
|
|
220
|
+
if (existing)
|
|
221
|
+
return existing;
|
|
222
|
+
const read = this.readDetachedHistory(session);
|
|
223
|
+
this.historyReads.set(session.id, read);
|
|
224
|
+
try {
|
|
225
|
+
return await read;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (this.historyReads.get(session.id) === read)
|
|
229
|
+
this.historyReads.delete(session.id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async readDetachedHistory(session) {
|
|
233
|
+
// A reader is intentionally not a SessionResource: it owns no subscriptions,
|
|
234
|
+
// runtime registration, plan lease, or writer state and is closed on every path.
|
|
235
|
+
const process = this.launch({
|
|
236
|
+
profile: session.profile,
|
|
237
|
+
cwd: this.readerCwd ?? session.workspacePath,
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
await process.rpc.request('initialize', {
|
|
241
|
+
clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
|
|
242
|
+
capabilities: { experimentalApi: true },
|
|
243
|
+
});
|
|
244
|
+
return await this.decodeHistory(process, session.threadId);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
process.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async decodeHistory(process, threadId) {
|
|
251
|
+
const result = decodeThreadRead(await process.rpc.request('thread/read', { threadId, includeTurns: true }));
|
|
180
252
|
const rawTurns = result;
|
|
181
253
|
const activeTurn = rawTurns.find((turn) => turn.status === 'inProgress' && typeof turn.id === 'string');
|
|
182
254
|
return {
|
|
@@ -343,6 +415,20 @@ export class CodexSessionRuntime {
|
|
|
343
415
|
function isMethodNotFound(error) {
|
|
344
416
|
return Boolean(error && typeof error === 'object' && error.code === -32601);
|
|
345
417
|
}
|
|
418
|
+
function classifyWriterAcquisitionFailure(error) {
|
|
419
|
+
if (isCodexThreadWriterBusy(error))
|
|
420
|
+
return new WriterAcquisitionError('writerBusy');
|
|
421
|
+
if (isMissingCodexThreadRollout(error))
|
|
422
|
+
return new WriterAcquisitionError('rolloutMissing');
|
|
423
|
+
if (isMethodNotFound(error))
|
|
424
|
+
return new WriterAcquisitionError('protocolIncompatible');
|
|
425
|
+
const code = error instanceof Error ? error.message : '';
|
|
426
|
+
if (code === 'ENOENT' || code === 'CODEX_THREAD_ID_MISSING')
|
|
427
|
+
return new WriterAcquisitionError('workspaceUnavailable');
|
|
428
|
+
if (/MCP|DEPENDENCY|SKILL/i.test(code))
|
|
429
|
+
return new WriterAcquisitionError('runtimeDependencyFailed');
|
|
430
|
+
return new WriterAcquisitionError('runtimeUnavailable');
|
|
431
|
+
}
|
|
346
432
|
function rateLimitWindows(value) {
|
|
347
433
|
const limits = asRecord(value)?.rateLimits;
|
|
348
434
|
if (!Array.isArray(limits))
|