@canonmsg/codex-plugin 0.32.0 → 0.32.2
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 +355 -208
- package/package.json +3 -3
|
@@ -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
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, 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';
|
|
9
|
+
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, resolveQuestionAllowOther, buildCanonTurnContextV2, buildCanonGroupContext, buildCompactGroupContextLines, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, hydrateCanonReplyContext, 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';
|
|
@@ -816,6 +817,94 @@ export async function main() {
|
|
|
816
817
|
hostMode: true,
|
|
817
818
|
rtdb,
|
|
818
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
|
+
}
|
|
819
908
|
const sessions = new Map();
|
|
820
909
|
const pendingSessionCreations = new Map();
|
|
821
910
|
const recoveryCheckpointTrackers = new Map();
|
|
@@ -898,7 +987,7 @@ export async function main() {
|
|
|
898
987
|
? Promise.resolve(input.hydratedPage)
|
|
899
988
|
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
900
989
|
]);
|
|
901
|
-
|
|
990
|
+
const hydrated = buildHydratedInboundContext({
|
|
902
991
|
agentId,
|
|
903
992
|
conversationId: input.conversationId,
|
|
904
993
|
conversation,
|
|
@@ -915,6 +1004,15 @@ export async function main() {
|
|
|
915
1004
|
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
916
1005
|
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
917
1006
|
});
|
|
1007
|
+
return {
|
|
1008
|
+
...hydrated,
|
|
1009
|
+
replyContext: await hydrateCanonReplyContext({
|
|
1010
|
+
client,
|
|
1011
|
+
conversationId: input.conversationId,
|
|
1012
|
+
message: input.message,
|
|
1013
|
+
messages: page?.messages,
|
|
1014
|
+
}),
|
|
1015
|
+
};
|
|
918
1016
|
}
|
|
919
1017
|
function writeState(session) {
|
|
920
1018
|
runtimeState.writeSessionState(session.conversationId, {
|
|
@@ -991,6 +1089,14 @@ export async function main() {
|
|
|
991
1089
|
settleRejectedPromptCheckpoints(conversationId, removed);
|
|
992
1090
|
writeTurn(session);
|
|
993
1091
|
}
|
|
1092
|
+
async function clearCompletedSilentStreaming(conversationId) {
|
|
1093
|
+
await clearCodexStreamingWithCompletedSilence({
|
|
1094
|
+
records: completedOutputs.list().filter((record) => record.conversationId === conversationId),
|
|
1095
|
+
read: () => rtdb.read(`/streaming/${conversationId}/${agentId}`),
|
|
1096
|
+
blank: (turnId) => runtimeState.writeStreaming(conversationId, { text: '', status: 'thinking', messageId: turnId, turnId, blocks: [] }),
|
|
1097
|
+
clear: () => runtimeState.clearStreaming(conversationId),
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
994
1100
|
function clearStreaming(conversationId) {
|
|
995
1101
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
996
1102
|
}
|
|
@@ -1028,7 +1134,7 @@ export async function main() {
|
|
|
1028
1134
|
messageId: session.currentTurnId ?? undefined,
|
|
1029
1135
|
turnId: session.currentTurnId,
|
|
1030
1136
|
blocks: [],
|
|
1031
|
-
})
|
|
1137
|
+
});
|
|
1032
1138
|
}
|
|
1033
1139
|
function upsertCodexTextSegment(session, event) {
|
|
1034
1140
|
const next = applyTextSegmentBlock({
|
|
@@ -1091,7 +1197,7 @@ export async function main() {
|
|
|
1091
1197
|
}
|
|
1092
1198
|
async function handoffFinalMessage(conversationId) {
|
|
1093
1199
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
1094
|
-
clearStreaming(conversationId);
|
|
1200
|
+
await runtimeState.clearStreaming(conversationId);
|
|
1095
1201
|
typingSignals.clear(conversationId).catch(() => { });
|
|
1096
1202
|
}
|
|
1097
1203
|
function refreshVisibleWorkSignal(session) {
|
|
@@ -1239,136 +1345,146 @@ export async function main() {
|
|
|
1239
1345
|
workspaceCwd,
|
|
1240
1346
|
allowWorktrees: sessionExecutionMode === 'worktree',
|
|
1241
1347
|
});
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
|
|
1249
|
-
availableExecutionModes: hostAvailableExecutionModes,
|
|
1250
|
-
});
|
|
1251
|
-
if (persistedMapping.status === 'configuration_required') {
|
|
1252
|
-
throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1253
|
-
}
|
|
1254
|
-
if (persistedMapping.status === 'adopted'
|
|
1255
|
-
&& (persistedMapping.state.baseCwd !== environment.baseCwd
|
|
1256
|
-
|| persistedMapping.state.executionMode !== environment.mode)) {
|
|
1257
|
-
const restoredEnvironment = prepareConversationEnvironment({
|
|
1258
|
-
agentId,
|
|
1348
|
+
let createdAdapter;
|
|
1349
|
+
return initializeCodexSession({
|
|
1350
|
+
key: conversationId,
|
|
1351
|
+
sessions,
|
|
1352
|
+
create: async () => {
|
|
1353
|
+
const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
|
|
1259
1354
|
conversationId,
|
|
1260
|
-
|
|
1261
|
-
|
|
1355
|
+
baseCwd: environment.baseCwd,
|
|
1356
|
+
executionMode: environment.mode,
|
|
1357
|
+
resumeField: 'threadId',
|
|
1358
|
+
configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
|
|
1359
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
1262
1360
|
});
|
|
1263
|
-
if (
|
|
1264
|
-
|
|
1265
|
-
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);
|
|
1361
|
+
if (persistedMapping.status === 'configuration_required') {
|
|
1362
|
+
throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1266
1363
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
model
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1364
|
+
if (persistedMapping.status === 'adopted'
|
|
1365
|
+
&& (persistedMapping.state.baseCwd !== environment.baseCwd
|
|
1366
|
+
|| persistedMapping.state.executionMode !== environment.mode)) {
|
|
1367
|
+
const restoredEnvironment = prepareConversationEnvironment({
|
|
1368
|
+
agentId,
|
|
1369
|
+
conversationId,
|
|
1370
|
+
workspaceCwd: persistedMapping.state.baseCwd,
|
|
1371
|
+
allowWorktrees: persistedMapping.state.executionMode === 'worktree',
|
|
1372
|
+
});
|
|
1373
|
+
if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
|
|
1374
|
+
releaseConversationEnvironment(restoredEnvironment);
|
|
1375
|
+
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);
|
|
1376
|
+
}
|
|
1377
|
+
releaseConversationEnvironment(environment);
|
|
1378
|
+
environment = restoredEnvironment;
|
|
1379
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
|
|
1380
|
+
}
|
|
1381
|
+
const sessionCwd = environment.cwd;
|
|
1382
|
+
const policy = resolveCodexEffectiveRuntimePolicy({
|
|
1383
|
+
args,
|
|
1384
|
+
config: null,
|
|
1385
|
+
permissionEnvelope: codexPermissionEnvelope,
|
|
1386
|
+
environment,
|
|
1387
|
+
serviceAgentMode,
|
|
1388
|
+
});
|
|
1389
|
+
const modelGuard = buildCodexModelGuardMessage(policy.model, codexCliStatus);
|
|
1390
|
+
if (modelGuard) {
|
|
1391
|
+
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
1392
|
+
}
|
|
1393
|
+
const storedThreadId = loadStoredThreadId(runtimeId, conversationId, environment.baseCwd, environment.mode, policy.fingerprint, {
|
|
1394
|
+
...(persistedMapping.status === 'new' || !persistedMapping.state.workspaceId
|
|
1395
|
+
? {}
|
|
1396
|
+
: { workspaceId: persistedMapping.state.workspaceId }),
|
|
1397
|
+
allowLegacyPolicyMigration: persistedMapping.status !== 'new',
|
|
1398
|
+
});
|
|
1399
|
+
const effectiveModel = policy.model ?? codexDefaultModel;
|
|
1400
|
+
const initialEffortResolution = resolveCodexEffortForModel({
|
|
1401
|
+
models: codexModels,
|
|
1402
|
+
model: effectiveModel,
|
|
1403
|
+
requestedEffort: configuredCodexEffort,
|
|
1404
|
+
});
|
|
1405
|
+
const initialEffort = initialEffortResolution.value;
|
|
1406
|
+
const adapter = useAppServer
|
|
1407
|
+
? new CodexAppServerAdapter({
|
|
1408
|
+
cwd: sessionCwd,
|
|
1409
|
+
threadId: storedThreadId,
|
|
1410
|
+
codexBin,
|
|
1411
|
+
model: policy.model ?? null,
|
|
1412
|
+
reasoningEffort: initialEffort,
|
|
1413
|
+
sandbox: policy.sandbox,
|
|
1414
|
+
addDirs: args['add-dir'] ?? [],
|
|
1415
|
+
configOverrides: args.config ?? [],
|
|
1416
|
+
fullAuto: policy.fullAuto,
|
|
1417
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1418
|
+
dynamicTools: codexDynamicTools,
|
|
1419
|
+
})
|
|
1420
|
+
: new CodexConversationAdapter({
|
|
1421
|
+
cwd: sessionCwd,
|
|
1422
|
+
threadId: storedThreadId,
|
|
1423
|
+
codexBin,
|
|
1424
|
+
model: policy.model ?? null,
|
|
1425
|
+
reasoningEffort: initialEffort,
|
|
1426
|
+
sandbox: policy.sandbox,
|
|
1427
|
+
codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
|
|
1428
|
+
addDirs: args['add-dir'] ?? [],
|
|
1429
|
+
configOverrides: args.config ?? [],
|
|
1430
|
+
fullAuto: policy.fullAuto,
|
|
1431
|
+
bypassApprovalsAndSandbox: policy.bypassApprovalsAndSandbox,
|
|
1432
|
+
});
|
|
1433
|
+
createdAdapter = adapter;
|
|
1434
|
+
const session = {
|
|
1435
|
+
conversationId,
|
|
1311
1436
|
cwd: sessionCwd,
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1437
|
+
environment,
|
|
1438
|
+
adapter,
|
|
1439
|
+
queue: [],
|
|
1440
|
+
running: false,
|
|
1441
|
+
state: buildCodexInitialSessionState({
|
|
1442
|
+
model: effectiveModel ?? undefined,
|
|
1443
|
+
permissionMode: policy.permissionMode,
|
|
1444
|
+
effort: initialEffort,
|
|
1445
|
+
}),
|
|
1446
|
+
policyFingerprint: policy.fingerprint,
|
|
1447
|
+
turnState: 'idle',
|
|
1448
|
+
currentTurnId: null,
|
|
1449
|
+
currentTurnOpenedAt: null,
|
|
1450
|
+
currentTurnUpdatedAt: null,
|
|
1451
|
+
currentTurnCanUseCodexAppTools: false,
|
|
1452
|
+
currentReplyAuthority: null,
|
|
1453
|
+
currentTurnAbortController: null,
|
|
1454
|
+
// Corrected by the first turn that runs; a session with no turn
|
|
1455
|
+
// publishes nothing anyway, and quiet is never an accident.
|
|
1456
|
+
turnVerbosity: 'verbose',
|
|
1457
|
+
currentTurnSilenced: false,
|
|
1458
|
+
activeSelfContextId: null,
|
|
1459
|
+
lastAcceptedIntent: null,
|
|
1460
|
+
resetRequested: false,
|
|
1461
|
+
lastActivity: Date.now(),
|
|
1462
|
+
typingKeepaliveTimer: null,
|
|
1463
|
+
closed: false,
|
|
1464
|
+
turnLiveText: '',
|
|
1465
|
+
turnBlocks: [],
|
|
1466
|
+
turnCommandBlocks: createCommandBlockTracker(),
|
|
1467
|
+
};
|
|
1468
|
+
await controlPoller.baseline([conversationId]);
|
|
1469
|
+
await runtimeState.patchAgentSessionSnapshot(conversationId, {
|
|
1470
|
+
configurationStatus: 'ready',
|
|
1471
|
+
lastError: null,
|
|
1322
1472
|
});
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
currentTurnOpenedAt: null,
|
|
1339
|
-
currentTurnUpdatedAt: null,
|
|
1340
|
-
currentTurnCanUseCodexAppTools: false,
|
|
1341
|
-
currentReplyAuthority: null,
|
|
1342
|
-
currentTurnAbortController: null,
|
|
1343
|
-
// Corrected by the first turn that runs; a session with no turn
|
|
1344
|
-
// publishes nothing anyway, and quiet is never an accident.
|
|
1345
|
-
turnVerbosity: 'verbose',
|
|
1346
|
-
currentTurnSilenced: false,
|
|
1347
|
-
activeSelfContextId: null,
|
|
1348
|
-
lastAcceptedIntent: null,
|
|
1349
|
-
resetRequested: false,
|
|
1350
|
-
lastActivity: Date.now(),
|
|
1351
|
-
typingKeepaliveTimer: null,
|
|
1352
|
-
closed: false,
|
|
1353
|
-
turnLiveText: '',
|
|
1354
|
-
turnBlocks: [],
|
|
1355
|
-
turnCommandBlocks: createCommandBlockTracker(),
|
|
1356
|
-
};
|
|
1357
|
-
sessions.set(conversationId, session);
|
|
1358
|
-
await controlPoller.baseline([conversationId]);
|
|
1359
|
-
await runtimeState.patchAgentSessionSnapshot(conversationId, {
|
|
1360
|
-
configurationStatus: 'ready',
|
|
1361
|
-
lastError: null,
|
|
1362
|
-
});
|
|
1363
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
1364
|
-
writeState(session);
|
|
1365
|
-
writeTurn(session);
|
|
1366
|
-
return session;
|
|
1367
|
-
}
|
|
1368
|
-
catch (error) {
|
|
1369
|
-
releaseConversationEnvironment(environment);
|
|
1370
|
-
throw error;
|
|
1371
|
-
}
|
|
1473
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
1474
|
+
writeState(session);
|
|
1475
|
+
writeTurn(session);
|
|
1476
|
+
return session;
|
|
1477
|
+
},
|
|
1478
|
+
cleanup: () => {
|
|
1479
|
+
try {
|
|
1480
|
+
if (createdAdapter instanceof CodexAppServerAdapter)
|
|
1481
|
+
createdAdapter.close();
|
|
1482
|
+
}
|
|
1483
|
+
finally {
|
|
1484
|
+
releaseConversationEnvironment(environment);
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
});
|
|
1372
1488
|
})();
|
|
1373
1489
|
pendingSessionCreations.set(conversationId, creation);
|
|
1374
1490
|
try {
|
|
@@ -1897,6 +2013,11 @@ export async function main() {
|
|
|
1897
2013
|
catch (error) {
|
|
1898
2014
|
const message = error instanceof Error ? error.message : String(error);
|
|
1899
2015
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2016
|
+
// No native input has been claimed or submitted. The endpoint can offer
|
|
2017
|
+
// this input again after a service/storage failure; do not settle it or
|
|
2018
|
+
// misreport a temporary API failure as missing local configuration.
|
|
2019
|
+
if (classifyCodexSessionStart(error) === 'deferred')
|
|
2020
|
+
throw error;
|
|
1900
2021
|
await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
|
|
1901
2022
|
configurationStatus: 'configuration_required',
|
|
1902
2023
|
lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
|
|
@@ -1967,12 +2088,36 @@ export async function main() {
|
|
|
1967
2088
|
async function runNextTurn(session) {
|
|
1968
2089
|
if (session.running || session.closed)
|
|
1969
2090
|
return;
|
|
2091
|
+
// Reserve the room before awaiting recovery so a new turn cannot race an
|
|
2092
|
+
// older completion's streaming teardown. The queue remains unclaimed on
|
|
2093
|
+
// failure and the ordinary host heartbeat retries it.
|
|
2094
|
+
session.running = true;
|
|
2095
|
+
try {
|
|
2096
|
+
await recoveringOutputRooms.get(session.conversationId);
|
|
2097
|
+
for (const record of completedOutputs.list()) {
|
|
2098
|
+
if (record.conversationId === session.conversationId && record.output.kind === 'none' && record.output.reason === 'silent') {
|
|
2099
|
+
await deliverCompletedOutput(record);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
catch (error) {
|
|
2104
|
+
session.running = false;
|
|
2105
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Prior completion still needs cleanup:`, error);
|
|
2106
|
+
return;
|
|
2107
|
+
}
|
|
2108
|
+
if (session.closed || sessions.get(session.conversationId) !== session) {
|
|
2109
|
+
session.running = false;
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
1970
2112
|
const nextTurn = session.queue.shift();
|
|
1971
|
-
if (!nextTurn)
|
|
2113
|
+
if (!nextTurn) {
|
|
2114
|
+
session.running = false;
|
|
1972
2115
|
return;
|
|
1973
|
-
|
|
2116
|
+
}
|
|
1974
2117
|
const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
|
|
1975
2118
|
let journaledInput = false;
|
|
2119
|
+
let completedOutput;
|
|
2120
|
+
let nativeCompleted = false;
|
|
1976
2121
|
session.state.lastError = undefined;
|
|
1977
2122
|
session.state.state = 'running';
|
|
1978
2123
|
session.currentTurnId = randomUUID();
|
|
@@ -2211,8 +2356,49 @@ export async function main() {
|
|
|
2211
2356
|
session.currentTurnSilenced = false;
|
|
2212
2357
|
result = await runTurnOnce();
|
|
2213
2358
|
}
|
|
2214
|
-
|
|
2215
|
-
|
|
2359
|
+
nativeCompleted = true;
|
|
2360
|
+
const turnTrail = buildFinalTurnTrail(session);
|
|
2361
|
+
const deliveryMetadata = {
|
|
2362
|
+
turnId: session.currentTurnId,
|
|
2363
|
+
turnSemantics: 'turn_complete',
|
|
2364
|
+
...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
|
|
2365
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
2366
|
+
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
2367
|
+
};
|
|
2368
|
+
const canDeliver = !result.interrupted && !!result.finalMessage && resolveSilentTurnDelivery({
|
|
2369
|
+
silenced: session.currentTurnSilenced, finalText: result.finalMessage,
|
|
2370
|
+
}) === 'deliver';
|
|
2371
|
+
const responseRouting = buildCodexTurnResponseRouting({ requestingUserId: nextTurn.requestingUserId, ownerId });
|
|
2372
|
+
const output = canDeliver && nextTurn.planMode
|
|
2373
|
+
? { kind: 'plan', body: {
|
|
2374
|
+
conversationId: session.conversationId, interactionKind: 'plan', planId: session.currentTurnId,
|
|
2375
|
+
expiresAt: Date.now() + PLAN_REVIEW_TIMEOUT_MS, title: 'Codex Plan', body: result.finalMessage, turnId: session.currentTurnId,
|
|
2376
|
+
...(responseRouting.responseUserId ? { responseUserId: responseRouting.responseUserId } : {}),
|
|
2377
|
+
} }
|
|
2378
|
+
: canDeliver || (!result.interrupted && !!result.exitCode)
|
|
2379
|
+
? { kind: 'message', text: canDeliver ? result.finalMessage : formatCodexTurnFailure(result.errorText), options: {
|
|
2380
|
+
messageId: buildCodexMessageId(session, canDeliver ? 'final' : 'error'),
|
|
2381
|
+
...(session.currentReplyAuthority ? { replyAuthority: session.currentReplyAuthority } : {}),
|
|
2382
|
+
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
2383
|
+
metadata: deliveryMetadata,
|
|
2384
|
+
} }
|
|
2385
|
+
: { kind: 'none', reason: result.interrupted ? 'interrupted' : session.currentTurnSilenced ? 'silent' : 'empty' };
|
|
2386
|
+
const record = {
|
|
2387
|
+
version: 1, turnId: session.currentTurnId, conversationId: session.conversationId,
|
|
2388
|
+
sourceMessageId: journaledInput ? nextTurn.sourceMessageId : null,
|
|
2389
|
+
nativeThreadId: result.threadId, createdAt: new Date().toISOString(), output,
|
|
2390
|
+
audience: conversationCache.has(session.conversationId) ? {
|
|
2391
|
+
memberIds: [...conversationCache.get(session.conversationId).memberIds].sort(),
|
|
2392
|
+
...(conversationCache.get(session.conversationId).membershipRevision !== undefined
|
|
2393
|
+
? { membershipRevision: conversationCache.get(session.conversationId).membershipRevision } : {}),
|
|
2394
|
+
} : null,
|
|
2395
|
+
};
|
|
2396
|
+
// This is the first local evidence that native work completed. A crash
|
|
2397
|
+
// before this fsynced record remains uncertain and needs its transcript;
|
|
2398
|
+
// after it, recovery only republishes the exact saved Canon output.
|
|
2399
|
+
completedOutputs.save(record);
|
|
2400
|
+
completedOutput = record;
|
|
2401
|
+
activeCompletedOutputs.add(record.turnId);
|
|
2216
2402
|
// Both the artifact gate and the final delivery weigh silence against
|
|
2217
2403
|
// this text, and they must weigh the same one — set it before any
|
|
2218
2404
|
// completion branch runs, including the ones that route first.
|
|
@@ -2231,23 +2417,10 @@ export async function main() {
|
|
|
2231
2417
|
if (result.threadId && !session.resetRequested) {
|
|
2232
2418
|
saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
2233
2419
|
}
|
|
2234
|
-
if (
|
|
2235
|
-
&& result.finalMessage
|
|
2236
|
-
&& nextTurn.planMode
|
|
2237
|
-
// A plan card carries the model's final text into the conversation as a
|
|
2238
|
-
// visible, actionable artifact — it IS posting. Silence is strict here
|
|
2239
|
-
// too: a silenced plan turn falls through to the silent teardown below
|
|
2240
|
-
// and raises no card.
|
|
2241
|
-
&& resolveSilentTurnDelivery({
|
|
2242
|
-
silenced: session.currentTurnSilenced,
|
|
2243
|
-
finalText: result.finalMessage,
|
|
2244
|
-
}) === 'deliver') {
|
|
2420
|
+
if (completedOutput.output.kind === 'plan') {
|
|
2245
2421
|
await routeArtifactsOnce();
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
ownerId,
|
|
2249
|
-
});
|
|
2250
|
-
const planId = session.currentTurnId ?? randomUUID();
|
|
2422
|
+
const plan = completedOutput.output.body;
|
|
2423
|
+
const planId = plan.planId;
|
|
2251
2424
|
let planCreated = false;
|
|
2252
2425
|
let resolvePlanCreated;
|
|
2253
2426
|
let rejectPlanCreated;
|
|
@@ -2256,17 +2429,14 @@ export async function main() {
|
|
|
2256
2429
|
rejectPlanCreated = reject;
|
|
2257
2430
|
});
|
|
2258
2431
|
const planReview = runtimeRequests.request('plan', session.conversationId, {
|
|
2259
|
-
title:
|
|
2260
|
-
|
|
2261
|
-
...(responseRouting.responseUserId
|
|
2262
|
-
? { responseUserId: responseRouting.responseUserId }
|
|
2263
|
-
: {}),
|
|
2264
|
-
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
2432
|
+
title: plan.title, body: plan.body, turnId: plan.turnId,
|
|
2433
|
+
...(plan.responseUserId ? { responseUserId: plan.responseUserId } : {}),
|
|
2265
2434
|
}, {
|
|
2266
2435
|
requestId: planId,
|
|
2267
|
-
expiresAt:
|
|
2436
|
+
expiresAt: plan.expiresAt,
|
|
2268
2437
|
responderPolicy: 'infer',
|
|
2269
|
-
onCreated: () => {
|
|
2438
|
+
onCreated: async () => {
|
|
2439
|
+
await deliverCompletedOutput(completedOutput, true);
|
|
2270
2440
|
planCreated = true;
|
|
2271
2441
|
session.turnState = 'waiting_input';
|
|
2272
2442
|
writeTurn(session);
|
|
@@ -2275,7 +2445,7 @@ export async function main() {
|
|
|
2275
2445
|
});
|
|
2276
2446
|
void planReview.then((planResult) => {
|
|
2277
2447
|
resolvePlanCreated();
|
|
2278
|
-
enqueueCodexPlanReviewResult(session, planResult,
|
|
2448
|
+
enqueueCodexPlanReviewResult(session, planResult, plan.responseUserId ?? null);
|
|
2279
2449
|
}).catch((error) => {
|
|
2280
2450
|
if (!planCreated) {
|
|
2281
2451
|
rejectPlanCreated(error);
|
|
@@ -2287,40 +2457,16 @@ export async function main() {
|
|
|
2287
2457
|
await handoffFinalMessage(session.conversationId);
|
|
2288
2458
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
|
|
2289
2459
|
}
|
|
2290
|
-
else if (
|
|
2291
|
-
&& result.finalMessage
|
|
2292
|
-
// A turn that called `no_reply` posts nothing. The failure branches
|
|
2293
|
-
// below are deliberately outside this gate: silence suppresses the
|
|
2294
|
-
// MODEL's reply, never Canon's own "this turn broke" diagnostic.
|
|
2295
|
-
&& resolveSilentTurnDelivery({
|
|
2296
|
-
silenced: session.currentTurnSilenced,
|
|
2297
|
-
finalText: result.finalMessage,
|
|
2298
|
-
}) === 'deliver') {
|
|
2460
|
+
else if (completedOutput.output.kind === 'message' && canDeliver) {
|
|
2299
2461
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
2300
2462
|
clearStoredThread();
|
|
2301
2463
|
}
|
|
2302
2464
|
await routeArtifactsOnce();
|
|
2303
|
-
|
|
2304
|
-
await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
|
|
2305
|
-
messageId: buildCodexMessageId(session, 'final'),
|
|
2306
|
-
...(session.currentReplyAuthority
|
|
2307
|
-
? { replyAuthority: session.currentReplyAuthority }
|
|
2308
|
-
: {}),
|
|
2309
|
-
...(session.activeSelfContextId
|
|
2310
|
-
? { selfContextId: session.activeSelfContextId }
|
|
2311
|
-
: {}),
|
|
2312
|
-
metadata: {
|
|
2313
|
-
turnId: session.currentTurnId,
|
|
2314
|
-
turnSemantics: 'turn_complete',
|
|
2315
|
-
...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
|
|
2316
|
-
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
2317
|
-
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
2318
|
-
},
|
|
2319
|
-
});
|
|
2465
|
+
await deliverCompletedOutput(completedOutput);
|
|
2320
2466
|
await handoffFinalMessage(session.conversationId);
|
|
2321
|
-
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${
|
|
2467
|
+
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${completedOutput.output.text.length} chars)`);
|
|
2322
2468
|
}
|
|
2323
|
-
else if (
|
|
2469
|
+
else if (completedOutput.output.kind === 'message') {
|
|
2324
2470
|
await routeArtifactsOnce();
|
|
2325
2471
|
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
2326
2472
|
session.state.lastError = userVisibleError;
|
|
@@ -2328,23 +2474,7 @@ export async function main() {
|
|
|
2328
2474
|
if (result.errorText) {
|
|
2329
2475
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
2330
2476
|
}
|
|
2331
|
-
|
|
2332
|
-
await sendMessageWithRetryChunked(client, session.conversationId, userVisibleError, {
|
|
2333
|
-
messageId: buildCodexMessageId(session, 'error'),
|
|
2334
|
-
...(session.currentReplyAuthority
|
|
2335
|
-
? { replyAuthority: session.currentReplyAuthority }
|
|
2336
|
-
: {}),
|
|
2337
|
-
...(session.activeSelfContextId
|
|
2338
|
-
? { selfContextId: session.activeSelfContextId }
|
|
2339
|
-
: {}),
|
|
2340
|
-
metadata: {
|
|
2341
|
-
turnId: session.currentTurnId,
|
|
2342
|
-
turnSemantics: 'turn_complete',
|
|
2343
|
-
...(nextTurn.sourceMessageId ? { sourceMessageId: nextTurn.sourceMessageId } : {}),
|
|
2344
|
-
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
2345
|
-
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
2346
|
-
},
|
|
2347
|
-
});
|
|
2477
|
+
await deliverCompletedOutput(completedOutput);
|
|
2348
2478
|
await handoffFinalMessage(session.conversationId);
|
|
2349
2479
|
}
|
|
2350
2480
|
else if (!result.interrupted) {
|
|
@@ -2362,7 +2492,7 @@ export async function main() {
|
|
|
2362
2492
|
// typing dots go together — leaving dots behind the removed row for
|
|
2363
2493
|
// the handoff window reads as "started to answer, then gave up".
|
|
2364
2494
|
await blankCodexStreaming(session);
|
|
2365
|
-
clearStreaming(session.conversationId);
|
|
2495
|
+
await runtimeState.clearStreaming(session.conversationId);
|
|
2366
2496
|
stopVisibleWorkSignal(session);
|
|
2367
2497
|
}
|
|
2368
2498
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -2375,8 +2505,18 @@ export async function main() {
|
|
|
2375
2505
|
typingSignals.clear(session.conversationId).catch(() => { });
|
|
2376
2506
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
2377
2507
|
}
|
|
2508
|
+
if (completedOutput.output.kind === 'none')
|
|
2509
|
+
await deliverCompletedOutput(completedOutput);
|
|
2378
2510
|
}
|
|
2379
2511
|
catch (error) {
|
|
2512
|
+
if (nativeCompleted) {
|
|
2513
|
+
session.state.lastError = completedOutput
|
|
2514
|
+
? 'Native work completed; its saved Canon output awaits delivery reconciliation.'
|
|
2515
|
+
: 'Native work completed, but its output could not be journaled. Operator reconciliation is required.';
|
|
2516
|
+
writeState(session);
|
|
2517
|
+
console.error(`[canon-codex] Native completion ${session.currentTurnId} needs reconciliation:`, error);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2380
2520
|
if (isPendingCanonOperation(error)) {
|
|
2381
2521
|
session.state.lastError = 'Canon delivery remains pending reconciliation.';
|
|
2382
2522
|
writeState(session);
|
|
@@ -2415,6 +2555,8 @@ export async function main() {
|
|
|
2415
2555
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
2416
2556
|
}
|
|
2417
2557
|
finally {
|
|
2558
|
+
if (completedOutput)
|
|
2559
|
+
activeCompletedOutputs.delete(completedOutput.turnId);
|
|
2418
2560
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2419
2561
|
session.currentTurnAbortController = null;
|
|
2420
2562
|
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
@@ -2725,7 +2867,6 @@ export async function main() {
|
|
|
2725
2867
|
});
|
|
2726
2868
|
},
|
|
2727
2869
|
onError: (error) => console.error('[canon-codex] Endpoint input deferred:', error), });
|
|
2728
|
-
inbound.start();
|
|
2729
2870
|
const stream = new CanonStream({
|
|
2730
2871
|
endpoint,
|
|
2731
2872
|
agentId,
|
|
@@ -2768,7 +2909,8 @@ export async function main() {
|
|
|
2768
2909
|
for (const conversation of conversations) {
|
|
2769
2910
|
knownConversationIds.add(conversation.id);
|
|
2770
2911
|
conversationCache.set(conversation.id, conversation);
|
|
2771
|
-
|
|
2912
|
+
await clearCompletedSilentStreaming(conversation.id);
|
|
2913
|
+
await runtimeState.clearStreaming(conversation.id);
|
|
2772
2914
|
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
2773
2915
|
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
2774
2916
|
}
|
|
@@ -2779,15 +2921,20 @@ export async function main() {
|
|
|
2779
2921
|
await reconnectRecovery.recoverNow().catch((error) => {
|
|
2780
2922
|
console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
|
|
2781
2923
|
});
|
|
2924
|
+
void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
|
|
2925
|
+
inbound.start();
|
|
2782
2926
|
startCodexStreamInBackground(stream, (error) => {
|
|
2783
2927
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
2784
2928
|
});
|
|
2785
2929
|
controlPoller.start();
|
|
2786
2930
|
const heartbeat = setInterval(() => {
|
|
2931
|
+
void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
|
|
2787
2932
|
for (const session of sessions.values()) {
|
|
2788
2933
|
writeState(session);
|
|
2789
2934
|
if (!session.running) {
|
|
2790
2935
|
writeTurn(session);
|
|
2936
|
+
if (session.queue.length)
|
|
2937
|
+
void runNextTurn(session);
|
|
2791
2938
|
}
|
|
2792
2939
|
}
|
|
2793
2940
|
publishLocalHeartbeat();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.32.
|
|
3
|
+
"version": "0.32.2",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"prepack": "npm run build"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@canonmsg/agent-sdk": "^11.0.
|
|
34
|
+
"@canonmsg/agent-sdk": "^11.0.1",
|
|
35
35
|
"@canonmsg/agent-tools": "^0.11.0",
|
|
36
36
|
"@canonmsg/coding-agent-host": "^0.9.0",
|
|
37
|
-
"@canonmsg/core": "^13.0.
|
|
37
|
+
"@canonmsg/core": "^13.0.4",
|
|
38
38
|
"@canonmsg/rich-cards": "^0.10.6",
|
|
39
39
|
"ws": "^8.21.3"
|
|
40
40
|
},
|