@awak-app/simy-cli 0.7.25 → 0.7.26
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/README.md +6 -0
- package/package.json +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +43 -8
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +21 -13
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/work-state.js +5 -0
|
@@ -2,7 +2,7 @@ import { agentRole } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
|
|
|
2
2
|
import { captureAcceptanceReads } from './acceptance-reads.js';
|
|
3
3
|
import { providerFailureDiagnostic } from './provider-failure.js';
|
|
4
4
|
import { captureDispatchProvenance } from '../update-detachment/provenance.js';
|
|
5
|
-
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
|
|
5
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, queueProviderTerminalReceipt, rootFromDelivery } from '../update-detachment/dispatch-gate.js';
|
|
6
6
|
import { normalizePullRequests } from './pull-requests.js';
|
|
7
7
|
import { delegatedNativeSandbox, canWriteWorkspace, authorizedWorktreeGitRoots } from './native-delegation.js';
|
|
8
8
|
import { flexibleReasoning } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
@@ -541,7 +541,16 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
|
|
|
541
541
|
let cursor = null;
|
|
542
542
|
do {
|
|
543
543
|
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
|
|
544
|
-
|
|
544
|
+
let page;
|
|
545
|
+
try { page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length); }
|
|
546
|
+
catch (error) {
|
|
547
|
+
// An oversized older turn (often attachments) must not discard an
|
|
548
|
+
// already complete recent exchange. This is omitted history, not a
|
|
549
|
+
// fabricated turn. Latest-page overflow still fails closed, and the
|
|
550
|
+
// classifier must judge whether the bounded context is sufficient.
|
|
551
|
+
if (cursor && turns.some(terminal) && error?.code === 'response_too_large') break;
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
545
554
|
for (const turn of page.data) {
|
|
546
555
|
if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
|
|
547
556
|
turnIds.add(turn.id); turns.push(turn);
|
|
@@ -654,9 +663,10 @@ async function frozenNativeSandbox(record, root, signal) {
|
|
|
654
663
|
return policy;
|
|
655
664
|
}
|
|
656
665
|
|
|
657
|
-
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, runPeerReview = runCodexPeerOpinion } = {}) {
|
|
666
|
+
export async function executeCodexAppServer(record, { webOrigin, openClient = openCodexAppServer, signal, authorizePreparation = async () => {}, beforeDispatch = async () => {}, onStarted = async () => {}, onActivity = () => {}, onUsage = () => {}, onEvidence = async () => {}, observeExternal = collectPurposeObservations, root, auditOnly = false, opinionOnly = false, peerAction, dispatchAttemptId: requestedAttemptId, runPeerReview = runCodexPeerOpinion } = {}) {
|
|
658
667
|
const dispatchOwner = captureDispatchOwner(root ? rootFromDelivery(root) : undefined);
|
|
659
668
|
let dispatchAttemptId;
|
|
669
|
+
if (requestedAttemptId && (!auditOnly || record.run?.managed?.outcomeAudit?.lease?.id !== requestedAttemptId)) throw new CodexAppServerError('identity', 'Independent audit dispatch must use its original lease.');
|
|
660
670
|
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: record.run.managed.taskId, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
|
|
661
671
|
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
662
672
|
const run = record.run, decision = run.managed?.decision;
|
|
@@ -697,13 +707,29 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
697
707
|
output_schema: { type: 'object', properties: { opinion: { type: 'string' } }, required: ['opinion'], additionalProperties: false },
|
|
698
708
|
}, { signal, readOnlyAnalysis: true, allowAutomaticRetry: false,
|
|
699
709
|
model: run.managed.resumeModel, cwd: taskDirectory(record), openClient, readAuthentication: readCodexAuthentication,
|
|
700
|
-
dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id,
|
|
710
|
+
dispatchTransport: initiate => dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: requestedAttemptId || run.managed.resumeLease?.id,
|
|
711
|
+
provenance: captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: peerSession,
|
|
712
|
+
worktree: taskDirectory(record), taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) }) }, attemptId => {
|
|
713
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
701
714
|
if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.');
|
|
702
715
|
dispatchAttemptId = attemptId;
|
|
703
716
|
return initiate();
|
|
704
717
|
}),
|
|
705
718
|
beforeDispatch: async () => { await authorizePreparation(); await beforeDispatch({ type: 'readOnly', networkAccess: false }); },
|
|
706
|
-
onContext: session => {
|
|
719
|
+
onContext: async session => {
|
|
720
|
+
if (!session || session === record.session || peerSession && peerSession !== session) { ambiguousContext = true; throw new CodexAppServerError('identity', 'Independent peer context changed.'); }
|
|
721
|
+
peerSession = session;
|
|
722
|
+
await onEvidence({ source: 'adapter_capture', actionId: decision.id, workerContext: { session, originalSession: record.session, cwd: taskDirectory(record), model: run.managed.resumeModel, ephemeralRequested: true } });
|
|
723
|
+
},
|
|
724
|
+
onStarted: ({ session, turnId }) => {
|
|
725
|
+
if (!dispatchAttemptId || session !== peerSession) throw new CodexAppServerError('identity', 'Independent peer identity changed.');
|
|
726
|
+
queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId });
|
|
727
|
+
onActivity();
|
|
728
|
+
},
|
|
729
|
+
onTerminal: ({ session, turnId, status, response }) => {
|
|
730
|
+
if (!dispatchAttemptId || session !== peerSession || session === record.session) throw new CodexAppServerError('identity', 'Independent peer identity changed.');
|
|
731
|
+
queueProviderTerminalReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, providerSession: session, providerExecutionId: turnId, status, response });
|
|
732
|
+
},
|
|
707
733
|
onUsage: observed => { usage = observed; onUsage(observed); } });
|
|
708
734
|
if (!peerSession || peerSession === record.session || ambiguousContext || typeof reply.output?.opinion !== 'string' || !reply.output.opinion.trim()) throw new CodexAppServerError('identity', 'Independent peer context or opinion unavailable.');
|
|
709
735
|
return { code: 0, dispatchAttemptId, opinion: { text: evidenceExcerpt(reply.output.opinion, 2000).text, source: 'independent_codex_session', session: peerSession, model: reply.model }, usage, toolEvidence: [] };
|
|
@@ -722,6 +748,8 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
722
748
|
const beforeEvidence = searchEnabled(run.managed) && !reviewing ? await captureWorkspace() : null;
|
|
723
749
|
let evidenceQueue = Promise.resolve(), evidenceFailure = null;
|
|
724
750
|
let activeTurnId = null, turnCompleted = false, turnStart = null, startReceipt = null;
|
|
751
|
+
let releaseTurnNotifications, terminalResolve;
|
|
752
|
+
const terminalObserved = new Promise(resolve => { terminalResolve = resolve; });
|
|
725
753
|
let usage = null, lastTotal = null;
|
|
726
754
|
try {
|
|
727
755
|
// An unavailable login is a connection dependency, not an action approval.
|
|
@@ -870,13 +898,18 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
870
898
|
lastTotal = Math.max(lastTotal ?? 0, total);
|
|
871
899
|
onUsage({ ...usage });
|
|
872
900
|
};
|
|
873
|
-
const cleanup = () => { unsubscribe
|
|
901
|
+
const cleanup = () => { releaseTurnNotifications = unsubscribe; signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
|
|
874
902
|
const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
|
|
875
903
|
const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
|
|
876
904
|
const closed = () => finish(new CodexAppServerError('closed', 'Codex app-server closed before turn completion.'));
|
|
877
905
|
const complete = () => {
|
|
878
906
|
if (!completion || !turnId || !startRecorded || completion.turn?.id !== turnId) return;
|
|
879
907
|
turnCompleted = true;
|
|
908
|
+
try { queueProviderTerminalReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, providerSession: targetSession,
|
|
909
|
+
providerExecutionId: turnId, status: completion.turn.status, response: messages.get(turnId) || '' }); }
|
|
910
|
+
catch (error) { terminalResolve(); return finish(error); }
|
|
911
|
+
terminalResolve();
|
|
912
|
+
if (settled) return;
|
|
880
913
|
if (completion.turn.status !== 'completed') return finish(new CodexAppServerError('turn_failed', 'Codex turn did not complete.'));
|
|
881
914
|
const message = messages.get(turnId);
|
|
882
915
|
if (!message || Buffer.byteLength(message) > (run.managed.purpose?.enabled ? 128 : 16) * 1024) return finish(new CodexAppServerError('checkpoint', 'Codex did not return a bounded checkpoint.'));
|
|
@@ -887,6 +920,13 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
887
920
|
}
|
|
888
921
|
};
|
|
889
922
|
const unsubscribe = client.onNotification((method, params, requestId) => {
|
|
923
|
+
if (settled) {
|
|
924
|
+
if (requestId === undefined && params.threadId === targetSession) {
|
|
925
|
+
if (method === 'item/completed' && params.turnId === turnId && params.item?.type === 'agentMessage') messages.set(turnId, params.item.text);
|
|
926
|
+
if (method === 'turn/completed' && params.turn?.id === turnId) { completion = params; complete(); }
|
|
927
|
+
}
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
890
930
|
if (requestId !== undefined && params.threadId === targetSession && /requestApproval|requestPermissions/i.test(method)) return finish(Object.assign(new CodexAppServerError('provider_approval', 'Codex requires provider approval or user input.'), { providerTurnId: params.turnId || turnId }));
|
|
891
931
|
if (requestId !== undefined && params.threadId === targetSession && /requestUserInput/i.test(method)) return finish(Object.assign(new CodexAppServerError(searchEnabled(run.managed) ? 'provider_question' : 'provider_approval', 'Provider asked a question; investigate before requesting a human decision.'), { providerTurnId: params.turnId || turnId, question: evidenceExcerpt(params.questions || [], 2000).text }));
|
|
892
932
|
if (params.threadId !== targetSession) return;
|
|
@@ -905,7 +945,7 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
905
945
|
signal?.addEventListener('abort', cancelled, { once: true });
|
|
906
946
|
client.child.once('close', closed);
|
|
907
947
|
const targetProvenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: targetSession, worktree: isolated ? executionCwd : record.cwd, taskId: run.managed.taskId || record.session, recordRef: recordPath(root || rootDirectory(), record.provider, record.session) });
|
|
908
|
-
turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
|
|
948
|
+
turnStart = dispatchWithFence(dispatchOwner, { taskId: run.managed.taskId || record.session, attemptId: requestedAttemptId || run.managed.resumeLease?.id, provenance: targetProvenance }, attemptId => { assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance }); if (root && hasPendingUserStop(root, record)) throw new CodexAppServerError('cancelled', 'User stopped this provider task.'); dispatchAttemptId = attemptId; return client.request('turn/start', { threadId: targetSession, input: [{ type: 'text', text: `${prompt}\n${purposeInstruction}` }], cwd: isolated ? executionCwd : record.cwd,
|
|
909
949
|
model: run.managed.resumeModel, ...(isolated ? {sandboxPolicy} : {}) }); })
|
|
910
950
|
.then(async response => { turnId = response?.turn?.id; activeTurnId = turnId; if (turnId) queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `provider:${dispatchAttemptId}:${turnId}`, providerExecutionId: turnId }); if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
|
|
911
951
|
startReceipt = Promise.resolve().then(() => onStarted({ turnId, session: targetSession }));
|
|
@@ -991,6 +1031,11 @@ export async function executeCodexAppServer(record, { webOrigin, openClient = op
|
|
|
991
1031
|
if (turnStart && !activeTurnId) await Promise.race([turnStart, new Promise(resolve => setTimeout(resolve, 2_000))]).catch(() => {});
|
|
992
1032
|
if (!turnCompleted && activeTurnId) await client.request('turn/interrupt', { threadId: targetSession, turnId: activeTurnId }, 2_000).catch(() => {});
|
|
993
1033
|
}
|
|
1034
|
+
if (activeTurnId && !turnCompleted && !client.closed) await new Promise(resolve => {
|
|
1035
|
+
const timer = setTimeout(resolve, 2_000);
|
|
1036
|
+
terminalObserved.then(() => { clearTimeout(timer); resolve(); });
|
|
1037
|
+
});
|
|
1038
|
+
releaseTurnNotifications?.();
|
|
994
1039
|
await client.close();
|
|
995
1040
|
|
|
996
1041
|
}
|
|
@@ -11,16 +11,22 @@ const disabledFeatures = [
|
|
|
11
11
|
export async function runCodexPeerOpinion(provider, request, {
|
|
12
12
|
model, cwd, openClient, readAuthentication, signal,
|
|
13
13
|
beforeDispatch = async () => {}, dispatchTransport = initiate => initiate(),
|
|
14
|
-
onContext = () => {}, onUsage = () => {}, timeoutMs = 120_000,
|
|
14
|
+
onContext = () => {}, onUsage = () => {}, onStarted = () => {}, onTerminal = () => {}, timeoutMs = 120_000,
|
|
15
15
|
} = {}) {
|
|
16
16
|
if (provider !== 'codex' || !model || !cwd || !openClient || !readAuthentication) {
|
|
17
17
|
throw new Error('Peer opinion requires the task’s Codex transport, model and workspace.');
|
|
18
18
|
}
|
|
19
19
|
signal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
|
|
20
20
|
signal.throwIfAborted();
|
|
21
|
-
const client = await openClient({ signal, cancelOnAbort:
|
|
21
|
+
const client = await openClient({ signal, cancelOnAbort: false, isolateConnectors: true, maxLineBytes: 1024 * 1024 });
|
|
22
22
|
let threadId, turnId, completed = false, unsubscribe, abort, closed;
|
|
23
|
-
let startPromise;
|
|
23
|
+
let startPromise, terminalResolve;
|
|
24
|
+
const terminalObserved = new Promise(resolve => { terminalResolve = resolve; });
|
|
25
|
+
// Preparation is abortable, but an owned turn must get a bounded chance to
|
|
26
|
+
// report interruption before its transport is closed.
|
|
27
|
+
let preparing = true;
|
|
28
|
+
const cancelPreparation = () => { if (preparing) void client.close(); };
|
|
29
|
+
signal.addEventListener('abort', cancelPreparation, { once: true });
|
|
24
30
|
try {
|
|
25
31
|
if (await readAuthentication(client) === 'missing') {
|
|
26
32
|
throw Object.assign(new Error('The task’s Codex account is not signed in.'), { code: 'authentication_required' });
|
|
@@ -73,9 +79,14 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
73
79
|
}
|
|
74
80
|
};
|
|
75
81
|
const finish = () => {
|
|
76
|
-
if (
|
|
82
|
+
if (!turnId || !turns.has(turnId)) return;
|
|
77
83
|
const turn = turns.get(turnId);
|
|
84
|
+
if (completed) return;
|
|
78
85
|
completed = true;
|
|
86
|
+
try { onTerminal({ session: threadId, turnId, status: turn.status, response: messages.get(turnId) || '' }); }
|
|
87
|
+
catch (error) { terminalResolve(); return fail(error); }
|
|
88
|
+
terminalResolve();
|
|
89
|
+
if (settled) return;
|
|
79
90
|
if (turn.status !== 'completed') return fail(Object.assign(new Error('Peer turn did not complete.'), { code: 'turn_failed' }));
|
|
80
91
|
let output;
|
|
81
92
|
try { output = JSON.parse(messages.get(turnId) || ''); } catch {
|
|
@@ -89,6 +100,7 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
89
100
|
};
|
|
90
101
|
unsubscribe = client.onNotification((method, params, requestId) => {
|
|
91
102
|
if (params.threadId !== threadId) return;
|
|
103
|
+
if (settled && !['item/completed', 'turn/completed'].includes(method)) return;
|
|
92
104
|
if (requestId !== undefined) return fail(Object.assign(new Error('Peer requested a tool or approval.'), { code: 'provider_approval' }));
|
|
93
105
|
if (method === 'item/completed' && params.item?.type === 'agentMessage') messages.set(params.turnId, params.item.text);
|
|
94
106
|
if (['item/started', 'item/completed'].includes(method) && ['commandExecution', 'mcpToolCall', 'fileChange', 'webSearch', 'dynamicToolCall'].includes(params.item?.type)) {
|
|
@@ -108,6 +120,8 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
108
120
|
client.child?.once('close', closed);
|
|
109
121
|
await beforeDispatch();
|
|
110
122
|
signal?.throwIfAborted();
|
|
123
|
+
preparing = false;
|
|
124
|
+
signal.removeEventListener('abort', cancelPreparation);
|
|
111
125
|
startPromise = Promise.resolve(dispatchTransport(() => client.request('turn/start', {
|
|
112
126
|
threadId, model, cwd, input: [{ type: 'text', text: request.prompt }],
|
|
113
127
|
outputSchema: request.output_schema, sandboxPolicy: { type: 'readOnly', networkAccess: false },
|
|
@@ -115,14 +129,22 @@ export async function runCodexPeerOpinion(provider, request, {
|
|
|
115
129
|
const started = await Promise.race([startPromise, done]);
|
|
116
130
|
turnId = started.turn?.id;
|
|
117
131
|
if (!turnId) throw new Error('Peer turn did not start.');
|
|
132
|
+
await onStarted({ session: threadId, turnId });
|
|
118
133
|
reportUsage();
|
|
119
134
|
finish();
|
|
120
135
|
return await done;
|
|
121
136
|
} finally {
|
|
122
137
|
signal?.removeEventListener('abort', abort);
|
|
123
138
|
if (closed) client.child?.removeListener('close', closed);
|
|
139
|
+
signal.removeEventListener('abort', cancelPreparation);
|
|
140
|
+
if (!completed && turnId && !client.closed) {
|
|
141
|
+
await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
|
|
142
|
+
if (!completed && !client.closed) await new Promise(resolve => {
|
|
143
|
+
const timer = setTimeout(resolve, 2000);
|
|
144
|
+
terminalObserved.then(() => { clearTimeout(timer); resolve(); });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
124
147
|
unsubscribe?.();
|
|
125
|
-
if (!completed && turnId) await client.request('turn/interrupt', { threadId, turnId }, 2000).catch(() => {});
|
|
126
148
|
await client.close();
|
|
127
149
|
}
|
|
128
150
|
}
|
|
@@ -121,7 +121,7 @@ async function auditConsistencyAttempt(record, findings, request, { root, signal
|
|
|
121
121
|
};
|
|
122
122
|
let usage = null, usageWrites = Promise.resolve();
|
|
123
123
|
try {
|
|
124
|
-
result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
|
|
124
|
+
result = await execute(view, { root, signal: controller.signal, openClient, auditOnly: true, opinionOnly, dispatchAttemptId: id, peerAction: record.run.managed.decision?.action, authorizePreparation:beforeDispatch,
|
|
125
125
|
beforeDispatch: async () => { await beforeDispatch(); await retainDiagnostic('dispatching'); },
|
|
126
126
|
onEvidence: evidence => {
|
|
127
127
|
const context = evidence?.workerContext;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OUTCOME_PLAN_SCHEMA, OUTCOME_PLAN_INSTRUCTION, validateOutcomePlan } from './outcome-plan.js';
|
|
1
2
|
import { withSceneReasoning } from '../codex-plugins/simy/hooks/scene-reasoning.mjs';
|
|
2
3
|
import { correctiveFollowupInstruction } from '../codex-plugins/simy/hooks/result-cycle.mjs';
|
|
3
4
|
import { readCodexConversation } from './codex-app-server.js';
|
|
@@ -7,7 +8,7 @@ import { runStructuredProviderReview } from '../sqm/review-worker.js';
|
|
|
7
8
|
import { inspectProviderStatus } from '../desktop-status.js';
|
|
8
9
|
import { createHash } from 'node:crypto';
|
|
9
10
|
|
|
10
|
-
export const CONTINUATION_ANALYSIS_VERSION =
|
|
11
|
+
export const CONTINUATION_ANALYSIS_VERSION = 11;
|
|
11
12
|
const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
|
|
12
13
|
const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
|
|
13
14
|
const string = maximum => ({ type: 'string', maxLength: maximum });
|
|
@@ -17,8 +18,9 @@ const EVIDENCE_SCHEMA = {
|
|
|
17
18
|
};
|
|
18
19
|
const OUTPUT_SCHEMA = {
|
|
19
20
|
type: 'object', additionalProperties: false,
|
|
20
|
-
required: ['state', 'reason', 'objective', 'remaining', 'nextAction', 'basis', 'assessment', 'blocker', 'pendingActions', 'nextActionAuthorization'],
|
|
21
|
+
required: ['state', 'reason', 'objective', 'remaining', 'nextAction', 'basis', 'assessment', 'blocker', 'pendingActions', 'nextActionAuthorization', 'outcomePlan'],
|
|
21
22
|
properties: {
|
|
23
|
+
outcomePlan: { anyOf: [{ type: 'null' }, OUTCOME_PLAN_SCHEMA] },
|
|
22
24
|
state: { type: 'string', enum: STATES }, reason: string(600), objective: string(600),
|
|
23
25
|
remaining: { type: 'array', maxItems: 8, items: string(400) },
|
|
24
26
|
nextAction: { anyOf: [string(600), { type: 'null' }] },
|
|
@@ -53,6 +55,7 @@ Determine whether the ORIGINAL user request contains concrete unfinished work wi
|
|
|
53
55
|
${correctiveFollowupInstruction} In this read-only classifier, select and ground the next action; do not perform it or answer the user yourself. A corrective question can establish investigation intent in context; use earlier still-valid work authorization for repairs and tests. Mere uncertainty about a completed result does not prove a defect: investigate the specific disputed outcome without inventing implementation work.
|
|
54
56
|
When supplied, originalProviderGoal is read-only goal metadata from this exact original conversation. Consider its objective together with the latest finished conversation evidence. An active goal can span several turns: a finished subtask does not necessarily finish the whole goal. Active status alone is NOT evidence of remaining actionable work; require concrete unfinished work in the finished messages. Later user corrections, cancellation and permissions still govern. A complete goal adds no new work or acceptance obligations; a distinct later explicit user request can be assessed on its own. Never resume a paused/blocked/usageLimited/budgetLimited goal or reset its limits. Goal evidence must use role=goal, turnRef=provider-goal and a verbatim excerpt from its objective; never present goal metadata as a human message. A truncated goal is incomplete evidence.
|
|
55
57
|
Write objective in the original user's language as the intended user experience: who should be able to do what, in which situation. Preserve the actual requested scope; do not replace the outcome with implementation tasks, test commands or constraints. Keep constraints in the reasoning and authorization checks. Do not invent an experience when the user only requested technical work.
|
|
58
|
+
${OUTCOME_PLAN_INSTRUCTION}
|
|
56
59
|
State rules:
|
|
57
60
|
- A final answer or missing plan/checkpoint alone is not completion. If original requested implementation remains explicitly unfinished, generate a concrete authorized next action even when the assistant supplied none; use enabling conditions, alternative routes or independent work. If only comparison and a Word document were requested and delivered, unimplemented features described in that document are outside scope. Do not let a generic completion phrase override concrete contradictory unfinished-work evidence, or invent gaps when the requested outcome is reported complete.
|
|
58
61
|
- continue: the user requested concrete work, work remains unfinished, and a specific feasible next step can be safely taken within existing authority. A continued local investigation/edit/test can qualify. Require user evidence for the objective AND evidence of concrete remaining work. Do not invent additional acceptance checks after the assistant says the requested work is done.
|
|
@@ -181,7 +184,8 @@ export function validateContinuationAnalysis(output, context) {
|
|
|
181
184
|
if (blocker && (!assessment.userRequestedWork || !assessment.unfinished || assessment.optionalFollowUp ||
|
|
182
185
|
!['needs_user', 'waiting', 'uncertain'].includes(state))) throw new Error('Blocker contradicts task status');
|
|
183
186
|
if (validatedPendingActions.length && (!assessment.userRequestedWork || !assessment.unfinished || assessment.optionalFollowUp || state === 'no_action')) throw new Error('Pending action contradicts task status');
|
|
184
|
-
|
|
187
|
+
const outcomePlan = validateOutcomePlan(output.outcomePlan, { objective, state, nextAction: output.nextAction, validateEvidence });
|
|
188
|
+
return { state, reason, objective, remaining, outcomePlan, nextAction: state === 'continue' ? output.nextAction : null, basis, blocker,
|
|
185
189
|
...(Object.hasOwn(output, 'pendingActions') ? { pendingActions: validatedPendingActions } : {}),
|
|
186
190
|
...(Object.hasOwn(output, 'nextActionAuthorization') ? { nextActionAuthorization: state === 'continue' ? nextActionAuthorization : null } : {}),
|
|
187
191
|
analysisVersion: CONTINUATION_ANALYSIS_VERSION };
|
|
@@ -30,7 +30,7 @@ export function sessionDecisionPresentation(candidate, run) {
|
|
|
30
30
|
choices: [],
|
|
31
31
|
};
|
|
32
32
|
if (run?.dependencyWait || run?.autoRecovery && ['recovery_required', 'auto_resuming'].includes(run.state) ||
|
|
33
|
-
['supervising', 'watching', 'waiting'].includes(candidate.autoContinueStatus)) return null;
|
|
33
|
+
['supervising', 'watching', 'waiting', 'starting', 'recovering'].includes(candidate.autoContinueStatus)) return null;
|
|
34
34
|
if (run && ['recovery_required', 'paused', 'failed', 'blocked'].includes(run.state)) {
|
|
35
35
|
const providerName = candidate.provider === 'claude' ? 'Claude' : candidate.provider === 'codex' ? 'Codex' : 'AI';
|
|
36
36
|
const context = run.state === 'paused' ? 'この作業は停止中です。停止の指示は自動で解除しません。' : ({ session_end: `元の${providerName}会話が終了し、自動継続が止まっています。`,
|
|
@@ -127,7 +127,7 @@ function navigationParent(item, record) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
130
|
-
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false } = {}) {
|
|
130
|
+
inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false } = {}) {
|
|
131
131
|
const now = requestedNow ?? Date.now();
|
|
132
132
|
const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
|
|
133
133
|
assertActive();
|
|
@@ -269,7 +269,11 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
const projected = new Array(items.length);
|
|
272
|
-
const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title))
|
|
272
|
+
const codexTitles = connected.has('codex') && (includeSelectionCandidates || items.some(item => item.provider === 'codex' && !sessionTitle(item.title)))
|
|
273
|
+
? await listCodexTitles({ codexHome }) : new Map();
|
|
274
|
+
// Title-index entries are navigation hints, not verified session metadata.
|
|
275
|
+
// Manual addition still reads the exact provider session before persisting.
|
|
276
|
+
const selectionCandidates = includeSelectionCandidates ? [...codexTitles].map(([session, title]) => ({ provider: 'codex', session, title })) : undefined;
|
|
273
277
|
assertActive();
|
|
274
278
|
let nextItem = 0;
|
|
275
279
|
const project = async item => {
|
|
@@ -337,14 +341,15 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
337
341
|
...autoContinueState(record, isRunning, availability),
|
|
338
342
|
lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
|
|
339
343
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
340
|
-
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null
|
|
344
|
+
binding: { mode, taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
345
|
+
...(upgradeable ? { upgradeRunId: run.id } : {}) },
|
|
341
346
|
originalSessionTarget: originalSessionTarget(item.provider, item.session),
|
|
342
347
|
continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
|
|
343
348
|
...(!archived && continuationAdmission ? { continuationAdmission } : {}),
|
|
344
349
|
continuationReasonCode: availability.reasonCode || claudeChannel?.code || 'ready',
|
|
345
350
|
capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
|
|
346
351
|
canPurposeContinue: !archived && supported && (!run || !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(run.state)),
|
|
347
|
-
canAnalyze: !archived &&
|
|
352
|
+
canAnalyze: !archived && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
|
|
348
353
|
canEnroll: !archived && (supported || liveHook) && !run, canContinue: !archived && (supported || liveHook) && !run,
|
|
349
354
|
canAutoContinue: !archived && (supported || liveHook) && (!run || upgradeable || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
|
|
350
355
|
reason: item.provider === 'claude' ? continuationIssue?.reason || claudeChannel.reason :
|
|
@@ -364,7 +369,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
364
369
|
const stateNow = requestedNow ?? Date.now();
|
|
365
370
|
for (const candidate of projected) candidate.workState = sessionWorkState(candidate, recordsByKey.get(`${candidate.provider}:${candidate.session}`)?.run, { now: stateNow });
|
|
366
371
|
projected.sort(compareSessionWork);
|
|
367
|
-
return { sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
372
|
+
return { ...(selectionCandidates ? { selectionCandidates } : {}), sessions: includeArchived ? projected : projected.filter(item => !item.archived && !item.localHidden), providers: { codex: { discovery: !connected.has('codex') ? 'disconnected' : codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: !connected.has('claude') ? 'disconnected' : claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
|
|
368
373
|
}
|
|
369
374
|
|
|
370
375
|
// Saving consent needs an exact admitted identity, not another provider RPC.
|
|
@@ -447,6 +452,10 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
447
452
|
failureBudgetMode: purposeFailureMode(next),
|
|
448
453
|
ruleDigest: purposeRules.digest, ruleIds: purposeRules.rules.map(rule => rule.id) };
|
|
449
454
|
if (next.run.state === 'paused' && next.run.reason === 'Automatic continuation paused by user.') { next.run.state = 'recovery_required'; next.run.recoveryCause = 'session_supervision'; next.run.managed.autoRecovery = true; }
|
|
455
|
+
// OFF keeps the current native turn alive. Re-enabling during that
|
|
456
|
+
// same turn restores future continuation without starting another turn.
|
|
457
|
+
if (previous.autoContinue === false && next.run.state === 'auto_resuming' && next.run.stopReason?.actor !== 'user')
|
|
458
|
+
next.run.managed.autoRecovery = true;
|
|
450
459
|
} else if (!enabled && next.run.managed.purpose) { if (next.run.state !== 'auto_resuming') next.run.managed.purpose.enabled = false; next.run.managed.autoRecovery = false; if (!TERMINAL_RUNS.has(next.run.state) && next.run.state !== 'auto_resuming') { next.run.state = 'paused'; next.run.reason = 'Automatic continuation paused by user.'; } }
|
|
451
460
|
}
|
|
452
461
|
// The desktop preference is an explicit human control too: an old decision
|
|
@@ -454,20 +463,17 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
|
|
|
454
463
|
if (next.run) advanceDeliveryControlRevision(next.run);
|
|
455
464
|
return next;
|
|
456
465
|
});
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
// overlapping Off action prevents the promotion from dispatching work.
|
|
460
|
-
await continueSession({ ...options, root, provider, session,
|
|
461
|
-
...updated.autoContinueSettings, autoContinueEpoch: updated.autoContinueEpoch, expectedProviderEpoch: link.epoch });
|
|
462
|
-
return readRecord(root, provider, session);
|
|
463
|
-
}
|
|
466
|
+
// Consent is durable now. Provider reads and ordinary-loop promotion belong
|
|
467
|
+
// to the background coordinator, so their failure cannot undo this response.
|
|
464
468
|
return updated;
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
export async function readSessionAutoContinue({ root, provider, session }) {
|
|
468
472
|
const record = await readRecord(root, provider, session);
|
|
469
473
|
return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
|
|
470
|
-
settings: record?.autoContinueSettings, hasRun: Boolean(record?.run)
|
|
474
|
+
settings: record?.autoContinueSettings, hasRun: Boolean(record?.run),
|
|
475
|
+
upgradeRunId: canUpgradeDelivery(record?.run, { automatic: true })
|
|
476
|
+
? record.run.id : null };
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
// Tracking is local observation metadata. It neither starts nor stops the
|
|
@@ -577,6 +583,8 @@ export async function followupSession(options) {
|
|
|
577
583
|
export async function continueSession(options) {
|
|
578
584
|
options = sessionLoopOptions(options);
|
|
579
585
|
const record = await readRecord(options.root, options.provider, options.session);
|
|
586
|
+
if (options.upgradeRunId && (record?.run?.id !== options.upgradeRunId || record.run.managed))
|
|
587
|
+
throw Object.assign(new Error('The original task changed before automatic continuation started.'), { code: 'session_task_changed' });
|
|
580
588
|
if (record?.run && !record.run.managed) {
|
|
581
589
|
// The user asked to continue this task. Adopt its goal and failure budget,
|
|
582
590
|
// rather than enrolling the generic context-bootstrap goal as a new run.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { sanitizedText } from './conversation-context.js';
|
|
2
|
+
|
|
3
|
+
// A read-only route to the requested outcome. Never an execution permission or
|
|
4
|
+
// acceptance certificate. Old/partial analyses intentionally have no plan.
|
|
5
|
+
const text = { type: 'string', minLength: 1, maxLength: 600 };
|
|
6
|
+
const id = { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,60}$' };
|
|
7
|
+
const evidence = { type: 'array', maxItems: 6, items: {
|
|
8
|
+
type: 'object', additionalProperties: false, required: ['role', 'turnRef', 'snippet'],
|
|
9
|
+
properties: { role: { type: 'string', enum: ['user', 'assistant', 'goal'] },
|
|
10
|
+
turnRef: { type: 'string', maxLength: 160 }, snippet: { type: 'string', maxLength: 280 } },
|
|
11
|
+
} };
|
|
12
|
+
export const OUTCOME_PLAN_SCHEMA = {
|
|
13
|
+
type: 'object', additionalProperties: false, required: ['destination', 'criteria', 'steps'],
|
|
14
|
+
properties: {
|
|
15
|
+
destination: text,
|
|
16
|
+
criteria: { type: 'array', minItems: 1, maxItems: 8, items: {
|
|
17
|
+
type: 'object', additionalProperties: false, required: ['id', 'description', 'basis'],
|
|
18
|
+
properties: { id, description: text, basis: evidence },
|
|
19
|
+
} },
|
|
20
|
+
steps: { type: 'array', minItems: 1, maxItems: 12, items: {
|
|
21
|
+
type: 'object', additionalProperties: false,
|
|
22
|
+
required: ['id', 'title', 'criterionIds', 'dependsOn', 'exitCondition', 'state', 'evidence'],
|
|
23
|
+
properties: { id, title: text, exitCondition: text,
|
|
24
|
+
criterionIds: { type: 'array', minItems: 1, maxItems: 8, items: id },
|
|
25
|
+
dependsOn: { type: 'array', maxItems: 11, items: id },
|
|
26
|
+
state: { type: 'string', enum: ['planned', 'current', 'reported'] }, evidence },
|
|
27
|
+
} },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const OUTCOME_PLAN_INSTRUCTION = `Return outcomePlan as a destination-driven proposed route, separate from chronological work reports. First identify the user's requested destination (exactly objective) and observable completion criteria grounded in direct user or active original-goal evidence. Work BACKWARDS from each criterion: what result must exist immediately before it, and what prerequisites enable that result? Then return those necessary steps in dependency/execution order. Every step must link to criterionIds and have a concrete observable exitCondition; cover every criterion. Do not turn paths, HEAD hashes, commands executed or investigation diary entries into milestones. Infer reasonable prerequisite steps within scope, but never add publication, deployment or other outcomes the user did not request. A plan grants no authority. Preserve blocked/future stages even when one safe action can proceed.
|
|
32
|
+
For each criterion include verbatim basis citations establishing requested scope; assistant suggestions alone are not requirements. For each step use planned unless the conversation explicitly reports its result (reported, with verbatim user/assistant evidence). Reported never means independently verified. At most one step is current; only for state=continue, and its title must exactly equal nextAction. Current depends only on stages reported accomplished; otherwise include the next enabling prerequisite first. Never mark an inferred step accomplished. For answered questions, casual chat, insufficient destination/criteria evidence or a route you cannot ground, return outcomePlan=null rather than inventing a full workflow. Do not treat an absent plan as a reason to continue completed work.`;
|
|
33
|
+
|
|
34
|
+
export function validateOutcomePlan(value, { objective, state, nextAction, validateEvidence }) {
|
|
35
|
+
if (value == null) return null;
|
|
36
|
+
try {
|
|
37
|
+
const string = value => {
|
|
38
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 600 || sanitizedText(value) !== value || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(value)) throw Error('Invalid text');
|
|
39
|
+
return value;
|
|
40
|
+
};
|
|
41
|
+
const identifier = value => { if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,60}$/.test(value)) throw Error('Invalid id'); return value; };
|
|
42
|
+
const list = (value, max, min = 0) => { if (!Array.isArray(value) || value.length < min || value.length > max) throw Error('Invalid list'); return value; };
|
|
43
|
+
if (value.destination !== objective || !objective) throw Error('Wrong destination');
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const criteria = list(value.criteria, 8, 1).map(c => {
|
|
46
|
+
const id = identifier(c.id);
|
|
47
|
+
if (ids.has(id)) throw Error('Duplicate criterion');
|
|
48
|
+
ids.add(id);
|
|
49
|
+
const basis = list(c.basis, 6, 1).map(validateEvidence);
|
|
50
|
+
if (!basis.every(e => ['user', 'goal'].includes(e.role))) throw Error('Unrequested criterion');
|
|
51
|
+
return { id, description: string(c.description), basis };
|
|
52
|
+
});
|
|
53
|
+
const prior = new Map(), covered = new Set();
|
|
54
|
+
let current = 0;
|
|
55
|
+
const steps = list(value.steps, 12, 1).map(s => {
|
|
56
|
+
const id = identifier(s.id), title = string(s.title), exitCondition = string(s.exitCondition);
|
|
57
|
+
if (prior.has(id) || !['planned', 'current', 'reported'].includes(s.state)) throw Error('Invalid step');
|
|
58
|
+
const criterionIds = list(s.criterionIds, 8, 1).map(identifier);
|
|
59
|
+
if (criterionIds.some(id => !ids.has(id))) throw Error('Unknown criterion');
|
|
60
|
+
criterionIds.forEach(id => covered.add(id));
|
|
61
|
+
const dependsOn = list(s.dependsOn, 11).map(identifier);
|
|
62
|
+
// Prior-only references reject cycles and forward/missing dependencies.
|
|
63
|
+
if (dependsOn.some(id => !prior.has(id))) throw Error('Invalid dependency');
|
|
64
|
+
const evidence = list(s.evidence, 6).map(validateEvidence);
|
|
65
|
+
if (s.state === 'reported' && (!evidence.length || evidence.some(e => e.role === 'goal'))) throw Error('Missing result report');
|
|
66
|
+
if (s.state === 'current' && (++current > 1 || state !== 'continue' || title !== nextAction || dependsOn.some(id => prior.get(id) !== 'reported'))) throw Error('Wrong current step');
|
|
67
|
+
prior.set(id, s.state);
|
|
68
|
+
return { id, title, criterionIds, dependsOn, exitCondition, state: s.state, evidence };
|
|
69
|
+
});
|
|
70
|
+
if (covered.size !== ids.size || state === 'continue' && current !== 1) throw Error('Incomplete route');
|
|
71
|
+
return { destination: objective, criteria, steps };
|
|
72
|
+
} catch {
|
|
73
|
+
// An optional presentation plan must never change triage/dispatch authority.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -9,6 +9,33 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import { purposeContract, llmDecisionInstruction } from '../codex-plugins/simy/hooks/purpose-cycle.mjs';
|
|
11
11
|
const root = new URL('../orchestrator/delivery/', import.meta.url);
|
|
12
|
+
|
|
13
|
+
// Transport context is a view. Scheduling, effects, stops and acceptance keep
|
|
14
|
+
// using the complete durable ledger; old alternatives must not exhaust RPC input.
|
|
15
|
+
export function purposeInstructionContext(managed) {
|
|
16
|
+
const view = structuredClone(managed), ledger = view.purpose?.evidence;
|
|
17
|
+
if (!Array.isArray(ledger?.units) || ledger.units.length <= 64) return view;
|
|
18
|
+
const ids = new Set([view.decision?.id, view.recoveryDiagnosisDecisionId,
|
|
19
|
+
view.executionUncertain?.decisionId].filter(Boolean));
|
|
20
|
+
const byId = new Map(ledger.units.map(unit => [unit.id, unit]));
|
|
21
|
+
const pending = [...ids];
|
|
22
|
+
for (let index = 0; index < pending.length; index++) {
|
|
23
|
+
for (const id of byId.get(pending[index])?.dependsOn || []) if (!ids.has(id)) {
|
|
24
|
+
ids.add(id); pending.push(id);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const important = ledger.units.filter(unit => ids.has(unit.id) ||
|
|
28
|
+
['unknown','running','dispatched','in_flight'].includes(unit.status));
|
|
29
|
+
const chosen = [...important, ...ledger.units.slice(-48)];
|
|
30
|
+
const unique = new Map(chosen.map(unit => [unit.id, unit]));
|
|
31
|
+
const statuses = {};
|
|
32
|
+
for (const unit of ledger.units) statuses[unit.status] = (statuses[unit.status] || 0) + 1;
|
|
33
|
+
ledger.contextProjection = { totalUnits: ledger.units.length, includedUnits: unique.size,
|
|
34
|
+
omittedUnits: ledger.units.length - unique.size, statuses,
|
|
35
|
+
note: 'Transmission view only. Omitted operations remain in durable state, not completed or authorized. Read additional units through the existing delivery status CLI when needed; select fields rather than dumping the full ledger.' };
|
|
36
|
+
ledger.units = [...unique.values()];
|
|
37
|
+
return view;
|
|
38
|
+
}
|
|
12
39
|
// Optional reference material must not become an execution prerequisite.
|
|
13
40
|
export async function loadPurposeRuleSet(options = {}) {
|
|
14
41
|
try { return await readPurposeRuleSet(options); }
|
|
@@ -45,6 +72,7 @@ async function readPurposeRuleSet({ readFileImpl = readFile, readdirImpl = readd
|
|
|
45
72
|
}
|
|
46
73
|
export async function buildPurposeInstruction(managed, { reviewer = false, cwd, webOrigin, allowNetwork = false, signal, knowledgeLoader = loadHypothesisKnowledge, authorizeKnowledgeNetwork, root: memoryRoot, record, memoryLoader = recallPerspectives } = {}) {
|
|
47
74
|
if (!managed?.purpose?.enabled) return withSceneReasoning();
|
|
75
|
+
managed = purposeInstructionContext(managed);
|
|
48
76
|
const ruleSet = await loadPurposeRuleSet();
|
|
49
77
|
if (reviewer) return `${llmDecisionInstruction}\nInspect the requested behavior and report concrete defects or missing outcomes. Consult relevant references only when useful. No rule inventory coverage, proof attachments, score or audit receipt is required.`;
|
|
50
78
|
const knowledge = await knowledgeLoader({ cwd, webOrigin, allowNetwork, signal, ...(authorizeKnowledgeNetwork ? { authorizeNetwork: authorizeKnowledgeNetwork } : {}) });
|
|
@@ -3,6 +3,18 @@ import { reconcileWorkFrontier, revalidateWorkProposal } from '../codex-plugins/
|
|
|
3
3
|
import { searchEnabled } from '../codex-plugins/simy/hooks/purpose-search.mjs';
|
|
4
4
|
import { taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
5
|
|
|
6
|
+
function frontierOptions(record) {
|
|
7
|
+
const m = record.run.managed, ledger = m.purpose?.evidence;
|
|
8
|
+
const prior = ledger?.units?.find(unit => unit.id === m.executionUncertain?.decisionId);
|
|
9
|
+
// This controller-created read exists to reconcile one retained unknown
|
|
10
|
+
// effect. Keep that effect unknown and fence all writers and other holders.
|
|
11
|
+
const reconciling = m.decision?.action?.kind === 'read' &&
|
|
12
|
+
m.recoveryDiagnosisDecisionId === m.decision.id && prior?.status === 'unknown' &&
|
|
13
|
+
prior.owner === m.taskId && prior.purposeRevision === ledger.purposeRevision;
|
|
14
|
+
return { stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
|
|
15
|
+
...(reconciling ? { reconcileUnknownUnitId: prior.id } : {}) };
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
// Enroll the controller's canonical workspace, never a worker-provided lock ID.
|
|
7
19
|
// Read-only diagnosis/verification is the path that resolves observed effects;
|
|
8
20
|
// it must remain possible while the frontier prevents another writer.
|
|
@@ -30,7 +42,7 @@ export function recoveryWorkProposal(record) {
|
|
|
30
42
|
if (unit.action.kind !== 'read' && !unit.writeResources.includes(workspace)) unit.writeResources.push(workspace);
|
|
31
43
|
}
|
|
32
44
|
// Missing PR readback stays blocked. This boundary never invents CI evidence.
|
|
33
|
-
const proposal = reconcileWorkFrontier(managed,
|
|
45
|
+
const proposal = reconcileWorkFrontier(managed, frontierOptions(record));
|
|
34
46
|
delete managed.frontierIssue;
|
|
35
47
|
return proposal;
|
|
36
48
|
} catch {
|
|
@@ -45,9 +57,7 @@ export function admitRecoveryWork(record, proposal, leaseId, now) {
|
|
|
45
57
|
const unit = managed.purpose.evidence.units.find(row => row.id === managed.decision.id);
|
|
46
58
|
if (!unit && managed.decision.action.kind === 'read' && ['understanding', 'needs_clarification'].includes(managed.phase)) return;
|
|
47
59
|
if (!unit) throw new Error('Selected work unit is missing.');
|
|
48
|
-
const current = revalidateWorkProposal(managed, proposal, unit.id,
|
|
49
|
-
stopped: ['paused', 'stopped', 'cancelled'].includes(record.run.state),
|
|
50
|
-
});
|
|
60
|
+
const current = revalidateWorkProposal(managed, proposal, unit.id, frontierOptions(record));
|
|
51
61
|
if (!current.eligible) {
|
|
52
62
|
throw new Error(`Work proposal needs reconciliation: ${current.reason || 'not eligible'}`);
|
|
53
63
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A remote usage scan must not hold up explicit preference or stop requests.
|
|
2
|
+
// Mutations retain arrival order; authorization remains in the native handlers.
|
|
3
|
+
export function createRemoteCommandQueue({ handle }) {
|
|
4
|
+
let controls = Promise.resolve(), observations = Promise.resolve();
|
|
5
|
+
return {
|
|
6
|
+
enqueue(command) {
|
|
7
|
+
const observation = ['session_statistics', 'refresh'].includes(command.action);
|
|
8
|
+
const pending = (observation ? observations : controls).then(() => handle(command));
|
|
9
|
+
// Keep a failed request from poisoning subsequent requests in that lane.
|
|
10
|
+
if (observation) observations = pending.catch(() => {});
|
|
11
|
+
else controls = pending.catch(() => {});
|
|
12
|
+
return pending;
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
export { createRemoteManagedDelivery } from './remote-managed-delivery.js';
|
|
2
|
+
export { createRemoteCommandQueue } from './remote-command-queue.js';
|
|
3
|
+
|
|
1
4
|
// Only host-discovered admission may reach the native mutation API.
|
|
2
5
|
export function createControl({ authorize, select, set, followup, save, revoke, root }) {
|
|
3
6
|
return async command => {
|