@canonmsg/codex-plugin 0.32.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 +345 -207
- package/package.json +2 -2
|
@@ -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, 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();
|
|
@@ -991,6 +1080,14 @@ export async function main() {
|
|
|
991
1080
|
settleRejectedPromptCheckpoints(conversationId, removed);
|
|
992
1081
|
writeTurn(session);
|
|
993
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
|
+
}
|
|
994
1091
|
function clearStreaming(conversationId) {
|
|
995
1092
|
runtimeState.clearStreaming(conversationId).catch(() => { });
|
|
996
1093
|
}
|
|
@@ -1028,7 +1125,7 @@ export async function main() {
|
|
|
1028
1125
|
messageId: session.currentTurnId ?? undefined,
|
|
1029
1126
|
turnId: session.currentTurnId,
|
|
1030
1127
|
blocks: [],
|
|
1031
|
-
})
|
|
1128
|
+
});
|
|
1032
1129
|
}
|
|
1033
1130
|
function upsertCodexTextSegment(session, event) {
|
|
1034
1131
|
const next = applyTextSegmentBlock({
|
|
@@ -1091,7 +1188,7 @@ export async function main() {
|
|
|
1091
1188
|
}
|
|
1092
1189
|
async function handoffFinalMessage(conversationId) {
|
|
1093
1190
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
1094
|
-
clearStreaming(conversationId);
|
|
1191
|
+
await runtimeState.clearStreaming(conversationId);
|
|
1095
1192
|
typingSignals.clear(conversationId).catch(() => { });
|
|
1096
1193
|
}
|
|
1097
1194
|
function refreshVisibleWorkSignal(session) {
|
|
@@ -1239,136 +1336,146 @@ export async function main() {
|
|
|
1239
1336
|
workspaceCwd,
|
|
1240
1337
|
allowWorktrees: sessionExecutionMode === 'worktree',
|
|
1241
1338
|
});
|
|
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,
|
|
1339
|
+
let createdAdapter;
|
|
1340
|
+
return initializeCodexSession({
|
|
1341
|
+
key: conversationId,
|
|
1342
|
+
sessions,
|
|
1343
|
+
create: async () => {
|
|
1344
|
+
const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
|
|
1259
1345
|
conversationId,
|
|
1260
|
-
|
|
1261
|
-
|
|
1346
|
+
baseCwd: environment.baseCwd,
|
|
1347
|
+
executionMode: environment.mode,
|
|
1348
|
+
resumeField: 'threadId',
|
|
1349
|
+
configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
|
|
1350
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
1262
1351
|
});
|
|
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);
|
|
1352
|
+
if (persistedMapping.status === 'configuration_required') {
|
|
1353
|
+
throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
1266
1354
|
}
|
|
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
|
-
|
|
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,
|
|
1311
1427
|
cwd: sessionCwd,
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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,
|
|
1322
1463
|
});
|
|
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
|
-
}
|
|
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
|
+
});
|
|
1372
1479
|
})();
|
|
1373
1480
|
pendingSessionCreations.set(conversationId, creation);
|
|
1374
1481
|
try {
|
|
@@ -1897,6 +2004,11 @@ export async function main() {
|
|
|
1897
2004
|
catch (error) {
|
|
1898
2005
|
const message = error instanceof Error ? error.message : String(error);
|
|
1899
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;
|
|
1900
2012
|
await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
|
|
1901
2013
|
configurationStatus: 'configuration_required',
|
|
1902
2014
|
lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
|
|
@@ -1967,12 +2079,36 @@ export async function main() {
|
|
|
1967
2079
|
async function runNextTurn(session) {
|
|
1968
2080
|
if (session.running || session.closed)
|
|
1969
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
|
+
}
|
|
1970
2103
|
const nextTurn = session.queue.shift();
|
|
1971
|
-
if (!nextTurn)
|
|
2104
|
+
if (!nextTurn) {
|
|
2105
|
+
session.running = false;
|
|
1972
2106
|
return;
|
|
1973
|
-
|
|
2107
|
+
}
|
|
1974
2108
|
const inboundId = nextTurn.sourceMessageId ? `message:${session.conversationId}:${nextTurn.sourceMessageId}` : null;
|
|
1975
2109
|
let journaledInput = false;
|
|
2110
|
+
let completedOutput;
|
|
2111
|
+
let nativeCompleted = false;
|
|
1976
2112
|
session.state.lastError = undefined;
|
|
1977
2113
|
session.state.state = 'running';
|
|
1978
2114
|
session.currentTurnId = randomUUID();
|
|
@@ -2211,8 +2347,49 @@ export async function main() {
|
|
|
2211
2347
|
session.currentTurnSilenced = false;
|
|
2212
2348
|
result = await runTurnOnce();
|
|
2213
2349
|
}
|
|
2214
|
-
|
|
2215
|
-
|
|
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);
|
|
2216
2393
|
// Both the artifact gate and the final delivery weigh silence against
|
|
2217
2394
|
// this text, and they must weigh the same one — set it before any
|
|
2218
2395
|
// completion branch runs, including the ones that route first.
|
|
@@ -2231,23 +2408,10 @@ export async function main() {
|
|
|
2231
2408
|
if (result.threadId && !session.resetRequested) {
|
|
2232
2409
|
saveStoredThreadId(runtimeId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
2233
2410
|
}
|
|
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') {
|
|
2411
|
+
if (completedOutput.output.kind === 'plan') {
|
|
2245
2412
|
await routeArtifactsOnce();
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
ownerId,
|
|
2249
|
-
});
|
|
2250
|
-
const planId = session.currentTurnId ?? randomUUID();
|
|
2413
|
+
const plan = completedOutput.output.body;
|
|
2414
|
+
const planId = plan.planId;
|
|
2251
2415
|
let planCreated = false;
|
|
2252
2416
|
let resolvePlanCreated;
|
|
2253
2417
|
let rejectPlanCreated;
|
|
@@ -2256,17 +2420,14 @@ export async function main() {
|
|
|
2256
2420
|
rejectPlanCreated = reject;
|
|
2257
2421
|
});
|
|
2258
2422
|
const planReview = runtimeRequests.request('plan', session.conversationId, {
|
|
2259
|
-
title:
|
|
2260
|
-
|
|
2261
|
-
...(responseRouting.responseUserId
|
|
2262
|
-
? { responseUserId: responseRouting.responseUserId }
|
|
2263
|
-
: {}),
|
|
2264
|
-
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
2423
|
+
title: plan.title, body: plan.body, turnId: plan.turnId,
|
|
2424
|
+
...(plan.responseUserId ? { responseUserId: plan.responseUserId } : {}),
|
|
2265
2425
|
}, {
|
|
2266
2426
|
requestId: planId,
|
|
2267
|
-
expiresAt:
|
|
2427
|
+
expiresAt: plan.expiresAt,
|
|
2268
2428
|
responderPolicy: 'infer',
|
|
2269
|
-
onCreated: () => {
|
|
2429
|
+
onCreated: async () => {
|
|
2430
|
+
await deliverCompletedOutput(completedOutput, true);
|
|
2270
2431
|
planCreated = true;
|
|
2271
2432
|
session.turnState = 'waiting_input';
|
|
2272
2433
|
writeTurn(session);
|
|
@@ -2275,7 +2436,7 @@ export async function main() {
|
|
|
2275
2436
|
});
|
|
2276
2437
|
void planReview.then((planResult) => {
|
|
2277
2438
|
resolvePlanCreated();
|
|
2278
|
-
enqueueCodexPlanReviewResult(session, planResult,
|
|
2439
|
+
enqueueCodexPlanReviewResult(session, planResult, plan.responseUserId ?? null);
|
|
2279
2440
|
}).catch((error) => {
|
|
2280
2441
|
if (!planCreated) {
|
|
2281
2442
|
rejectPlanCreated(error);
|
|
@@ -2287,40 +2448,16 @@ export async function main() {
|
|
|
2287
2448
|
await handoffFinalMessage(session.conversationId);
|
|
2288
2449
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent plan approval card`);
|
|
2289
2450
|
}
|
|
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') {
|
|
2451
|
+
else if (completedOutput.output.kind === 'message' && canDeliver) {
|
|
2299
2452
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
2300
2453
|
clearStoredThread();
|
|
2301
2454
|
}
|
|
2302
2455
|
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
|
-
});
|
|
2456
|
+
await deliverCompletedOutput(completedOutput);
|
|
2320
2457
|
await handoffFinalMessage(session.conversationId);
|
|
2321
|
-
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)`);
|
|
2322
2459
|
}
|
|
2323
|
-
else if (
|
|
2460
|
+
else if (completedOutput.output.kind === 'message') {
|
|
2324
2461
|
await routeArtifactsOnce();
|
|
2325
2462
|
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
2326
2463
|
session.state.lastError = userVisibleError;
|
|
@@ -2328,23 +2465,7 @@ export async function main() {
|
|
|
2328
2465
|
if (result.errorText) {
|
|
2329
2466
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
2330
2467
|
}
|
|
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
|
-
});
|
|
2468
|
+
await deliverCompletedOutput(completedOutput);
|
|
2348
2469
|
await handoffFinalMessage(session.conversationId);
|
|
2349
2470
|
}
|
|
2350
2471
|
else if (!result.interrupted) {
|
|
@@ -2362,7 +2483,7 @@ export async function main() {
|
|
|
2362
2483
|
// typing dots go together — leaving dots behind the removed row for
|
|
2363
2484
|
// the handoff window reads as "started to answer, then gave up".
|
|
2364
2485
|
await blankCodexStreaming(session);
|
|
2365
|
-
clearStreaming(session.conversationId);
|
|
2486
|
+
await runtimeState.clearStreaming(session.conversationId);
|
|
2366
2487
|
stopVisibleWorkSignal(session);
|
|
2367
2488
|
}
|
|
2368
2489
|
await handoffFinalMessage(session.conversationId);
|
|
@@ -2375,8 +2496,18 @@ export async function main() {
|
|
|
2375
2496
|
typingSignals.clear(session.conversationId).catch(() => { });
|
|
2376
2497
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
2377
2498
|
}
|
|
2499
|
+
if (completedOutput.output.kind === 'none')
|
|
2500
|
+
await deliverCompletedOutput(completedOutput);
|
|
2378
2501
|
}
|
|
2379
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
|
+
}
|
|
2380
2511
|
if (isPendingCanonOperation(error)) {
|
|
2381
2512
|
session.state.lastError = 'Canon delivery remains pending reconciliation.';
|
|
2382
2513
|
writeState(session);
|
|
@@ -2415,6 +2546,8 @@ export async function main() {
|
|
|
2415
2546
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
2416
2547
|
}
|
|
2417
2548
|
finally {
|
|
2549
|
+
if (completedOutput)
|
|
2550
|
+
activeCompletedOutputs.delete(completedOutput.turnId);
|
|
2418
2551
|
session.currentTurnAbortController?.abort(new Error('Codex turn ended'));
|
|
2419
2552
|
session.currentTurnAbortController = null;
|
|
2420
2553
|
recoveryCheckpointsFor(session.conversationId).settle(nextTurn.sourceMessageId);
|
|
@@ -2725,7 +2858,6 @@ export async function main() {
|
|
|
2725
2858
|
});
|
|
2726
2859
|
},
|
|
2727
2860
|
onError: (error) => console.error('[canon-codex] Endpoint input deferred:', error), });
|
|
2728
|
-
inbound.start();
|
|
2729
2861
|
const stream = new CanonStream({
|
|
2730
2862
|
endpoint,
|
|
2731
2863
|
agentId,
|
|
@@ -2768,7 +2900,8 @@ export async function main() {
|
|
|
2768
2900
|
for (const conversation of conversations) {
|
|
2769
2901
|
knownConversationIds.add(conversation.id);
|
|
2770
2902
|
conversationCache.set(conversation.id, conversation);
|
|
2771
|
-
|
|
2903
|
+
await clearCompletedSilentStreaming(conversation.id);
|
|
2904
|
+
await runtimeState.clearStreaming(conversation.id);
|
|
2772
2905
|
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
2773
2906
|
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
2774
2907
|
}
|
|
@@ -2779,15 +2912,20 @@ export async function main() {
|
|
|
2779
2912
|
await reconnectRecovery.recoverNow().catch((error) => {
|
|
2780
2913
|
console.error('[canon-codex] Startup recovery failed:', error instanceof Error ? error.message : error);
|
|
2781
2914
|
});
|
|
2915
|
+
void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
|
|
2916
|
+
inbound.start();
|
|
2782
2917
|
startCodexStreamInBackground(stream, (error) => {
|
|
2783
2918
|
console.error('[canon-codex] SSE start error:', error instanceof Error ? error.message : error);
|
|
2784
2919
|
});
|
|
2785
2920
|
controlPoller.start();
|
|
2786
2921
|
const heartbeat = setInterval(() => {
|
|
2922
|
+
void recoverCompletedOutputs().catch((error) => console.error('[canon-codex] Completed output recovery failed:', error));
|
|
2787
2923
|
for (const session of sessions.values()) {
|
|
2788
2924
|
writeState(session);
|
|
2789
2925
|
if (!session.running) {
|
|
2790
2926
|
writeTurn(session);
|
|
2927
|
+
if (session.queue.length)
|
|
2928
|
+
void runNextTurn(session);
|
|
2791
2929
|
}
|
|
2792
2930
|
}
|
|
2793
2931
|
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.1",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@canonmsg/agent-sdk": "^11.0.0",
|
|
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.3",
|
|
38
38
|
"@canonmsg/rich-cards": "^0.10.6",
|
|
39
39
|
"ws": "^8.21.3"
|
|
40
40
|
},
|