@canonmsg/codex-plugin 0.31.0 → 0.32.1
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/completed-output.d.ts +60 -0
- package/dist/completed-output.js +149 -0
- package/dist/host-lifecycle.d.ts +9 -0
- package/dist/host-lifecycle.js +17 -0
- package/dist/host.js +411 -303
- package/package.json +7 -7
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { SendMessageOptions } from '@canonmsg/core';
|
|
2
|
+
export interface CodexCompletedOutput {
|
|
3
|
+
version: 1;
|
|
4
|
+
turnId: string;
|
|
5
|
+
conversationId: string;
|
|
6
|
+
sourceMessageId: string | null;
|
|
7
|
+
nativeThreadId: string | null;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
audience: {
|
|
10
|
+
memberIds: string[];
|
|
11
|
+
membershipRevision?: number;
|
|
12
|
+
} | null;
|
|
13
|
+
output: {
|
|
14
|
+
kind: 'message';
|
|
15
|
+
text: string;
|
|
16
|
+
options: SendMessageOptions & {
|
|
17
|
+
messageId: string;
|
|
18
|
+
};
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'plan';
|
|
21
|
+
body: {
|
|
22
|
+
conversationId: string;
|
|
23
|
+
interactionKind: 'plan';
|
|
24
|
+
planId: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
title: string;
|
|
27
|
+
body: string;
|
|
28
|
+
turnId: string;
|
|
29
|
+
responseUserId?: string;
|
|
30
|
+
};
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'none';
|
|
33
|
+
reason: 'silent' | 'interrupted' | 'empty';
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** A completed native result is replayable output, never permission to run it again. */
|
|
37
|
+
export declare function createCodexCompletedOutputJournal(directory: string, scope: unknown): {
|
|
38
|
+
save(record: CodexCompletedOutput): void;
|
|
39
|
+
list(): CodexCompletedOutput[];
|
|
40
|
+
remove(turnId: string): void;
|
|
41
|
+
};
|
|
42
|
+
export declare function codexPlanOutputOperationId(record: CodexCompletedOutput): string;
|
|
43
|
+
/** A new delivery after restart must not silently adopt a changed audience. */
|
|
44
|
+
export declare function assertCodexCompletedOutputAudience(record: CodexCompletedOutput, current: {
|
|
45
|
+
memberIds: string[];
|
|
46
|
+
membershipRevision?: number;
|
|
47
|
+
} | null): void;
|
|
48
|
+
/** Deleting nonblank streaming text promotes it to chat history on the server. */
|
|
49
|
+
export declare function clearCodexStreamingWithCompletedSilence(options: {
|
|
50
|
+
records: CodexCompletedOutput[];
|
|
51
|
+
read(): Promise<unknown>;
|
|
52
|
+
blank(turnId: string): Promise<void>;
|
|
53
|
+
clear(): Promise<void>;
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
/** Delete plaintext only after the exact output is durable and its input is settled. */
|
|
56
|
+
export declare function handoffCodexCompletedOutput(record: CodexCompletedOutput, handlers: {
|
|
57
|
+
publish(record: CodexCompletedOutput): Promise<void>;
|
|
58
|
+
settle(record: CodexCompletedOutput): Promise<void>;
|
|
59
|
+
remove(turnId: string): void;
|
|
60
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** A completed native result is replayable output, never permission to run it again. */
|
|
5
|
+
export function createCodexCompletedOutputJournal(directory, scope) {
|
|
6
|
+
const folder = join(directory, createHash('sha256').update(JSON.stringify(scope)).digest('hex'));
|
|
7
|
+
const filename = (turnId) => join(folder, `${createHash('sha256').update(turnId).digest('hex')}.json`);
|
|
8
|
+
function syncDirectory() {
|
|
9
|
+
const fd = openSync(folder, 'r');
|
|
10
|
+
try {
|
|
11
|
+
fsyncSync(fd);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
closeSync(fd);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function validate(record) {
|
|
18
|
+
if (record.version !== 1 || typeof record.turnId !== 'string' || !record.turnId
|
|
19
|
+
|| typeof record.conversationId !== 'string' || !record.conversationId || typeof record.createdAt !== 'string'
|
|
20
|
+
|| !['message', 'plan', 'none'].includes(record.output?.kind)
|
|
21
|
+
|| (record.output.kind === 'message' && (typeof record.output.text !== 'string' || typeof record.output.options?.messageId !== 'string'))
|
|
22
|
+
|| (record.output.kind === 'plan' && (record.output.body?.planId !== record.turnId || record.output.body?.conversationId !== record.conversationId))
|
|
23
|
+
|| (record.output.kind === 'none' && !['silent', 'interrupted', 'empty'].includes(record.output.reason))) {
|
|
24
|
+
throw new Error('Invalid completed Codex output record; operator reconciliation is required');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function recoverTemporaryFiles() {
|
|
28
|
+
for (const name of readdirSync(folder)) {
|
|
29
|
+
const match = /^([a-f0-9]{64})\.(\d+)\.[a-f0-9-]+\.tmp$/.exec(name);
|
|
30
|
+
if (!match)
|
|
31
|
+
continue;
|
|
32
|
+
try {
|
|
33
|
+
process.kill(Number(match[2]), 0);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code !== 'ESRCH')
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const path = join(folder, name);
|
|
41
|
+
let record;
|
|
42
|
+
try {
|
|
43
|
+
record = JSON.parse(readFileSync(path, 'utf8'));
|
|
44
|
+
validate(record);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A torn write never established a completed result. Its input stays
|
|
48
|
+
// uncertain; the native transcript is the remaining recovery source.
|
|
49
|
+
unlinkSync(path);
|
|
50
|
+
throw new Error('Incomplete Codex output write removed; reconcile its native transcript');
|
|
51
|
+
}
|
|
52
|
+
const destination = filename(record.turnId);
|
|
53
|
+
if (destination !== join(folder, `${match[1]}.json`))
|
|
54
|
+
throw new Error('Completed Codex output temporary file identity mismatch');
|
|
55
|
+
if (existsSync(destination)) {
|
|
56
|
+
if (readFileSync(destination, 'utf8') !== JSON.stringify(record))
|
|
57
|
+
throw new Error('Conflicting completed Codex outputs require reconciliation');
|
|
58
|
+
unlinkSync(path);
|
|
59
|
+
}
|
|
60
|
+
else
|
|
61
|
+
renameSync(path, destination);
|
|
62
|
+
syncDirectory();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
save(record) {
|
|
67
|
+
validate(record);
|
|
68
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
69
|
+
const path = filename(record.turnId);
|
|
70
|
+
const serialized = JSON.stringify(record);
|
|
71
|
+
if (existsSync(path)) {
|
|
72
|
+
if (readFileSync(path, 'utf8') !== serialized)
|
|
73
|
+
throw new Error('Completed Codex output cannot be replaced');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const temporary = `${path.slice(0, -5)}.${process.pid}.${randomUUID()}.tmp`;
|
|
77
|
+
try {
|
|
78
|
+
const fd = openSync(temporary, 'wx', 0o600);
|
|
79
|
+
try {
|
|
80
|
+
writeFileSync(fd, serialized);
|
|
81
|
+
fsyncSync(fd);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
renameSync(temporary, path);
|
|
87
|
+
syncDirectory();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
try {
|
|
91
|
+
unlinkSync(temporary);
|
|
92
|
+
}
|
|
93
|
+
catch { /* Renamed records remain recoverable. */ }
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
list() {
|
|
98
|
+
if (!existsSync(folder))
|
|
99
|
+
return [];
|
|
100
|
+
recoverTemporaryFiles();
|
|
101
|
+
return readdirSync(folder).filter((name) => name.endsWith('.json')).map((name) => {
|
|
102
|
+
const record = JSON.parse(readFileSync(join(folder, name), 'utf8'));
|
|
103
|
+
validate(record);
|
|
104
|
+
if (filename(record.turnId) !== join(folder, name))
|
|
105
|
+
throw new Error('Completed Codex output identity mismatch');
|
|
106
|
+
return record;
|
|
107
|
+
}).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
108
|
+
},
|
|
109
|
+
remove(turnId) {
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(filename(turnId));
|
|
112
|
+
syncDirectory();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error.code !== 'ENOENT')
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function codexPlanOutputOperationId(record) {
|
|
122
|
+
return createHash('sha256').update(JSON.stringify(['codex-plan-output', record.conversationId, record.turnId])).digest('hex');
|
|
123
|
+
}
|
|
124
|
+
/** A new delivery after restart must not silently adopt a changed audience. */
|
|
125
|
+
export function assertCodexCompletedOutputAudience(record, current) {
|
|
126
|
+
if (!record.audience || !current
|
|
127
|
+
|| (record.audience.membershipRevision !== undefined && record.audience.membershipRevision !== current.membershipRevision)
|
|
128
|
+
|| JSON.stringify([...record.audience.memberIds].sort()) !== JSON.stringify([...current.memberIds].sort())) {
|
|
129
|
+
throw new Error('Completed Codex output audience changed; operator reconciliation is required');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Deleting nonblank streaming text promotes it to chat history on the server. */
|
|
133
|
+
export async function clearCodexStreamingWithCompletedSilence(options) {
|
|
134
|
+
const silentTurns = new Set(options.records.filter((record) => record.output.kind === 'none' && record.output.reason === 'silent').map((record) => record.turnId));
|
|
135
|
+
if (!silentTurns.size)
|
|
136
|
+
return;
|
|
137
|
+
const value = await options.read();
|
|
138
|
+
const turnId = value?.turnId ?? value?.messageId;
|
|
139
|
+
if (!turnId || !silentTurns.has(turnId))
|
|
140
|
+
return;
|
|
141
|
+
await options.blank(turnId);
|
|
142
|
+
await options.clear();
|
|
143
|
+
}
|
|
144
|
+
/** Delete plaintext only after the exact output is durable and its input is settled. */
|
|
145
|
+
export async function handoffCodexCompletedOutput(record, handlers) {
|
|
146
|
+
await handlers.publish(record);
|
|
147
|
+
await handlers.settle(record);
|
|
148
|
+
handlers.remove(record.turnId);
|
|
149
|
+
}
|
package/dist/host-lifecycle.d.ts
CHANGED
|
@@ -2,3 +2,12 @@ export interface LongLivedStream {
|
|
|
2
2
|
start(): Promise<void>;
|
|
3
3
|
}
|
|
4
4
|
export declare function startCodexStreamInBackground(stream: LongLivedStream, onError: (error: unknown) => void): void;
|
|
5
|
+
/** Only fully initialized sessions may be reused by another inbound offer. */
|
|
6
|
+
export declare function initializeCodexSession<T>(options: {
|
|
7
|
+
key: string;
|
|
8
|
+
sessions: Map<string, T>;
|
|
9
|
+
create(): Promise<T>;
|
|
10
|
+
cleanup(): void;
|
|
11
|
+
}): Promise<T>;
|
|
12
|
+
/** Only an explicit local policy/configuration error may settle an unstarted input. */
|
|
13
|
+
export declare function classifyCodexSessionStart(error: unknown): 'configuration_required' | 'deferred';
|
package/dist/host-lifecycle.js
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
import { ExecutionEnvironmentError } from '@canonmsg/core';
|
|
1
2
|
export function startCodexStreamInBackground(stream, onError) {
|
|
2
3
|
stream.start().catch(onError);
|
|
3
4
|
}
|
|
5
|
+
/** Only fully initialized sessions may be reused by another inbound offer. */
|
|
6
|
+
export async function initializeCodexSession(options) {
|
|
7
|
+
try {
|
|
8
|
+
const session = await options.create();
|
|
9
|
+
options.sessions.set(options.key, session);
|
|
10
|
+
return session;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
options.cleanup();
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Only an explicit local policy/configuration error may settle an unstarted input. */
|
|
18
|
+
export function classifyCodexSessionStart(error) {
|
|
19
|
+
return error instanceof ExecutionEnvironmentError ? 'configuration_required' : 'deferred';
|
|
20
|
+
}
|
package/dist/host.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { setDefaultResultOrder } from 'node:dns';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { dirname } from 'node:path';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
8
|
-
import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting,
|
|
9
|
-
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError,
|
|
8
|
+
import { buildTrailBlockId, buildUndeliverableFinalNotice, captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, PLAN_BLOCK_TITLE, resolveTurnArtifactRouting, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, } from '@canonmsg/coding-agent-host';
|
|
9
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, buildCanonInboundFrameV1, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CANON_DIR, runtimePlanDescriptor, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, decideAutoReply, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, markLocalRuntimeStopped, normalizeTurnMetadata, parseTurnVerbosityConfig, RuntimeRequestManager, prepareConversationEnvironment, releaseConversationEnvironment, resolveLocalRuntimeSessionState, resolveCanonAgent, verifyResolvedAgentEnvironment, CanonApiError, sendMessageWithRetry, isPendingCanonOperation, sendMessageWithRetryChunked, saveRuntimeSessionState, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, isSilentTurnSuppressed, resolveSilentTurnDelivery, resolveTurnVerbosity, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
10
10
|
import { validateCard } from '@canonmsg/rich-cards';
|
|
11
11
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
12
12
|
import { CodexAppServerAdapter, } from './app-server-adapter.js';
|
|
@@ -16,7 +16,8 @@ import { clearStoredThreadId, buildCodexThreadPolicyFingerprint, loadStoredThrea
|
|
|
16
16
|
import { deriveCodexPermissionEnvelope, mapCanonPermissionToCodex, } from './permission-mode.js';
|
|
17
17
|
import { detectCodexCliVersion } from './codex-cli-version.js';
|
|
18
18
|
import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
|
|
19
|
-
import { startCodexStreamInBackground } from './host-lifecycle.js';
|
|
19
|
+
import { classifyCodexSessionStart, initializeCodexSession, startCodexStreamInBackground } from './host-lifecycle.js';
|
|
20
|
+
import { clearCodexStreamingWithCompletedSilence, assertCodexCompletedOutputAudience, createCodexCompletedOutputJournal, codexPlanOutputOperationId, handoffCodexCompletedOutput } from './completed-output.js';
|
|
20
21
|
import { createCodexControlPoller } from './control-channel.js';
|
|
21
22
|
import { runCli, acquireLock } from '@canonmsg/core';
|
|
22
23
|
import { runCodexWorkSessionHost } from './work-session-cli.js';
|
|
@@ -688,7 +689,8 @@ export async function main() {
|
|
|
688
689
|
lockHandle?.release();
|
|
689
690
|
throw error;
|
|
690
691
|
}
|
|
691
|
-
const client = new CanonClient(apiKey, baseUrl);
|
|
692
|
+
const client = new CanonClient(apiKey, baseUrl, { environmentId: resolvedAgent.environmentId, streamUrl });
|
|
693
|
+
const endpoint = await client.getEndpoint();
|
|
692
694
|
const rtdb = initRTDBAuth(client, { rtdbUrl, firebaseApiKey });
|
|
693
695
|
const typingSignals = createTypingStatusPublisher({
|
|
694
696
|
setTyping: (conversationId, typing, status) => status
|
|
@@ -815,6 +817,94 @@ export async function main() {
|
|
|
815
817
|
hostMode: true,
|
|
816
818
|
rtdb,
|
|
817
819
|
});
|
|
820
|
+
const endpointIdentity = await endpoint.ready();
|
|
821
|
+
const completedOutputs = createCodexCompletedOutputJournal(join(CANON_DIR, 'codex-completed-output'), {
|
|
822
|
+
runtimeId, environmentId: endpointIdentity.environmentId, principalId: endpointIdentity.principalId,
|
|
823
|
+
ownershipVersion: endpointIdentity.ownershipVersion ?? null, installationId: endpointIdentity.installationId,
|
|
824
|
+
});
|
|
825
|
+
const activeCompletedOutputs = new Set();
|
|
826
|
+
const recoveringOutputRooms = new Map();
|
|
827
|
+
let recoveringCompletedOutputs;
|
|
828
|
+
async function publishCompletedPlan(record) {
|
|
829
|
+
if (record.output.kind !== 'plan')
|
|
830
|
+
throw new Error('Expected completed Codex plan');
|
|
831
|
+
const operationId = codexPlanOutputOperationId(record);
|
|
832
|
+
if (await endpoint.getOperation(operationId)) {
|
|
833
|
+
return client.waitForOperation(operationId, { timeoutMs: 5_000 });
|
|
834
|
+
}
|
|
835
|
+
return endpoint.executeResult({ action: 'create_interaction', body: record.output.body }, { operationId });
|
|
836
|
+
}
|
|
837
|
+
runtimeRequests.register('plan', {
|
|
838
|
+
...runtimePlanDescriptor,
|
|
839
|
+
async create(input) {
|
|
840
|
+
const record = completedOutputs.list().find((item) => item.turnId === input.requestId && item.conversationId === input.conversationId);
|
|
841
|
+
if (!record || record.output.kind !== 'plan')
|
|
842
|
+
return runtimePlanDescriptor.create(input);
|
|
843
|
+
const created = await publishCompletedPlan(record);
|
|
844
|
+
return { requestId: created.planId, expiresAt: created.expiresAt, messageId: created.messageId };
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
async function deliverCompletedOutput(record, alreadyPublished = false) {
|
|
848
|
+
await handoffCodexCompletedOutput(record, {
|
|
849
|
+
publish: async () => {
|
|
850
|
+
if (alreadyPublished)
|
|
851
|
+
return;
|
|
852
|
+
if (record.output.kind === 'none') {
|
|
853
|
+
if (record.output.reason === 'silent')
|
|
854
|
+
await clearCompletedSilentStreaming(record.conversationId);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (record.output.kind === 'plan')
|
|
858
|
+
await publishCompletedPlan(record);
|
|
859
|
+
else {
|
|
860
|
+
const existing = await client.getMessageOperation(record.conversationId, record.output.options.messageId);
|
|
861
|
+
if (existing)
|
|
862
|
+
await client.waitForOperation(existing.operationId, { timeoutMs: 5_000 });
|
|
863
|
+
else
|
|
864
|
+
await sendMessageWithRetryChunked(client, record.conversationId, record.output.text, record.output.options, { timeoutMs: 5_000 });
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
settle: async () => {
|
|
868
|
+
if (record.sourceMessageId)
|
|
869
|
+
await endpoint.setInboundState(`message:${record.conversationId}:${record.sourceMessageId}`, 'settled', 'native-output-handed-off');
|
|
870
|
+
},
|
|
871
|
+
remove: completedOutputs.remove,
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
function recoverCompletedOutputs() {
|
|
875
|
+
if (recoveringCompletedOutputs)
|
|
876
|
+
return recoveringCompletedOutputs;
|
|
877
|
+
recoveringCompletedOutputs = (async () => {
|
|
878
|
+
for (const record of completedOutputs.list()) {
|
|
879
|
+
if (activeCompletedOutputs.has(record.turnId) || sessions.get(record.conversationId)?.running)
|
|
880
|
+
continue;
|
|
881
|
+
const recovery = (async () => {
|
|
882
|
+
const existing = record.output.kind === 'plan'
|
|
883
|
+
? await endpoint.getOperation(codexPlanOutputOperationId(record))
|
|
884
|
+
: record.output.kind === 'message'
|
|
885
|
+
? await client.getMessageOperation(record.conversationId, record.output.options.messageId)
|
|
886
|
+
: undefined;
|
|
887
|
+
if (record.output.kind !== 'none' && !existing) {
|
|
888
|
+
assertCodexCompletedOutputAudience(record, await client.getConversation(record.conversationId));
|
|
889
|
+
}
|
|
890
|
+
await deliverCompletedOutput(record);
|
|
891
|
+
})();
|
|
892
|
+
recoveringOutputRooms.set(record.conversationId, recovery);
|
|
893
|
+
try {
|
|
894
|
+
await recovery;
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
// Retain unresolved output, including rejected/uncertain delivery.
|
|
898
|
+
// Never rescan workspace files or submit another native turn here.
|
|
899
|
+
console.error(`[canon-codex] Completed output ${record.turnId} awaits delivery reconciliation:`, error);
|
|
900
|
+
}
|
|
901
|
+
finally {
|
|
902
|
+
recoveringOutputRooms.delete(record.conversationId);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
})().finally(() => { recoveringCompletedOutputs = undefined; });
|
|
906
|
+
return recoveringCompletedOutputs;
|
|
907
|
+
}
|
|
818
908
|
const sessions = new Map();
|
|
819
909
|
const pendingSessionCreations = new Map();
|
|
820
910
|
const recoveryCheckpointTrackers = new Map();
|
|
@@ -861,6 +951,8 @@ export async function main() {
|
|
|
861
951
|
...payload.changes,
|
|
862
952
|
memberIds,
|
|
863
953
|
});
|
|
954
|
+
void client.rememberConversation(conversationCache.get(payload.conversationId))
|
|
955
|
+
.catch((error) => console.error('[canon] Failed to persist conversation update:', error));
|
|
864
956
|
}
|
|
865
957
|
if (membershipChange) {
|
|
866
958
|
pendingMembershipChanges.set(payload.conversationId, membershipChange);
|
|
@@ -988,6 +1080,14 @@ export async function main() {
|
|
|
988
1080
|
settleRejectedPromptCheckpoints(conversationId, removed);
|
|
989
1081
|
writeTurn(session);
|
|
990
1082
|
}
|
|
1083
|
+
async function clearCompletedSilentStreaming(conversationId) {
|
|
1084
|
+
await clearCodexStreamingWithCompletedSilence({
|
|
1085
|
+
records: completedOutputs.list().filter((record) => record.conversationId === conversationId),
|
|
1086
|
+
read: () => rtdb.read(`/streaming/${conversationId}/${agentId}`),
|
|
1087
|
+
blank: (turnId) => runtimeState.writeStreaming(conversationId, { text: '', status: 'thinking', messageId: turnId, turnId, blocks: [] }),
|
|
1088
|
+
clear: () => runtimeState.clearStreaming(conversationId),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
991
1091
|
function clearStreaming(conversationId) {
|
|
992
1092
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
993
1093
|
}
|
|
@@ -1025,7 +1125,7 @@ export async function main() {
|
|
|
1025
1125
|
messageId: session.currentTurnId ?? undefined,
|
|
1026
1126
|
turnId: session.currentTurnId,
|
|
1027
1127
|
blocks: [],
|
|
1028
|
-
})
|
|
1128
|
+
});
|
|
1029
1129
|
}
|
|
1030
1130
|
function upsertCodexTextSegment(session, event) {
|
|
1031
1131
|
const next = applyTextSegmentBlock({
|
|
@@ -1088,7 +1188,7 @@ export async function main() {
|
|
|
1088
1188
|
}
|
|
1089
1189
|
async function handoffFinalMessage(conversationId) {
|
|
1090
1190
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
1091
|
-
clearStreaming(conversationId);
|
|
1191
|
+
await runtimeState.clearStreaming(conversationId);
|
|
1092
1192
|
typingSignals.clear(conversationId).catch(() => { });
|
|
1093
1193
|
}
|
|
1094
1194
|
function refreshVisibleWorkSignal(session) {
|
|
@@ -1236,136 +1336,146 @@ export async function main() {
|
|
|
1236
1336
|
workspaceCwd,
|
|
1237
1337
|
allowWorktrees: sessionExecutionMode === 'worktree',
|
|
1238
1338
|
});
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
|
|
1246
|
-
availableExecutionModes: hostAvailableExecutionModes,
|
|
1247
|
-
});
|
|
1248
|
-
if (persistedMapping.status === 'configuration_required') {
|
|
1249
|
-
throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1250
|
-
}
|
|
1251
|
-
if (persistedMapping.status === 'adopted'
|
|
1252
|
-
&& (persistedMapping.state.baseCwd !== environment.baseCwd
|
|
1253
|
-
|| persistedMapping.state.executionMode !== environment.mode)) {
|
|
1254
|
-
const restoredEnvironment = prepareConversationEnvironment({
|
|
1255
|
-
agentId,
|
|
1339
|
+
let createdAdapter;
|
|
1340
|
+
return initializeCodexSession({
|
|
1341
|
+
key: conversationId,
|
|
1342
|
+
sessions,
|
|
1343
|
+
create: async () => {
|
|
1344
|
+
const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
|
|
1256
1345
|
conversationId,
|
|
1257
|
-
|
|
1258
|
-
|
|
1346
|
+
baseCwd: environment.baseCwd,
|
|
1347
|
+
executionMode: environment.mode,
|
|
1348
|
+
resumeField: 'threadId',
|
|
1349
|
+
configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
|
|
1350
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
1259
1351
|
});
|
|
1260
|
-
if (
|
|
1261
|
-
|
|
1262
|
-
throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1352
|
+
if (persistedMapping.status === 'configuration_required') {
|
|
1353
|
+
throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1263
1354
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
model
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1355
|
+
if (persistedMapping.status === 'adopted'
|
|
1356
|
+
&& (persistedMapping.state.baseCwd !== environment.baseCwd
|
|
1357
|
+
|| persistedMapping.state.executionMode !== environment.mode)) {
|
|
1358
|
+
const restoredEnvironment = prepareConversationEnvironment({
|
|
1359
|
+
agentId,
|
|
1360
|
+
conversationId,
|
|
1361
|
+
workspaceCwd: persistedMapping.state.baseCwd,
|
|
1362
|
+
allowWorktrees: persistedMapping.state.executionMode === 'worktree',
|
|
1363
|
+
});
|
|
1364
|
+
if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
|
|
1365
|
+
releaseConversationEnvironment(restoredEnvironment);
|
|
1366
|
+
throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1367
|
+
}
|
|
1368
|
+
releaseConversationEnvironment(environment);
|
|
1369
|
+
environment = restoredEnvironment;
|
|
1370
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
|
|
1371
|
+
}
|
|
1372
|
+
const sessionCwd = environment.cwd;
|
|
1373
|
+
const policy = resolveCodexEffectiveRuntimePolicy({
|
|
1374
|
+
args,
|
|
1375
|
+
config: null,
|
|
1376
|
+
permissionEnvelope: codexPermissionEnvelope,
|
|
1377
|
+
environment,
|
|
1378
|
+
serviceAgentMode,
|
|
1379
|
+
});
|
|
1380
|
+
const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
|
|
1381
|
+
if (modelGuard) {
|
|
1382
|
+
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
1383
|
+
}
|
|
1384
|
+
const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint, {
|
|
1385
|
+
...(persistedMapping.status === 'new' || !persistedMapping.state.workspaceId
|
|
1386
|
+
? {}
|
|
1387
|
+
: { workspaceId: persistedMapping.state.workspaceId }),
|
|
1388
|
+
allowLegacyPolicyMigration: persistedMapping.status !== 'new',
|
|
1389
|
+
});
|
|
1390
|
+
const effectiveModel = policy.model ?? codexDefaultModel;
|
|
1391
|
+
const initialEffortResolution = resolveCodexEffortForModel({
|
|
1392
|
+
models: codexModels,
|
|
1393
|
+
model: effectiveModel,
|
|
1394
|
+
requestedEffort: configuredCodexEffort,
|
|
1395
|
+
});
|
|
1396
|
+
const initialEffort = initialEffortResolution.value;
|
|
1397
|
+
const adapter = useAppServer
|
|
1398
|
+
? new CodexAppServerAdapter({
|
|
1399
|
+
cwd: sessionCwd,
|
|
1400
|
+
threadId: storedThreadId,
|
|
1401
|
+
codexBin,
|
|
1402
|
+
model: policy.model ?? null,
|
|
1403
|
+
reasoningEffort: initialEffort,
|
|
1404
|
+
sandbox: policy.sandbox,
|
|
1405
|
+
addDirs: args['add-dir'] ?? [],
|
|
1406
|
+
configOverrides: args.config ?? [],
|
|
1407
|
+
fullAuto: policy.fullAuto,
|
|
1408
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1409
|
+
dynamicTools: codexDynamicTools,
|
|
1410
|
+
})
|
|
1411
|
+
: new CodexConversationAdapter({
|
|
1412
|
+
cwd: sessionCwd,
|
|
1413
|
+
threadId: storedThreadId,
|
|
1414
|
+
codexBin,
|
|
1415
|
+
model: policy.model ?? null,
|
|
1416
|
+
reasoningEffort: initialEffort,
|
|
1417
|
+
sandbox: policy.sandbox,
|
|
1418
|
+
codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
|
|
1419
|
+
addDirs: args['add-dir'] ?? [],
|
|
1420
|
+
configOverrides: args.config ?? [],
|
|
1421
|
+
fullAuto: policy.fullAuto,
|
|
1422
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1423
|
+
});
|
|
1424
|
+
createdAdapter = adapter;
|
|
1425
|
+
const session = {
|
|
1426
|
+
conversationId,
|
|
1308
1427
|
cwd: sessionCwd,
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1428
|
+
environment,
|
|
1429
|
+
adapter,
|
|
1430
|
+
queue: [],
|
|
1431
|
+
running: false,
|
|
1432
|
+
state: buildCodexInitialSessionState({
|
|
1433
|
+
model: effectiveModel ?? undefined,
|
|
1434
|
+
permissionMode: policy.permissionMode,
|
|
1435
|
+
effort: initialEffort,
|
|
1436
|
+
}),
|
|
1437
|
+
policyFingerprint: policy.fingerprint,
|
|
1438
|
+
turnState: 'idle',
|
|
1439
|
+
currentTurnId: null,
|
|
1440
|
+
currentTurnOpenedAt: null,
|
|
1441
|
+
currentTurnUpdatedAt: null,
|
|
1442
|
+
currentTurnCanUseCodexAppTools: false,
|
|
1443
|
+
currentReplyAuthority: null,
|
|
1444
|
+
currentTurnAbortController: null,
|
|
1445
|
+
// Corrected by the first turn that runs; a session with no turn
|
|
1446
|
+
// publishes nothing anyway, and quiet is never an accident.
|
|
1447
|
+
turnVerbosity: 'verbose',
|
|
1448
|
+
currentTurnSilenced: false,
|
|
1449
|
+
activeSelfContextId: null,
|
|
1450
|
+
lastAcceptedIntent: null,
|
|
1451
|
+
resetRequested: false,
|
|
1452
|
+
lastActivity: Date.now(),
|
|
1453
|
+
typingKeepaliveTimer: null,
|
|
1454
|
+
closed: false,
|
|
1455
|
+
turnLiveText: '',
|
|
1456
|
+
turnBlocks: [],
|
|
1457
|
+
turnCommandBlocks: createCommandBlockTracker(),
|
|
1458
|
+
};
|
|
1459
|
+
await controlPoller.baseline([conversationId]);
|
|
1460
|
+
await runtimeState.patchAgentSessionSnapshot(conversationId, {
|
|
1461
|
+
configurationStatus: 'ready',
|
|
1462
|
+
lastError: null,
|
|
1319
1463
|
});
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
currentTurnOpenedAt: null,
|
|
1336
|
-
currentTurnUpdatedAt: null,
|
|
1337
|
-
currentTurnCanUseCodexAppTools: false,
|
|
1338
|
-
currentReplyAuthority: null,
|
|
1339
|
-
currentTurnAbortController: null,
|
|
1340
|
-
// Corrected by the first turn that runs; a session with no turn
|
|
1341
|
-
// publishes nothing anyway, and quiet is never an accident.
|
|
1342
|
-
turnVerbosity: 'verbose',
|
|
1343
|
-
currentTurnSilenced: false,
|
|
1344
|
-
activeSelfContextId: null,
|
|
1345
|
-
lastAcceptedIntent: null,
|
|
1346
|
-
resetRequested: false,
|
|
1347
|
-
lastActivity: Date.now(),
|
|
1348
|
-
typingKeepaliveTimer: null,
|
|
1349
|
-
closed: false,
|
|
1350
|
-
turnLiveText: '',
|
|
1351
|
-
turnBlocks: [],
|
|
1352
|
-
turnCommandBlocks: createCommandBlockTracker(),
|
|
1353
|
-
};
|
|
1354
|
-
sessions.set(conversationId, session);
|
|
1355
|
-
await controlPoller.baseline([conversationId]);
|
|
1356
|
-
await runtimeState.patchAgentSessionSnapshot(conversationId, {
|
|
1357
|
-
configurationStatus: 'ready',
|
|
1358
|
-
lastError: null,
|
|
1359
|
-
});
|
|
1360
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
1361
|
-
writeState(session);
|
|
1362
|
-
writeTurn(session);
|
|
1363
|
-
return session;
|
|
1364
|
-
}
|
|
1365
|
-
catch (error) {
|
|
1366
|
-
releaseConversationEnvironment(environment);
|
|
1367
|
-
throw error;
|
|
1368
|
-
}
|
|
1464
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
1465
|
+
writeState(session);
|
|
1466
|
+
writeTurn(session);
|
|
1467
|
+
return session;
|
|
1468
|
+
},
|
|
1469
|
+
cleanup: () => {
|
|
1470
|
+
try {
|
|
1471
|
+
if (createdAdapter instanceof CodexAppServerAdapter)
|
|
1472
|
+
createdAdapter.close();
|
|
1473
|
+
}
|
|
1474
|
+
finally {
|
|
1475
|
+
releaseConversationEnvironment(environment);
|
|
1476
|
+
}
|
|
1477
|
+
},
|
|
1478
|
+
});
|
|
1369
1479
|
})();
|
|
1370
1480
|
pendingSessionCreations.set(conversationId, creation);
|
|
1371
1481
|
try {
|
|
@@ -1787,6 +1897,7 @@ export async function main() {
|
|
|
1787
1897
|
if (input.turnDispatch && input.turnDispatch.kind !== 'run_turn') {
|
|
1788
1898
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed server-dispatched turn: ${input.turnDispatch.reason}`);
|
|
1789
1899
|
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1900
|
+
await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
|
|
1790
1901
|
return;
|
|
1791
1902
|
}
|
|
1792
1903
|
if (input.message.metadata?.type === 'plan_approval_reply') {
|
|
@@ -1795,6 +1906,7 @@ export async function main() {
|
|
|
1795
1906
|
// replies left by an older coding descriptor instead of turning them
|
|
1796
1907
|
// into hidden plan/implementation prompts after an upgrade or restart.
|
|
1797
1908
|
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1909
|
+
await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
|
|
1798
1910
|
return;
|
|
1799
1911
|
}
|
|
1800
1912
|
const planId = readString(input.message.metadata, 'planId');
|
|
@@ -1803,6 +1915,7 @@ export async function main() {
|
|
|
1803
1915
|
metadata: input.message.metadata,
|
|
1804
1916
|
})) {
|
|
1805
1917
|
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1918
|
+
await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
|
|
1806
1919
|
return;
|
|
1807
1920
|
}
|
|
1808
1921
|
const session = await getOrCreateSession(input.conversationId);
|
|
@@ -1877,6 +1990,7 @@ export async function main() {
|
|
|
1877
1990
|
if (!autoReply.allow) {
|
|
1878
1991
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
1879
1992
|
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
1993
|
+
await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
|
|
1880
1994
|
return;
|
|
1881
1995
|
}
|
|
1882
1996
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -1890,6 +2004,11 @@ export async function main() {
|
|
|
1890
2004
|
catch (error) {
|
|
1891
2005
|
const message = error instanceof Error ? error.message : String(error);
|
|
1892
2006
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2007
|
+
// No native input has been claimed or submitted. The endpoint can offer
|
|
2008
|
+
// this input again after a service/storage failure; do not settle it or
|
|
2009
|
+
// misreport a temporary API failure as missing local configuration.
|
|
2010
|
+
if (classifyCodexSessionStart(error) === 'deferred')
|
|
2011
|
+
throw error;
|
|
1893
2012
|
await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
|
|
1894
2013
|
configurationStatus: 'configuration_required',
|
|
1895
2014
|
lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
|
|
@@ -1907,6 +2026,7 @@ export async function main() {
|
|
|
1907
2026
|
...(input.replyAuthority ? { replyAuthority: input.replyAuthority } : {}),
|
|
1908
2027
|
}).catch(() => { });
|
|
1909
2028
|
recoveryCheckpointsFor(input.conversationId).settle(input.message.id);
|
|
2029
|
+
await endpoint.setInboundState(`message:${input.conversationId}:${input.message.id}`, 'settled', 'not-dispatched');
|
|
1910
2030
|
return;
|
|
1911
2031
|
}
|
|
1912
2032
|
session.activeSelfContextId = activeSelfContextId;
|
|
@@ -1959,10 +2079,36 @@ export async function main() {
|
|
|
1959
2079
|
async function runNextTurn(session) {
|
|
1960
2080
|
if (session.running || session.closed)
|
|
1961
2081
|
return;
|
|
2082
|
+
// Reserve the room before awaiting recovery so a new turn cannot race an
|
|
2083
|
+
// older completion's streaming teardown. The queue remains unclaimed on
|
|
2084
|
+
// failure and the ordinary host heartbeat retries it.
|
|
2085
|
+
session.running = true;
|
|
2086
|
+
try {
|
|
2087
|
+
await recoveringOutputRooms.get(session.conversationId);
|
|
2088
|
+
for (const record of completedOutputs.list()) {
|
|
2089
|
+
if (record.conversationId === session.conversationId && record.output.kind === 'none' && record.output.reason === 'silent') {
|
|
2090
|
+
await deliverCompletedOutput(record);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
catch (error) {
|
|
2095
|
+
session.running = false;
|
|
2096
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Prior completion still needs cleanup:`, error);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (session.closed || sessions.get(session.conversationId) !== session) {
|
|
2100
|
+
session.running = false;
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
1962
2103
|
const nextTurn = session.queue.shift();
|
|
1963
|
-
if (!nextTurn)
|
|
2104
|
+
if (!nextTurn) {
|
|
2105
|
+
session.running = false;
|
|
1964
2106
|
return;
|
|
1965
|
-
|
|
2107
|
+
}
|
|
2108
|
+
const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
|
|
2109
|
+
let journaledInput = false;
|
|
2110
|
+
let completedOutput;
|
|
2111
|
+
let nativeCompleted = false;
|
|
1966
2112
|
session.state.lastError = undefined;
|
|
1967
2113
|
session.state.state = 'running';
|
|
1968
2114
|
session.currentTurnId = randomUUID();
|
|
@@ -2180,6 +2326,12 @@ export async function main() {
|
|
|
2180
2326
|
skillInvocationText: nextTurn.skillInvocationText,
|
|
2181
2327
|
onServerRequest: (request) => handleCodexServerRequest(session, request, nextTurn.requestingUserId ?? null, nextTurn.sourceMessageId ?? null),
|
|
2182
2328
|
});
|
|
2329
|
+
// Canon-origin queues must retain a durable execution owner, including
|
|
2330
|
+
// legacy queues restored before endpoint migration. Native continuations
|
|
2331
|
+
// without a Canon source message remain a separate operator path.
|
|
2332
|
+
if (inboundId && !await endpoint.claimInbound(inboundId))
|
|
2333
|
+
return;
|
|
2334
|
+
journaledInput = !!inboundId;
|
|
2183
2335
|
let result = await runTurnOnce();
|
|
2184
2336
|
if (!result.interrupted
|
|
2185
2337
|
&& !result.finalMessage
|
|
@@ -2195,6 +2347,49 @@ export async function main() {
|
|
|
2195
2347
|
session.currentTurnSilenced = false;
|
|
2196
2348
|
result = await runTurnOnce();
|
|
2197
2349
|
}
|
|
2350
|
+
nativeCompleted = true;
|
|
2351
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
2352
|
+
const deliveryMetadata = {
|
|
2353
|
+
turnId: session.currentTurnId,
|
|
2354
|
+
turnSemantics: 'turn_complete',
|
|
2355
|
+
...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
|
|
2356
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
2357
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
2358
|
+
};
|
|
2359
|
+
const canDeliver = !result.interrupted && !!result.finalMessage && resolveSilentTurnDelivery({
|
|
2360
|
+
silenced: session.currentTurnSilenced, finalText: result.finalMessage,
|
|
2361
|
+
}) === 'deliver';
|
|
2362
|
+
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId: nextTurn.requestingUserId, ownerId });
|
|
2363
|
+
const output = canDeliver && nextTurn.planMode
|
|
2364
|
+
? { kind: 'plan', body: {
|
|
2365
|
+
conversationId: session.conversationId, interactionKind: 'plan', planId: session.currentTurnId,
|
|
2366
|
+
expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS, title: 'Codex Plan', body: result.finalMessage, turnId: session.currentTurnId,
|
|
2367
|
+
...(responseRouting.responseUserId ? { responseUserId: responseRouting.responseUserId } : {}),
|
|
2368
|
+
} }
|
|
2369
|
+
: canDeliver || (!result.interrupted && !!result.exitCode)
|
|
2370
|
+
? { kind: 'message', text: canDeliver ? result.finalMessage : formatCodexTurnFailure(result.errorText), options: {
|
|
2371
|
+
messageId: buildCodexMessageId(session, canDeliver ? 'final' : 'error'),
|
|
2372
|
+
...(session.currentReplyAuthority ? { replyAuthority: session.currentReplyAuthority } : {}),
|
|
2373
|
+
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
2374
|
+
metadata: deliveryMetadata,
|
|
2375
|
+
} }
|
|
2376
|
+
: { kind: 'none', reason: result.interrupted ? 'interrupted' : session.currentTurnSilenced ? 'silent' : 'empty' };
|
|
2377
|
+
const record = {
|
|
2378
|
+
version: 1, turnId: session.currentTurnId, conversationId: session.conversationId,
|
|
2379
|
+
sourceMessageId: journaledInput ? nextTurn.sourceMessageId : null,
|
|
2380
|
+
nativeThreadId: result.threadId, createdAt: new Date().toISOString(), output,
|
|
2381
|
+
audience: conversationCache.has(session.conversationId) ? {
|
|
2382
|
+
memberIds: [...conversationCache.get(session.conversationId).memberIds].sort(),
|
|
2383
|
+
...(conversationCache.get(session.conversationId).membershipRevision !== undefined
|
|
2384
|
+
? { membershipRevision: conversationCache.get(session.conversationId).membershipRevision } : {}),
|
|
2385
|
+
} : null,
|
|
2386
|
+
};
|
|
2387
|
+
// This is the first local evidence that native work completed. A crash
|
|
2388
|
+
// before this fsynced record remains uncertain and needs its transcript;
|
|
2389
|
+
// after it, recovery only republishes the exact saved Canon output.
|
|
2390
|
+
completedOutputs.save(record);
|
|
2391
|
+
completedOutput = record;
|
|
2392
|
+
activeCompletedOutputs.add(record.turnId);
|
|
2198
2393
|
// Both the artifact gate and the final delivery weigh silence against
|
|
2199
2394
|
// this text, and they must weigh the same one — set it before any
|
|
2200
2395
|
// completion branch runs, including the ones that route first.
|
|
@@ -2213,23 +2408,10 @@ export async function main() {
|
|
|
2213
2408
|
if (result.threadId && !session.resetRequested) {
|
|
2214
2409
|
saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
2215
2410
|
}
|
|
2216
|
-
if (
|
|
2217
|
-
&& result.finalMessage
|
|
2218
|
-
&& nextTurn.planMode
|
|
2219
|
-
// A plan card carries the model's final text into the conversation as a
|
|
2220
|
-
// visible, actionable artifact — it IS posting. Silence is strict here
|
|
2221
|
-
// too: a silenced plan turn falls through to the silent teardown below
|
|
2222
|
-
// and raises no card.
|
|
2223
|
-
&& resolveSilentTurnDelivery({
|
|
2224
|
-
silenced: session.currentTurnSilenced,
|
|
2225
|
-
finalText: result.finalMessage,
|
|
2226
|
-
}) === 'deliver') {
|
|
2411
|
+
if (completedOutput.output.kind === 'plan') {
|
|
2227
2412
|
await routeArtifactsOnce();
|
|
2228
|
-
const
|
|
2229
|
-
|
|
2230
|
-
ownerId,
|
|
2231
|
-
});
|
|
2232
|
-
const planId = session.currentTurnId ?? randomUUID();
|
|
2413
|
+
const plan = completedOutput.output.body;
|
|
2414
|
+
const planId = plan.planId;
|
|
2233
2415
|
let planCreated = false;
|
|
2234
2416
|
let resolvePlanCreated;
|
|
2235
2417
|
let rejectPlanCreated;
|
|
@@ -2238,17 +2420,14 @@ export async function main() {
|
|
|
2238
2420
|
rejectPlanCreated = reject;
|
|
2239
2421
|
});
|
|
2240
2422
|
const planReview = runtimeRequests.request('plan', session.conversationId, {
|
|
2241
|
-
title:
|
|
2242
|
-
|
|
2243
|
-
...(responseRouting.responseUserId
|
|
2244
|
-
? { responseUserId: responseRouting.responseUserId }
|
|
2245
|
-
: {}),
|
|
2246
|
-
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
2423
|
+
title: plan.title, body: plan.body, turnId: plan.turnId,
|
|
2424
|
+
...(plan.responseUserId ? { responseUserId: plan.responseUserId } : {}),
|
|
2247
2425
|
}, {
|
|
2248
2426
|
requestId: planId,
|
|
2249
|
-
expiresAt:
|
|
2427
|
+
expiresAt: plan.expiresAt,
|
|
2250
2428
|
responderPolicy: 'infer',
|
|
2251
|
-
onCreated: () => {
|
|
2429
|
+
onCreated: async () => {
|
|
2430
|
+
await deliverCompletedOutput(completedOutput, true);
|
|
2252
2431
|
planCreated = true;
|
|
2253
2432
|
session.turnState = 'waiting_input';
|
|
2254
2433
|
writeTurn(session);
|
|
@@ -2257,7 +2436,7 @@ export async function main() {
|
|
|
2257
2436
|
});
|
|
2258
2437
|
void planReview.then((planResult) => {
|
|
2259
2438
|
resolvePlanCreated();
|
|
2260
|
-
enqueueCodexPlanReviewResult(session, planResult,
|
|
2439
|
+
enqueueCodexPlanReviewResult(session, planResult, plan.responseUserId ?? null);
|
|
2261
2440
|
}).catch((error) => {
|
|
2262
2441
|
if (!planCreated) {
|
|
2263
2442
|
rejectPlanCreated(error);
|
|
@@ -2269,40 +2448,16 @@ export async function main() {
|
|
|
2269
2448
|
await handoffFinalMessage(session.conversationId);
|
|
2270
2449
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
|
|
2271
2450
|
}
|
|
2272
|
-
else if (
|
|
2273
|
-
&& result.finalMessage
|
|
2274
|
-
// A turn that called `no_reply` posts nothing. The failure branches
|
|
2275
|
-
// below are deliberately outside this gate: silence suppresses the
|
|
2276
|
-
// MODEL's reply, never Canon's own "this turn broke" diagnostic.
|
|
2277
|
-
&& resolveSilentTurnDelivery({
|
|
2278
|
-
silenced: session.currentTurnSilenced,
|
|
2279
|
-
finalText: result.finalMessage,
|
|
2280
|
-
}) === 'deliver') {
|
|
2451
|
+
else if (completedOutput.output.kind === 'message' && canDeliver) {
|
|
2281
2452
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
2282
2453
|
clearStoredThread();
|
|
2283
2454
|
}
|
|
2284
2455
|
await routeArtifactsOnce();
|
|
2285
|
-
|
|
2286
|
-
await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
|
|
2287
|
-
messageId: buildCodexMessageId(session, 'final'),
|
|
2288
|
-
...(session.currentReplyAuthority
|
|
2289
|
-
? { replyAuthority: session.currentReplyAuthority }
|
|
2290
|
-
: {}),
|
|
2291
|
-
...(session.activeSelfContextId
|
|
2292
|
-
? { selfContextId: session.activeSelfContextId }
|
|
2293
|
-
: {}),
|
|
2294
|
-
metadata: {
|
|
2295
|
-
turnId: session.currentTurnId,
|
|
2296
|
-
turnSemantics: 'turn_complete',
|
|
2297
|
-
...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
|
|
2298
|
-
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
2299
|
-
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
2300
|
-
},
|
|
2301
|
-
});
|
|
2456
|
+
await deliverCompletedOutput(completedOutput);
|
|
2302
2457
|
await handoffFinalMessage(session.conversationId);
|
|
2303
|
-
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${
|
|
2458
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${completedOutput.output.text.length} chars)`);
|
|
2304
2459
|
}
|
|
2305
|
-
else if (
|
|
2460
|
+
else if (completedOutput.output.kind === 'message') {
|
|
2306
2461
|
await routeArtifactsOnce();
|
|
2307
2462
|
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
2308
2463
|
session.state.lastError = userVisibleError;
|
|
@@ -2310,23 +2465,7 @@ export async function main() {
|
|
|
2310
2465
|
if (result.errorText) {
|
|
2311
2466
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
2312
2467
|
}
|
|
2313
|
-
|
|
2314
|
-
await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
|
|
2315
|
-
messageId: buildCodexMessageId(session, 'error'),
|
|
2316
|
-
...(session.currentReplyAuthority
|
|
2317
|
-
? { replyAuthority: session.currentReplyAuthority }
|
|
2318
|
-
: {}),
|
|
2319
|
-
...(session.activeSelfContextId
|
|
2320
|
-
? { selfContextId: session.activeSelfContextId }
|
|
2321
|
-
: {}),
|
|
2322
|
-
metadata: {
|
|
2323
|
-
turnId: session.currentTurnId,
|
|
2324
|
-
turnSemantics: 'turn_complete',
|
|
2325
|
-
...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
|
|
2326
|
-
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
2327
|
-
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
2328
|
-
},
|
|
2329
|
-
});
|
|
2468
|
+
await deliverCompletedOutput(completedOutput);
|
|
2330
2469
|
await handoffFinalMessage(session.conversationId);
|
|
2331
2470
|
}
|
|
2332
2471
|
else if (!result.interrupted) {
|
|
@@ -2344,7 +2483,7 @@ export async function main() {
|
|
|
2344
2483
|
// typing dots go together — leaving dots behind the removed row for
|
|
2345
2484
|
// the handoff window reads as "started to answer, then gave up".
|
|
2346
2485
|
await blankCodexStreaming(session);
|
|
2347
|
-
clearStreaming(session.conversationId);
|
|
2486
|
+
await runtimeState.clearStreaming(session.conversationId);
|
|
2348
2487
|
stopVisibleWorkSignal(session);
|
|
2349
2488
|
}
|
|
2350
2489
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -2357,8 +2496,23 @@ export async function main() {
|
|
|
2357
2496
|
typingSignals.clear(session.conversationId).catch(() => { });
|
|
2358
2497
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
2359
2498
|
}
|
|
2499
|
+
if (completedOutput.output.kind === 'none')
|
|
2500
|
+
await deliverCompletedOutput(completedOutput);
|
|
2360
2501
|
}
|
|
2361
2502
|
catch (error) {
|
|
2503
|
+
if (nativeCompleted) {
|
|
2504
|
+
session.state.lastError = completedOutput
|
|
2505
|
+
? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
|
|
2506
|
+
: 'Native work completed, but its output could not be journaled. Operator reconciliation is required.';
|
|
2507
|
+
writeState(session);
|
|
2508
|
+
console.error(`[canon-codex] Native completion ${session.currentTurnId} needs reconciliation:`, error);
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
if (isPendingCanonOperation(error)) {
|
|
2512
|
+
session.state.lastError = 'Canon delivery remains pending reconciliation.';
|
|
2513
|
+
writeState(session);
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2362
2516
|
const message = error instanceof ExecutionEnvironmentError
|
|
2363
2517
|
? error.userMessage
|
|
2364
2518
|
: error instanceof CanonApiError
|
|
@@ -2392,6 +2546,8 @@ export async function main() {
|
|
|
2392
2546
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
2393
2547
|
}
|
|
2394
2548
|
finally {
|
|
2549
|
+
if (completedOutput)
|
|
2550
|
+
activeCompletedOutputs.delete(completedOutput.turnId);
|
|
2395
2551
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2396
2552
|
session.currentTurnAbortController = null;
|
|
2397
2553
|
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
@@ -2653,75 +2809,10 @@ export async function main() {
|
|
|
2653
2809
|
console.error(`[canon-codex] Runtime ${operation} failed:`, error);
|
|
2654
2810
|
},
|
|
2655
2811
|
});
|
|
2656
|
-
let startupRecoveryComplete = false;
|
|
2657
2812
|
async function recoverInboundMessageGaps() {
|
|
2658
|
-
//
|
|
2659
|
-
//
|
|
2660
|
-
// pass, not an automatic retry loop.
|
|
2661
|
-
const knownBeforeRefresh = new Set(knownConversationIds);
|
|
2813
|
+
// Refresh discovery only. Timeline reconstruction is never authorization to
|
|
2814
|
+
// execute historical turns; admitted pending work lives in the endpoint inbox.
|
|
2662
2815
|
await refreshKnownConversationIds(true);
|
|
2663
|
-
const conversationsDiscoveredWhileOffline = startupRecoveryComplete
|
|
2664
|
-
? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
|
|
2665
|
-
: new Set();
|
|
2666
|
-
for (const conversationId of knownConversationIds) {
|
|
2667
|
-
const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
|
|
2668
|
-
const recoveryBatch = recoveryCheckpoints.reserveBatch();
|
|
2669
|
-
try {
|
|
2670
|
-
const cursor = loadRuntimeSessionState(runtimeId, {
|
|
2671
|
-
conversationId,
|
|
2672
|
-
baseCwd: workingDir,
|
|
2673
|
-
})?.lastInboundMessageId ?? null;
|
|
2674
|
-
const recovered = await collectMissedInboundMessages({
|
|
2675
|
-
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2676
|
-
cursor,
|
|
2677
|
-
agentId,
|
|
2678
|
-
requireContiguousCursor: true,
|
|
2679
|
-
noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
|
|
2680
|
-
? 'bounded-window'
|
|
2681
|
-
: 'latest-only',
|
|
2682
|
-
});
|
|
2683
|
-
recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
|
|
2684
|
-
if (recovered.mode === 'incomplete-gap') {
|
|
2685
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] recovery_incomplete_gap: cursor is missing; replaying ${recovered.messages.length} bounded inbound message(s) before advancing to ${recovered.recoveryCursor ?? 'no cursor'}`);
|
|
2686
|
-
}
|
|
2687
|
-
for (const message of recovered.messages) {
|
|
2688
|
-
const isPlanReply = isCodexPlanApprovalReply(message.metadata, serviceAgentMode);
|
|
2689
|
-
if (!isPlanReply && !shouldTriggerAgentTurn({
|
|
2690
|
-
senderType: message.senderType,
|
|
2691
|
-
metadata: message.metadata,
|
|
2692
|
-
}).allow) {
|
|
2693
|
-
recoveryCheckpoints.settle(message.id);
|
|
2694
|
-
continue;
|
|
2695
|
-
}
|
|
2696
|
-
if (!claimInboundMessageId(message.id))
|
|
2697
|
-
continue;
|
|
2698
|
-
try {
|
|
2699
|
-
await enqueueInboundMessage({
|
|
2700
|
-
conversationId,
|
|
2701
|
-
message,
|
|
2702
|
-
senderName: message.senderName || message.senderId,
|
|
2703
|
-
isOwner: message.senderId === ownerId,
|
|
2704
|
-
behavior: recovered.newestPage.behavior,
|
|
2705
|
-
activeSelfContextId: recovered.newestPage.activeSelfContextIdByMessageId?.[message.id] ?? null,
|
|
2706
|
-
selfContexts: recovered.newestPage.selfContexts,
|
|
2707
|
-
hydratedPage: recovered.newestPage,
|
|
2708
|
-
});
|
|
2709
|
-
settleInboundMessageId(message.id, true);
|
|
2710
|
-
}
|
|
2711
|
-
catch (error) {
|
|
2712
|
-
settleInboundMessageId(message.id, false);
|
|
2713
|
-
throw error;
|
|
2714
|
-
}
|
|
2715
|
-
}
|
|
2716
|
-
if (recovered.messages.length > 0) {
|
|
2717
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
catch (error) {
|
|
2721
|
-
recoveryBatch.cancel();
|
|
2722
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Recovery failed:`, error instanceof Error ? error.message : error);
|
|
2723
|
-
}
|
|
2724
|
-
}
|
|
2725
2816
|
}
|
|
2726
2817
|
const reconnectRecovery = createReconnectRecoveryCoordinator(recoverInboundMessageGaps);
|
|
2727
2818
|
const observeReconnectRecovery = (reason, recovery) => {
|
|
@@ -2731,39 +2822,50 @@ export async function main() {
|
|
|
2731
2822
|
console.error(`[canon-codex] ${reason} recovery failed:`, error instanceof Error ? error.message : error);
|
|
2732
2823
|
});
|
|
2733
2824
|
};
|
|
2825
|
+
const inbound = endpoint.acceptInbound({ kind: 'message.created',
|
|
2826
|
+
offer: async (event) => {
|
|
2827
|
+
const payload = event.data;
|
|
2828
|
+
const message = payload.message;
|
|
2829
|
+
if (message.senderId === agentId) {
|
|
2830
|
+
await endpoint.setInboundState(event.id, 'settled', 'own-message');
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
if (!claimInboundMessageId(message.id))
|
|
2834
|
+
return;
|
|
2835
|
+
recoveryCheckpointsFor(payload.conversationId).track(message.id);
|
|
2836
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2837
|
+
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2838
|
+
recoveryCheckpointsFor(payload.conversationId).settle(message.id);
|
|
2839
|
+
settleInboundMessageId(message.id, true);
|
|
2840
|
+
await endpoint.setInboundState(`message:${payload.conversationId}:${message.id}`, 'settled', 'observe-only');
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
await enqueueInboundMessage({
|
|
2844
|
+
conversationId: payload.conversationId,
|
|
2845
|
+
message,
|
|
2846
|
+
senderName: message.senderName || message.senderId,
|
|
2847
|
+
isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
|
|
2848
|
+
behavior: payload.behavior,
|
|
2849
|
+
activeSelfContextId: payload.activeSelfContextId,
|
|
2850
|
+
selfContexts: payload.selfContexts,
|
|
2851
|
+
provenance: payload.provenance,
|
|
2852
|
+
turnDispatch: payload.turnDispatch,
|
|
2853
|
+
replyAuthority: payload.replyAuthority,
|
|
2854
|
+
}).then(() => settleInboundMessageId(message.id, true), (error) => {
|
|
2855
|
+
settleInboundMessageId(message.id, false);
|
|
2856
|
+
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
|
|
2857
|
+
throw error;
|
|
2858
|
+
});
|
|
2859
|
+
},
|
|
2860
|
+
onError: (error) => console.error('[canon-codex] Endpoint input deferred:', error), });
|
|
2734
2861
|
const stream = new CanonStream({
|
|
2735
|
-
|
|
2862
|
+
endpoint,
|
|
2736
2863
|
agentId,
|
|
2737
|
-
streamUrl,
|
|
2738
2864
|
handler: {
|
|
2739
2865
|
onMessage: (payload) => {
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
if (!claimInboundMessageId(message.id))
|
|
2744
|
-
return;
|
|
2745
|
-
recoveryCheckpointsFor(payload.conversationId).track(message.id);
|
|
2746
|
-
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2747
|
-
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2748
|
-
recoveryCheckpointsFor(payload.conversationId).settle(message.id);
|
|
2749
|
-
settleInboundMessageId(message.id, true);
|
|
2750
|
-
return;
|
|
2751
|
-
}
|
|
2752
|
-
void enqueueInboundMessage({
|
|
2753
|
-
conversationId: payload.conversationId,
|
|
2754
|
-
message,
|
|
2755
|
-
senderName: message.senderName || message.senderId,
|
|
2756
|
-
isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
|
|
2757
|
-
behavior: payload.behavior,
|
|
2758
|
-
activeSelfContextId: payload.activeSelfContextId,
|
|
2759
|
-
selfContexts: payload.selfContexts,
|
|
2760
|
-
provenance: payload.provenance,
|
|
2761
|
-
turnDispatch: payload.turnDispatch,
|
|
2762
|
-
replyAuthority: payload.replyAuthority,
|
|
2763
|
-
}).then(() => settleInboundMessageId(message.id, true), (error) => {
|
|
2764
|
-
settleInboundMessageId(message.id, false);
|
|
2765
|
-
console.error(`[canon-codex] [${payload.conversationId.slice(0, 8)}] Failed to queue inbound message:`, error instanceof Error ? error.message : error);
|
|
2766
|
-
});
|
|
2866
|
+
void inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`,
|
|
2867
|
+
kind: 'message.created', conversationId: payload.conversationId, durable: true,
|
|
2868
|
+
data: payload });
|
|
2767
2869
|
},
|
|
2768
2870
|
onMessageDeleted: (payload) => {
|
|
2769
2871
|
removeQueuedPrompt(payload.conversationId, payload.messageId);
|
|
@@ -2798,7 +2900,8 @@ export async function main() {
|
|
|
2798
2900
|
for (const conversation of conversations) {
|
|
2799
2901
|
knownConversationIds.add(conversation.id);
|
|
2800
2902
|
conversationCache.set(conversation.id, conversation);
|
|
2801
|
-
|
|
2903
|
+
await clearCompletedSilentStreaming(conversation.id);
|
|
2904
|
+
await runtimeState.clearStreaming(conversation.id);
|
|
2802
2905
|
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
2803
2906
|
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
2804
2907
|
}
|
|
@@ -2809,16 +2912,20 @@ export async function main() {
|
|
|
2809
2912
|
await reconnectRecovery.recoverNow().catch((error) => {
|
|
2810
2913
|
console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
|
|
2811
2914
|
});
|
|
2812
|
-
|
|
2915
|
+
void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
|
|
2916
|
+
inbound.start();
|
|
2813
2917
|
startCodexStreamInBackground(stream, (error) => {
|
|
2814
2918
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
2815
2919
|
});
|
|
2816
2920
|
controlPoller.start();
|
|
2817
2921
|
const heartbeat = setInterval(() => {
|
|
2922
|
+
void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
|
|
2818
2923
|
for (const session of sessions.values()) {
|
|
2819
2924
|
writeState(session);
|
|
2820
2925
|
if (!session.running) {
|
|
2821
2926
|
writeTurn(session);
|
|
2927
|
+
if (session.queue.length)
|
|
2928
|
+
void runNextTurn(session);
|
|
2822
2929
|
}
|
|
2823
2930
|
}
|
|
2824
2931
|
publishLocalHeartbeat();
|
|
@@ -2845,6 +2952,7 @@ export async function main() {
|
|
|
2845
2952
|
}
|
|
2846
2953
|
runtimeRequests.dispose();
|
|
2847
2954
|
stream.stop();
|
|
2955
|
+
await inbound.close();
|
|
2848
2956
|
await runtimeHeartbeat.dispose();
|
|
2849
2957
|
for (const session of [...sessions.values()]) {
|
|
2850
2958
|
await session.adapter.interrupt().catch(() => { });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,15 +31,15 @@
|
|
|
31
31
|
"prepack": "npm run build"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@canonmsg/agent-sdk": "^
|
|
35
|
-
"@canonmsg/agent-tools": "^0.
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^
|
|
38
|
-
"@canonmsg/rich-cards": "^0.10.
|
|
34
|
+
"@canonmsg/agent-sdk": "^11.0.0",
|
|
35
|
+
"@canonmsg/agent-tools": "^0.11.0",
|
|
36
|
+
"@canonmsg/coding-agent-host": "^0.9.0",
|
|
37
|
+
"@canonmsg/core": "^13.0.3",
|
|
38
|
+
"@canonmsg/rich-cards": "^0.10.6",
|
|
39
39
|
"ws": "^8.21.3"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
|
-
"node": ">=
|
|
42
|
+
"node": ">=22.22.3"
|
|
43
43
|
},
|
|
44
44
|
"keywords": [
|
|
45
45
|
"canon",
|