codex-workflow-v2 2.0.0-beta.13 → 2.0.0-beta.13.10
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 -1
- package/dist/reviewer-runtime-build.json +44 -20
- package/dist/src/alpha6/failed-step-planning-recovery.d.ts +10 -0
- package/dist/src/alpha6/failed-step-planning-recovery.js +93 -0
- package/dist/src/alpha6/failed-step-planning-recovery.js.map +1 -0
- package/dist/src/alpha6/plan-risk.d.ts +4 -0
- package/dist/src/alpha6/plan-risk.js +50 -13
- package/dist/src/alpha6/plan-risk.js.map +1 -1
- package/dist/src/alpha6/preexecution-replan.d.ts +38 -0
- package/dist/src/alpha6/preexecution-replan.js +130 -0
- package/dist/src/alpha6/preexecution-replan.js.map +1 -0
- package/dist/src/alpha6/remediation.d.ts +4 -2
- package/dist/src/alpha6/remediation.js +155 -20
- package/dist/src/alpha6/remediation.js.map +1 -1
- package/dist/src/alpha6/root-cause-replan-carryover.d.ts +18 -0
- package/dist/src/alpha6/root-cause-replan-carryover.js +396 -0
- package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -0
- package/dist/src/alpha7/autonomy.d.ts +3 -0
- package/dist/src/alpha7/autonomy.js +89 -42
- package/dist/src/alpha7/autonomy.js.map +1 -1
- package/dist/src/change-explanation.d.ts +64 -0
- package/dist/src/change-explanation.js +150 -0
- package/dist/src/change-explanation.js.map +1 -0
- package/dist/src/cli-actions.d.ts +3 -2
- package/dist/src/cli-actions.js +6 -0
- package/dist/src/cli-actions.js.map +1 -1
- package/dist/src/cli.js +73 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/contracts.d.ts +38 -0
- package/dist/src/dependency-provenance.d.ts +3 -2
- package/dist/src/dependency-provenance.js +33 -2
- package/dist/src/dependency-provenance.js.map +1 -1
- package/dist/src/domain/plan-semantics.d.ts +11 -0
- package/dist/src/domain/plan-semantics.js +49 -0
- package/dist/src/domain/plan-semantics.js.map +1 -0
- package/dist/src/gateway-handshake.js +2 -0
- package/dist/src/gateway-handshake.js.map +1 -1
- package/dist/src/lifecycle/corrective-replan.js +3 -3
- package/dist/src/lifecycle/corrective-replan.js.map +1 -1
- package/dist/src/observed-routes.js +1 -0
- package/dist/src/observed-routes.js.map +1 -1
- package/dist/src/pending-review-update.d.ts +49 -0
- package/dist/src/pending-review-update.js +132 -0
- package/dist/src/pending-review-update.js.map +1 -0
- package/dist/src/repository.js +16 -4
- package/dist/src/repository.js.map +1 -1
- package/dist/src/reviewer.js +4 -1
- package/dist/src/reviewer.js.map +1 -1
- package/dist/src/state/corrective-replan-executor.js +3 -1
- package/dist/src/state/corrective-replan-executor.js.map +1 -1
- package/dist/src/state/corrective-replan-public.js +5 -4
- package/dist/src/state/corrective-replan-public.js.map +1 -1
- package/dist/src/state/corrective-yield-executor.js +12 -3
- package/dist/src/state/corrective-yield-executor.js.map +1 -1
- package/dist/src/state/store.d.ts +1 -1
- package/dist/src/state/store.js +17 -3
- package/dist/src/state/store.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/workflow.d.ts +84 -1
- package/dist/src/workflow.js +1616 -355
- package/dist/src/workflow.js.map +1 -1
- package/docs/beta13.2-signal-review-recovery.md +38 -0
- package/docs/change-model.md +118 -0
- package/docs/delegated-approval.md +26 -0
- package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
- package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +9 -1
- package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +9 -5
- package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +18 -3
- package/docs/pending-review-update.md +15 -0
- package/docs/release.md +267 -2
- package/docs/updating-existing-project.md +36 -6
- package/docs/validation-report.md +30 -9
- package/package.json +2 -1
- package/plugins/codex-workflow-gateway/references/chat-dispatch.md +204 -0
- package/plugins/codex-workflow-gateway/references/protocol.md +11 -0
- package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +298 -0
- package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +43 -0
- package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +55 -3
- package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +218 -21
- package/schemas/plan-risk-audit-event.schema.json +1 -0
- package/schemas/preexecution-replan-event.schema.json +45 -0
- package/schemas/task.schema.json +55 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { selectChatModel } from './chat-model-policy.mjs';
|
|
6
|
+
|
|
7
|
+
const hash = text => createHash('sha256').update(text).digest('hex');
|
|
8
|
+
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
9
|
+
? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
10
|
+
const text = (value, name) => {
|
|
11
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} is required.`);
|
|
12
|
+
return value.trim();
|
|
13
|
+
};
|
|
14
|
+
const actualId = value => {
|
|
15
|
+
const id = text(value, 'actual threadId');
|
|
16
|
+
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)) throw new Error('Expected actual thread UUID, not clientThreadId.');
|
|
17
|
+
return id;
|
|
18
|
+
};
|
|
19
|
+
function jsonFile(file) {
|
|
20
|
+
if (statSync(file).size > 16 * 1024 * 1024) throw new Error('Input file exceeds 16 MiB.');
|
|
21
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
function commonGitDir(cwd) {
|
|
24
|
+
return realpathSync(execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
25
|
+
cwd, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024,
|
|
26
|
+
}).trim());
|
|
27
|
+
}
|
|
28
|
+
function promptMatches(value, digest) {
|
|
29
|
+
if (typeof value !== 'string') return false;
|
|
30
|
+
if (hash(value) === digest) return true;
|
|
31
|
+
const wrapped = /^<codex_delegation>\s*<source_thread_id>[^<]+<\/source_thread_id>\s*<input>([\s\S]*)<\/input>\s*<\/codex_delegation>$/.exec(value);
|
|
32
|
+
return wrapped !== null && hash(wrapped[1]) === digest;
|
|
33
|
+
}
|
|
34
|
+
function view(entry) {
|
|
35
|
+
const d = entry.dispatch;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
reservationId: entry.reservationId, state: d?.state ?? (entry.threadId ? 'legacy-bound' : 'untracked'),
|
|
39
|
+
createAllowed: false, dispatchId: d?.id ?? null, clientThreadId: d?.clientThreadId ?? null,
|
|
40
|
+
threadId: entry.threadId ?? d?.candidateThreadId ?? null, hostId: d?.hostId ?? entry.hostId ?? null,
|
|
41
|
+
executionContext: d?.executionContext ?? null,
|
|
42
|
+
selection: d?.selection ?? null, supervision: d?.supervision ?? null,
|
|
43
|
+
candidates: d?.candidates ?? [],
|
|
44
|
+
nextAction: entry.threadId ? 'supervise-existing' : d?.state === 'resolved' ? 'title-readback-and-bind'
|
|
45
|
+
: d ? 'resolve-existing-creation' : 'dispatch-begin',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function dispatchAction(action, entry, input = {}) {
|
|
49
|
+
if (action === 'dispatch-status') return view(entry);
|
|
50
|
+
if (action === 'dispatch-begin') {
|
|
51
|
+
// A durable marker is committed BEFORE the external call. Even a lost response never permits another call.
|
|
52
|
+
if (entry.dispatch || entry.threadId) return view(entry);
|
|
53
|
+
if (entry.status === 'abandoned' || entry.status === 'blocked') throw new Error('Reservation is not dispatchable.');
|
|
54
|
+
const repositoryRoot = realpathSync(text(input.repositoryRoot, 'repositoryRoot'));
|
|
55
|
+
const gitDir = commonGitDir(repositoryRoot);
|
|
56
|
+
const executionMode = input.executionMode ?? 'worktree';
|
|
57
|
+
if (!['local', 'worktree'].includes(executionMode)) throw new Error('executionMode must be local or worktree.');
|
|
58
|
+
const appProjectId = text(input.appProjectId, 'appProjectId');
|
|
59
|
+
const hostId = text(input.hostId, 'hostId');
|
|
60
|
+
const promptPath = realpathSync(text(input.promptFile, 'promptFile'));
|
|
61
|
+
if (statSync(promptPath).size > 1024 * 1024) throw new Error('TaskContextPacket exceeds 1 MiB.');
|
|
62
|
+
const prompt = readFileSync(promptPath, 'utf8');
|
|
63
|
+
if (!prompt.trim() || !prompt.includes(entry.entityId)) throw new Error('Prompt must include the exact entity ID.');
|
|
64
|
+
const selection = selectChatModel(entry.type, input.modelRequest);
|
|
65
|
+
const id = randomUUID();
|
|
66
|
+
const marker = `[workflow-dispatch:${id}:${hash(prompt)}]`;
|
|
67
|
+
const dispatchedPrompt = `${marker}\n${prompt}\nExecution boundary: use the actual child checkout returned by App readback. Before any write, reconcile it with the exact Task branch and run dispatch-context-check. A repository path in this packet is identity context, not authorization to write outside that checkout.`;
|
|
68
|
+
entry.dispatch = {
|
|
69
|
+
version: 1, id, state: 'creating', appProjectId, hostId, repositoryRoot, gitDir, executionMode,
|
|
70
|
+
promptPath, promptHash: hash(prompt), dispatchedPromptHash: hash(dispatchedPrompt), marker,
|
|
71
|
+
selection, selectionHistory: [selection], clientThreadId: null, candidateThreadId: null,
|
|
72
|
+
candidates: [], observations: [], createdAt: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
return { ...view(entry), createAllowed: true, nextAction: 'create-thread-once', createArgs: {
|
|
75
|
+
target: { type: 'project', projectId: appProjectId, environment: { type: executionMode } },
|
|
76
|
+
title: entry.requestedTitle, prompt: dispatchedPrompt, model: selection.model, thinking: selection.thinking,
|
|
77
|
+
} };
|
|
78
|
+
}
|
|
79
|
+
const d = entry.dispatch;
|
|
80
|
+
if (action === 'dispatch-handoff-reconcile') {
|
|
81
|
+
const receipt = input.receipt;
|
|
82
|
+
const t = input.observation?.thread;
|
|
83
|
+
const sourceThreadId = actualId(input.sourceThreadId);
|
|
84
|
+
const sourceHostId = text(input.sourceHostId, 'sourceHostId');
|
|
85
|
+
const operationId = text(receipt?.operationId, 'handoff operationId');
|
|
86
|
+
if (receipt?.status !== 'success' || !Number.isSafeInteger(receipt.revision) || receipt.revision < 1
|
|
87
|
+
|| !Array.isArray(receipt.steps) || receipt.steps.length === 0
|
|
88
|
+
|| receipt.steps.some(step => !step || typeof step.id !== 'string' || !step.id || step.status !== 'done')) {
|
|
89
|
+
throw new Error('Require a successful completed App handoff receipt.');
|
|
90
|
+
}
|
|
91
|
+
const destinationThreadId = actualId(receipt.destinationThreadId);
|
|
92
|
+
const destinationHostId = text(receipt.destinationHostId, 'destinationHostId');
|
|
93
|
+
if (!entry.titleVerified || entry.status !== 'bound' || !entry.threadId
|
|
94
|
+
|| receipt.threadTitle !== entry.observedTitle || receipt.threadTitle !== entry.requestedTitle
|
|
95
|
+
|| t?.kind !== 'codex' || t.id !== destinationThreadId || t.hostId !== destinationHostId
|
|
96
|
+
|| t.title !== receipt.threadTitle) throw new Error('Handoff requires the exact bound title and destination readback.');
|
|
97
|
+
const cwd = realpathSync(text(t.cwd, 'destination cwd'));
|
|
98
|
+
if (cwd !== realpathSync(text(receipt.destinationCwd, 'receipt destinationCwd'))) {
|
|
99
|
+
throw new Error('Handoff destination cwd does not match its receipt.');
|
|
100
|
+
}
|
|
101
|
+
const root = realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 5000 }).trim());
|
|
102
|
+
const repositoryRoot = realpathSync(text(input.repositoryRoot, 'repositoryRoot from fresh Task'));
|
|
103
|
+
const gitDir = commonGitDir(repositoryRoot);
|
|
104
|
+
const expectedBranch = text(input.expectedBranch, 'expectedBranch from fresh Task');
|
|
105
|
+
if (commonGitDir(root) !== gitDir || (d && d.gitDir !== gitDir)
|
|
106
|
+
|| execFileSync('git', ['branch', '--show-current'], { cwd: root, encoding: 'utf8', timeout: 5000 }).trim() !== expectedBranch) {
|
|
107
|
+
throw new Error('Handoff repository or Task branch mismatch.');
|
|
108
|
+
}
|
|
109
|
+
const binding = { sourceThreadId, sourceHostId, receipt, repositoryRoot, expectedBranch,
|
|
110
|
+
destination: { threadId: destinationThreadId, hostId: destinationHostId, cwd, checkoutRoot: root, title: t.title } };
|
|
111
|
+
const bindingHash = hash(canonical(binding));
|
|
112
|
+
const history = entry.handoffHistory ?? [];
|
|
113
|
+
const previous = history.find(event => event.operationId === operationId);
|
|
114
|
+
if (previous) {
|
|
115
|
+
if (previous.bindingHash !== bindingHash || history.at(-1) !== previous
|
|
116
|
+
|| entry.threadId !== destinationThreadId || entry.hostId !== destinationHostId) {
|
|
117
|
+
throw new Error('Handoff operation was already recorded with a different binding or superseded.');
|
|
118
|
+
}
|
|
119
|
+
return { ...view(entry), handoffOperationId: operationId, idempotentRetry: true };
|
|
120
|
+
}
|
|
121
|
+
if (entry.threadId !== sourceThreadId || entry.hostId !== sourceHostId
|
|
122
|
+
|| (d && (d.hostId !== sourceHostId || d.candidateThreadId !== sourceThreadId))) {
|
|
123
|
+
throw new Error('Handoff source must match the currently bound Worker.');
|
|
124
|
+
}
|
|
125
|
+
const recordedAt = new Date().toISOString();
|
|
126
|
+
// Receipt input is coherence evidence, not a signed App capability. Preserve the
|
|
127
|
+
// complete prior dispatch instead of relabeling its original creation history.
|
|
128
|
+
entry.handoffHistory = [...history, { operationId, bindingHash, ...binding, recordedAt,
|
|
129
|
+
priorDispatch: d ? structuredClone(d) : null }];
|
|
130
|
+
entry.threadId = destinationThreadId;
|
|
131
|
+
entry.hostId = destinationHostId;
|
|
132
|
+
if (d) {
|
|
133
|
+
d.hostId = destinationHostId;
|
|
134
|
+
d.candidateThreadId = destinationThreadId;
|
|
135
|
+
d.executionContext = { checkoutRoot: root, gitDir, threadId: destinationThreadId, hostId: destinationHostId };
|
|
136
|
+
d.executionMode = root === d.repositoryRoot ? 'local' : 'worktree';
|
|
137
|
+
d.candidates = [];
|
|
138
|
+
d.duplicateDisposition = null;
|
|
139
|
+
d.state = 'resolved';
|
|
140
|
+
d.observations.push({ threadId: destinationThreadId, hostId: destinationHostId, cwd, title: t.title,
|
|
141
|
+
observedAt: recordedAt, handoffOperationId: operationId });
|
|
142
|
+
if (d.supervision) d.supervision = { ...d.supervision, cursor: null,
|
|
143
|
+
state: 'attention', nextAction: 'refresh-destination-observation-and-core-next', updatedAt: recordedAt };
|
|
144
|
+
}
|
|
145
|
+
return { ...view(entry), handoffOperationId: operationId, idempotentRetry: false };
|
|
146
|
+
}
|
|
147
|
+
if (action === 'dispatch-context-check') {
|
|
148
|
+
// A fresh observation is mandatory; prior readback is not a filesystem capability.
|
|
149
|
+
const t = input.observation?.thread;
|
|
150
|
+
if (!entry.threadId || t?.id !== entry.threadId || t.kind !== 'codex' || t.hostId !== (d?.hostId ?? entry.hostId)) {
|
|
151
|
+
throw new Error('Require a fresh observation of the bound Worker.');
|
|
152
|
+
}
|
|
153
|
+
const cwd = realpathSync(text(t.cwd, 'thread cwd'));
|
|
154
|
+
const writeRoot = realpathSync(text(input.writeRoot, 'writeRoot'));
|
|
155
|
+
const root = realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
156
|
+
cwd, encoding: 'utf8', timeout: 5000,
|
|
157
|
+
}).trim());
|
|
158
|
+
const branch = execFileSync('git', ['branch', '--show-current'], {
|
|
159
|
+
cwd: root, encoding: 'utf8', timeout: 5000,
|
|
160
|
+
}).trim();
|
|
161
|
+
const expectedBranch = text(input.expectedBranch, 'expectedBranch from fresh Task');
|
|
162
|
+
const reasons = [];
|
|
163
|
+
const expectedGitDir = d?.gitDir ?? commonGitDir(realpathSync(text(input.repositoryRoot, 'repositoryRoot from fresh Task')));
|
|
164
|
+
if (commonGitDir(root) !== expectedGitDir) reasons.push('REPOSITORY_MISMATCH');
|
|
165
|
+
if (d && d.executionContext?.checkoutRoot !== root) reasons.push('WORKER_CHECKOUT_CHANGED');
|
|
166
|
+
if (writeRoot !== root) reasons.push('WRITE_ROOT_MISMATCH');
|
|
167
|
+
if (branch !== expectedBranch) reasons.push('TASK_BRANCH_MISMATCH');
|
|
168
|
+
return { kind: 'execution-context-check', readOnly: true, authority: 'none',
|
|
169
|
+
matched: reasons.length === 0, reasons, threadId: entry.threadId,
|
|
170
|
+
contextBinding: d ? 'tracked-dispatch' : 'legacy-current-observation',
|
|
171
|
+
historicalCheckoutVerified: Boolean(d?.executionContext),
|
|
172
|
+
checkoutRoot: root, writeRoot, branch, expectedBranch,
|
|
173
|
+
nextAction: reasons.length ? 'reconcile-existing-worker-context' : 'refresh-core-next-and-writer-authority',
|
|
174
|
+
limitations: ['This check does not grant filesystem access or override external approval.',
|
|
175
|
+
'Recheck before a write after cwd or branch changes. No automatic handoff or duplicate dispatch.'] };
|
|
176
|
+
}
|
|
177
|
+
if (!d) throw new Error('No tracked dispatch; reconcile legacy chats manually, never assume creation failed.');
|
|
178
|
+
if (action === 'dispatch-result') {
|
|
179
|
+
const result = input.result;
|
|
180
|
+
if (!result || result.hostId !== d.hostId) throw new Error('Create result host mismatch.');
|
|
181
|
+
if (result.threadId && result.clientThreadId) throw new Error('Ambiguous create result.');
|
|
182
|
+
if (result.threadId) {
|
|
183
|
+
const id = actualId(result.threadId);
|
|
184
|
+
if (d.candidateThreadId && d.candidateThreadId !== id) throw new Error('Conflicting actual thread IDs.');
|
|
185
|
+
d.candidateThreadId = id;
|
|
186
|
+
} else {
|
|
187
|
+
const id = text(result.clientThreadId, 'clientThreadId');
|
|
188
|
+
if (!id.startsWith('client-new-thread:')) throw new Error('Invalid pending clientThreadId.');
|
|
189
|
+
if (d.clientThreadId && d.clientThreadId !== id) throw new Error('Conflicting creation attempts; reconcile duplicates.');
|
|
190
|
+
d.clientThreadId = id;
|
|
191
|
+
}
|
|
192
|
+
if (d.state === 'creating') d.state = 'pending';
|
|
193
|
+
return view(entry);
|
|
194
|
+
}
|
|
195
|
+
if (action === 'dispatch-discover') {
|
|
196
|
+
// Read only specifically supplied session files; never scan credential stores or print transcripts/reasoning.
|
|
197
|
+
if (!Array.isArray(input.sessionFiles) || input.sessionFiles.length > 200) throw new Error('Supply at most 200 candidate session files.');
|
|
198
|
+
const found = new Map(d.candidates.map(c => [c.threadId, c]));
|
|
199
|
+
for (const file of input.sessionFiles) {
|
|
200
|
+
if (statSync(file).size > 16 * 1024 * 1024) throw new Error('Session exceeds 16 MiB; narrow evidence required.');
|
|
201
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
202
|
+
const meta = JSON.parse(lines[0]).payload;
|
|
203
|
+
if (!meta || !['vscode', 'cli'].includes(meta.source) || typeof meta.cwd !== 'string') continue;
|
|
204
|
+
const rows = lines.slice(1).filter(Boolean).map(line => JSON.parse(line));
|
|
205
|
+
const bound = rows.some(row => {
|
|
206
|
+
if (row.type !== 'response_item') return false;
|
|
207
|
+
const p = row.payload;
|
|
208
|
+
// Actual App rollout format: a create_thread function output containing routing-only XML.
|
|
209
|
+
if (p?.type === 'function_call_output' && p.name === 'create_thread' && p.namespace === 'codex_app') {
|
|
210
|
+
return promptMatches(p.output, d.dispatchedPromptHash);
|
|
211
|
+
}
|
|
212
|
+
return p?.type === 'message' && p.role === 'user' && p.content?.some(c =>
|
|
213
|
+
['input_text', 'text'].includes(c.type) && promptMatches(c.text, d.dispatchedPromptHash));
|
|
214
|
+
});
|
|
215
|
+
if (!bound) continue;
|
|
216
|
+
if (commonGitDir(meta.cwd) !== d.gitDir) throw new Error('Session belongs to another repository.');
|
|
217
|
+
const id = actualId(meta.id);
|
|
218
|
+
found.set(id, { threadId: id, cwd: realpathSync(meta.cwd), sourceFile: realpathSync(file) });
|
|
219
|
+
}
|
|
220
|
+
d.candidates = [...found.values()];
|
|
221
|
+
if (d.candidates.length > 1) d.state = 'duplicate-conflict';
|
|
222
|
+
return view(entry);
|
|
223
|
+
}
|
|
224
|
+
if (action === 'dispatch-resolve-duplicates') {
|
|
225
|
+
const canonical = actualId(input.canonicalThreadId);
|
|
226
|
+
if (entry.threadId && entry.threadId !== canonical) throw new Error('Cannot replace a bound child.');
|
|
227
|
+
if (d.candidateThreadId && d.candidateThreadId !== canonical) throw new Error('Cannot replace the correlated create result.');
|
|
228
|
+
if (!d.candidates.some(c => c.threadId === canonical)) throw new Error('Canonical child must be a discovered candidate.');
|
|
229
|
+
const others = d.candidates.filter(c => c.threadId !== canonical);
|
|
230
|
+
if (!others.length || !Array.isArray(input.dispositions) || input.dispositions.length !== others.length) {
|
|
231
|
+
throw new Error('Require one idle readback and archive receipt for every duplicate.');
|
|
232
|
+
}
|
|
233
|
+
const receipts = others.map(candidate => {
|
|
234
|
+
const matching = input.dispositions.filter(x => x?.threadId === candidate.threadId);
|
|
235
|
+
if (matching.length !== 1) throw new Error('Duplicate disposition missing or repeated.');
|
|
236
|
+
const item = matching[0];
|
|
237
|
+
const t = item.observation?.thread;
|
|
238
|
+
if (t?.id !== candidate.threadId || t.kind !== 'codex' || t.hostId !== d.hostId
|
|
239
|
+
|| t.status?.type !== 'idle' || realpathSync(text(t.cwd, 'duplicate cwd')) !== candidate.cwd
|
|
240
|
+
|| commonGitDir(t.cwd) !== d.gitDir || item.archiveResult?.threadId !== candidate.threadId
|
|
241
|
+
|| item.archiveResult?.archived !== true) throw new Error('Duplicate requires matching idle readback and successful archive receipt.');
|
|
242
|
+
return { threadId: candidate.threadId, archived: true, recordedAt: new Date().toISOString() };
|
|
243
|
+
});
|
|
244
|
+
d.duplicateDisposition = { canonicalThreadId: canonical, archived: receipts };
|
|
245
|
+
d.state = 'pending';
|
|
246
|
+
return view(entry);
|
|
247
|
+
}
|
|
248
|
+
if (action === 'dispatch-observe') {
|
|
249
|
+
const observation = input.observation;
|
|
250
|
+
const t = observation?.thread;
|
|
251
|
+
if (!t || t.kind !== 'codex' || t.hostId !== d.hostId) throw new Error('Require actual Codex read_thread observation on the bound host.');
|
|
252
|
+
const id = actualId(t.id);
|
|
253
|
+
if (d.candidates.length > 1 && (d.duplicateDisposition?.canonicalThreadId !== id
|
|
254
|
+
|| d.candidates.some(c => c.threadId !== id && !d.duplicateDisposition.archived.some(a => a.threadId === c.threadId)))) {
|
|
255
|
+
throw new Error('Multiple candidates: explicit duplicate disposition required before binding.');
|
|
256
|
+
}
|
|
257
|
+
const candidate = d.candidates.find(c => c.threadId === id);
|
|
258
|
+
if (id !== d.candidateThreadId && !candidate) throw new Error('Uncorrelated thread; require create result or exact session prompt evidence.');
|
|
259
|
+
if (commonGitDir(text(t.cwd, 'thread cwd')) !== d.gitDir
|
|
260
|
+
|| (candidate && realpathSync(t.cwd) !== candidate.cwd)) throw new Error('Readback repository/cwd mismatch.');
|
|
261
|
+
if (t.projectId !== undefined && t.projectId !== d.appProjectId) throw new Error('Readback App project mismatch.');
|
|
262
|
+
if (d.candidateThreadId && d.candidateThreadId !== id) throw new Error('Readback differs from create result.');
|
|
263
|
+
const checkoutRoot = realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
264
|
+
cwd: t.cwd, encoding: 'utf8', timeout: 5000,
|
|
265
|
+
}).trim());
|
|
266
|
+
if (d.executionMode === 'local' && checkoutRoot !== d.repositoryRoot) {
|
|
267
|
+
throw new Error('Local dispatch readback checkout mismatch.');
|
|
268
|
+
}
|
|
269
|
+
if (d.executionContext && d.executionContext.checkoutRoot !== checkoutRoot) {
|
|
270
|
+
throw new Error('Bound Worker checkout changed; reconcile context without replacing the child.');
|
|
271
|
+
}
|
|
272
|
+
d.executionContext = { checkoutRoot, gitDir: d.gitDir, threadId: id, hostId: d.hostId };
|
|
273
|
+
d.candidateThreadId = id;
|
|
274
|
+
d.state = 'resolved';
|
|
275
|
+
d.observations.push({ threadId: id, hostId: t.hostId, cwd: realpathSync(t.cwd),
|
|
276
|
+
title: text(t.title, 'observed title'), observedAt: new Date().toISOString() });
|
|
277
|
+
return view(entry);
|
|
278
|
+
}
|
|
279
|
+
if (action === 'supervision-record') {
|
|
280
|
+
if (!entry.threadId || input.threadId !== entry.threadId || input.ownerThreadId === entry.threadId) throw new Error('Require bound child and separate supervisor.');
|
|
281
|
+
const owner = actualId(input.ownerThreadId);
|
|
282
|
+
if (d.supervision && d.supervision.ownerThreadId !== owner) throw new Error('Supervisor already assigned; no parallel wait owner.');
|
|
283
|
+
if (!['active', 'attention', 'completed'].includes(input.state)) throw new Error('Invalid supervision state.');
|
|
284
|
+
d.supervision = { ownerThreadId: owner, state: input.state, cursor: input.cursor == null ? null : text(input.cursor, 'cursor'),
|
|
285
|
+
nextAction: text(input.nextAction, 'nextAction'), updatedAt: new Date().toISOString() };
|
|
286
|
+
return view(entry);
|
|
287
|
+
}
|
|
288
|
+
if (action === 'model-select') {
|
|
289
|
+
if (!entry.threadId || input.threadId !== entry.threadId) throw new Error('Phase changes require the same bound thread.');
|
|
290
|
+
const selection = selectChatModel(entry.type, input.modelRequest);
|
|
291
|
+
d.selection = selection;
|
|
292
|
+
d.selectionHistory.push(selection);
|
|
293
|
+
return { ...view(entry), followUpArgs: { threadId: entry.threadId, hostId: entry.hostId,
|
|
294
|
+
model: selection.model, thinking: selection.thinking } };
|
|
295
|
+
}
|
|
296
|
+
throw new Error(`Unknown dispatch action: ${action}`);
|
|
297
|
+
}
|
|
298
|
+
export function dispatchInput(file) { return jsonFile(file); }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Relative cost/latency policy, not a price list or an availability oracle.
|
|
2
|
+
export const MODEL_POLICY_VERSION = 'chat-model-policy-v1';
|
|
3
|
+
const PROFILES = {
|
|
4
|
+
mechanical: [['gpt-5.4-mini', 'low'], ['gpt-5.6-luna', 'low']],
|
|
5
|
+
scoped: [['gpt-5.6-luna', 'medium'], ['gpt-5.6-terra', 'medium']],
|
|
6
|
+
implementation: [['gpt-5.6-terra', 'medium'], ['gpt-5.6-sol', 'medium']],
|
|
7
|
+
planning: [['gpt-5.6-sol', 'medium'], ['gpt-6-astra', 'medium']],
|
|
8
|
+
review: [['gpt-5.6-sol', 'high'], ['gpt-6-astra', 'high']],
|
|
9
|
+
'scoped-review': [['gpt-5.6-terra', 'medium'], ['gpt-5.6-sol', 'medium']],
|
|
10
|
+
complex: [['gpt-6-astra', 'high'], ['gpt-5.6-sol', 'high']],
|
|
11
|
+
};
|
|
12
|
+
const TYPES = ['coordinator', 'task', 'step-review', 'final-review', 'corrective-audit', 'plan-audit'];
|
|
13
|
+
export function selectChatModel(type, request) {
|
|
14
|
+
if (!TYPES.includes(type)) throw new Error('Unknown chat type.');
|
|
15
|
+
if (!request || !['routine', 'standard', 'complex'].includes(request.complexity)
|
|
16
|
+
|| !['planning', 'implementation', 'mechanical', 'review', 'coordination'].includes(request.phase)
|
|
17
|
+
|| typeof request.rationale !== 'string' || !request.rationale.trim()
|
|
18
|
+
|| typeof request.highRisk !== 'boolean') throw new Error('Explicit phase, complexity, highRisk and rationale are required.');
|
|
19
|
+
if (!Array.isArray(request.availableModels) || request.availableModels.length === 0
|
|
20
|
+
|| request.availableModels.some(m => !m || typeof m.id !== 'string' || !Array.isArray(m.reasoningEfforts)
|
|
21
|
+
|| m.reasoningEfforts.some(e => typeof e !== 'string'))
|
|
22
|
+
|| new Set(request.availableModels.map(m => m.id)).size !== request.availableModels.length) {
|
|
23
|
+
throw new Error('Provide the destination host model/effort catalog; never infer availability.');
|
|
24
|
+
}
|
|
25
|
+
const reviewRole = type.endsWith('review') || type.endsWith('audit');
|
|
26
|
+
let profile;
|
|
27
|
+
if (request.highRisk || request.complexity === 'complex') profile = 'complex';
|
|
28
|
+
else if (type === 'step-review' && request.complexity === 'routine') profile = 'scoped-review';
|
|
29
|
+
else if (reviewRole || request.phase === 'review') profile = 'review';
|
|
30
|
+
else if (request.phase === 'planning') profile = 'planning';
|
|
31
|
+
else if (request.phase === 'mechanical') {
|
|
32
|
+
if (request.complexity !== 'routine') throw new Error('Mechanical phase requires routine complexity.');
|
|
33
|
+
profile = 'mechanical';
|
|
34
|
+
} else if (type === 'coordinator' || request.phase === 'coordination' || request.complexity === 'routine') profile = 'scoped';
|
|
35
|
+
else profile = 'implementation';
|
|
36
|
+
const selected = PROFILES[profile].find(([id, effort]) => request.availableModels.some(m => m.id === id && m.reasoningEfforts.includes(effort)));
|
|
37
|
+
if (!selected) throw new Error(`No supported model/effort for ${profile}; explicit policy revision required, never inherit coordinator settings.`);
|
|
38
|
+
return {
|
|
39
|
+
policyVersion: MODEL_POLICY_VERSION, profile, model: selected[0], thinking: selected[1],
|
|
40
|
+
phase: request.phase, complexity: request.complexity, highRisk: request.highRisk,
|
|
41
|
+
rationale: request.rationale.trim(), fallback: selected !== PROFILES[profile][0],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -12,6 +12,7 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import process from 'node:process';
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { dispatchAction, dispatchInput } from './chat-dispatch.mjs';
|
|
15
16
|
|
|
16
17
|
const TYPES = new Set(['coordinator', 'task', 'step-review', 'final-review', 'corrective-audit', 'plan-audit']);
|
|
17
18
|
const LOCK_STALE_MS = 30_000;
|
|
@@ -25,6 +26,14 @@ export function allocateChat(options) {
|
|
|
25
26
|
const sequence = registry.nextSequence;
|
|
26
27
|
const reservationId = randomUUID();
|
|
27
28
|
const candidates = buildTitleCandidates({ ...options, sequence });
|
|
29
|
+
const existing = registry.entries.find(entry => entry.status !== 'abandoned'
|
|
30
|
+
&& entry.type === options.type && entry.entityId === options.entityId
|
|
31
|
+
&& entry.milestoneOrdinal === requiredOrdinal(options.milestoneOrdinal, 'M')
|
|
32
|
+
&& entry.taskOrdinal === optionalOrdinal(options.taskOrdinal, 'T')
|
|
33
|
+
&& entry.stepOrdinal === optionalOrdinal(options.stepOrdinal, 'S')
|
|
34
|
+
&& (['task', 'coordinator'].includes(options.type)
|
|
35
|
+
|| entry.attempt === positiveInteger(options.attempt ?? 1, 'attempt')));
|
|
36
|
+
if (existing) return { ...publicEntry(existing), reused: true };
|
|
28
37
|
const entry = {
|
|
29
38
|
sequence,
|
|
30
39
|
reservationId,
|
|
@@ -92,6 +101,17 @@ export function bindChat(options) {
|
|
|
92
101
|
const registry = readRegistry(file, options.projectId);
|
|
93
102
|
const entry = requireEntry(registry, options.reservationId);
|
|
94
103
|
if (!entry.titleVerified) throw new Error('Chat title must pass exact readback before binding.');
|
|
104
|
+
if (options.threadId?.startsWith('client-')) throw new Error('Cannot bind a pending clientThreadId.');
|
|
105
|
+
if (entry.threadId && (entry.threadId !== options.threadId || entry.hostId !== options.hostId)) {
|
|
106
|
+
throw new Error('Reservation already bound to another thread.');
|
|
107
|
+
}
|
|
108
|
+
if (registry.entries.some(other => other !== entry && other.threadId === options.threadId
|
|
109
|
+
&& other.hostId === options.hostId)) throw new Error('Thread already belongs to another reservation.');
|
|
110
|
+
if (entry.dispatch && (entry.dispatch.state !== 'resolved' || entry.dispatch.candidateThreadId !== options.threadId
|
|
111
|
+
|| entry.dispatch.hostId !== options.hostId
|
|
112
|
+
|| entry.dispatch.observations.at(-1)?.title !== entry.observedTitle)) {
|
|
113
|
+
throw new Error('Tracked creation must pass correlated read_thread observation before binding.');
|
|
114
|
+
}
|
|
95
115
|
entry.threadId = requiredText(options.threadId, 'threadId');
|
|
96
116
|
entry.hostId = requiredText(options.hostId, 'hostId');
|
|
97
117
|
entry.status = 'bound';
|
|
@@ -106,6 +126,9 @@ export function retitleChat(options) {
|
|
|
106
126
|
return withRegistryLock(options.projectId, options.registryHome, (file) => {
|
|
107
127
|
const registry = readRegistry(file, options.projectId);
|
|
108
128
|
const entry = requireEntry(registry, options.reservationId);
|
|
129
|
+
if (entry.dispatch && options.entityId !== entry.entityId) {
|
|
130
|
+
throw new Error('Tracked dispatch entity is immutable; retitle cannot replace its identity.');
|
|
131
|
+
}
|
|
109
132
|
entry.entityId = requiredText(options.entityId, 'entityId');
|
|
110
133
|
entry.semanticTitle = requiredText(options.semanticTitle, 'semanticTitle');
|
|
111
134
|
entry.candidates = buildTitleCandidates({
|
|
@@ -134,6 +157,7 @@ export function abandonChat(options) {
|
|
|
134
157
|
return withRegistryLock(options.projectId, options.registryHome, (file) => {
|
|
135
158
|
const registry = readRegistry(file, options.projectId);
|
|
136
159
|
const entry = requireEntry(registry, options.reservationId);
|
|
160
|
+
if (entry.dispatch || entry.threadId) throw new Error('Cannot abandon a started creation or bound chat to enable a duplicate.');
|
|
137
161
|
entry.status = 'abandoned';
|
|
138
162
|
entry.updatedAt = new Date().toISOString();
|
|
139
163
|
writeRegistry(file, registry);
|
|
@@ -141,6 +165,25 @@ export function abandonChat(options) {
|
|
|
141
165
|
});
|
|
142
166
|
}
|
|
143
167
|
|
|
168
|
+
export function dispatchChat(options) {
|
|
169
|
+
validateProjectId(options.projectId);
|
|
170
|
+
return withRegistryLock(options.projectId, options.registryHome, file => {
|
|
171
|
+
const registry = readRegistry(file, options.projectId);
|
|
172
|
+
const entry = requireEntry(registry, options.reservationId);
|
|
173
|
+
if (options.action === 'dispatch-handoff-reconcile') {
|
|
174
|
+
const destination = options.input?.observation?.thread;
|
|
175
|
+
if (registry.entries.some(other => other !== entry && other.threadId === destination?.id
|
|
176
|
+
&& other.hostId === destination?.hostId)) throw new Error('Handoff destination already belongs to another reservation.');
|
|
177
|
+
}
|
|
178
|
+
const result = dispatchAction(options.action, entry, options.input);
|
|
179
|
+
if (!['dispatch-status', 'dispatch-context-check'].includes(options.action) && result.idempotentRetry !== true) {
|
|
180
|
+
entry.updatedAt = new Date().toISOString();
|
|
181
|
+
writeRegistry(file, registry);
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
144
187
|
export function showRegistry(options) {
|
|
145
188
|
validateProjectId(options.projectId);
|
|
146
189
|
const file = registryFile(options.projectId, options.registryHome);
|
|
@@ -217,7 +260,10 @@ function withRegistryLock(projectId, registryHome, operation) {
|
|
|
217
260
|
|
|
218
261
|
function lockIsStale(lock) {
|
|
219
262
|
try {
|
|
220
|
-
|
|
263
|
+
if (Date.now() - statSync(lock).mtimeMs <= LOCK_STALE_MS) return false;
|
|
264
|
+
const owner = JSON.parse(readFileSync(path.join(lock, 'owner.json'), 'utf8'));
|
|
265
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1) return false;
|
|
266
|
+
try { process.kill(owner.pid, 0); return false; } catch (error) { return error?.code === 'ESRCH'; }
|
|
221
267
|
} catch {
|
|
222
268
|
return false;
|
|
223
269
|
}
|
|
@@ -253,7 +299,7 @@ function readRegistry(file, projectId) {
|
|
|
253
299
|
|
|
254
300
|
function writeRegistry(file, registry) {
|
|
255
301
|
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
256
|
-
writeFileSync(temporary, `${JSON.stringify(registry, null, 2)}\n`, { flag: 'wx' });
|
|
302
|
+
writeFileSync(temporary, `${JSON.stringify(registry, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
257
303
|
renameSync(temporary, file);
|
|
258
304
|
}
|
|
259
305
|
|
|
@@ -275,6 +321,7 @@ function publicEntry(entry) {
|
|
|
275
321
|
threadId: entry.threadId,
|
|
276
322
|
hostId: entry.hostId,
|
|
277
323
|
status: entry.status,
|
|
324
|
+
dispatchState: entry.dispatch?.state ?? null,
|
|
278
325
|
};
|
|
279
326
|
}
|
|
280
327
|
|
|
@@ -334,6 +381,11 @@ function cli() {
|
|
|
334
381
|
projectId: option(args, 'project-id'),
|
|
335
382
|
registryHome: option(args, 'registry-home'),
|
|
336
383
|
};
|
|
384
|
+
if (action?.startsWith('dispatch-') || action === 'supervision-record' || action === 'model-select') {
|
|
385
|
+
const file = option(args, 'file');
|
|
386
|
+
return dispatchChat({ ...common, action, reservationId: option(args, 'reservation-id'),
|
|
387
|
+
input: file ? dispatchInput(file) : {} });
|
|
388
|
+
}
|
|
337
389
|
if (action === 'allocate') return allocateChat({
|
|
338
390
|
...common,
|
|
339
391
|
type: option(args, 'type'),
|
|
@@ -363,7 +415,7 @@ function cli() {
|
|
|
363
415
|
});
|
|
364
416
|
if (action === 'abandon') return abandonChat({ ...common, reservationId: option(args, 'reservation-id') });
|
|
365
417
|
if (action === 'show') return showRegistry(common);
|
|
366
|
-
throw new Error('Use allocate, retitle, readback, bind, abandon, or
|
|
418
|
+
throw new Error('Use allocate, retitle, readback, bind, abandon, show, dispatch-begin/result/discover/resolve-duplicates/observe/status, supervision-record, or model-select.');
|
|
367
419
|
}
|
|
368
420
|
|
|
369
421
|
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
|