@zq-silk/yui 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -9
- package/dist/cli/commandCatalog.js +5 -5
- package/dist/cli.js +5 -3
- package/dist/commands/agentCommands.js +13 -6
- package/dist/commands/globalRoleCommands.js +11 -3
- package/dist/commands/roleConfiguration.js +7 -0
- package/dist/commands/roleRuntimeGuard.js +30 -0
- package/dist/commands/taskCommands.js +10 -3
- package/dist/context/sessionBootstrapManifest.js +24 -9
- package/dist/controller/fileSchedulerStoreAdapter.js +4 -4
- package/dist/controller/jobClient.js +1 -0
- package/dist/controller/jobControl.js +137 -10
- package/dist/controller/jobSupervisor.js +89 -75
- package/dist/controller/runtime.js +19 -28
- package/dist/controller/runtimeLaunchCoordinator.js +9 -30
- package/dist/controller/sessionNotify.js +5 -0
- package/dist/core/boundedRpc.js +3 -1
- package/dist/executor/agentExecutor.js +8 -11
- package/dist/executor/effectiveLaunch.js +34 -17
- package/dist/executor/fileRoleLaunchPlanner.js +11 -8
- package/dist/job/durableJob.js +68 -6
- package/dist/kernel/callAuthority.js +24 -0
- package/dist/kernel/instanceHost.js +97 -0
- package/dist/kernel/kernelPorts.js +44 -0
- package/dist/kernel/operationFacts.js +32 -0
- package/dist/runtime/agentHost.js +7 -0
- package/dist/runtime/codexInteractiveHost.js +191 -0
- package/dist/runtime/exactControlPlane.js +8 -10
- package/dist/runtime/structuredProviderHost.js +35 -0
- package/dist/runtime/tmuxAdapters.js +51 -9
- package/dist/scheduler/activeRoleTurnDelivery.js +4 -4
- package/dist/scheduler/leaderWakeupProcessor.js +3 -4
- package/dist/storage/sqliteSchema.js +28 -0
- package/dist/storage/sqliteStore.js +11 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -1
- package/dist/storage/taskStore.js +6 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +3 -0
- package/dist/tmux/tmuxManager.js +43 -28
- package/i18n/README.zh-CN.md +7 -0
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import { persistRuntimeProcessExitObservation, replayRuntimeProcessExitOutbox }
|
|
|
17
17
|
import { readRuntimeStopReceipt, removeRuntimeStopReceipt } from "./runtimeStopReceipt.js";
|
|
18
18
|
import { AGENT_HOST_CONTROL_TIMEOUT_MS, AGENT_HOST_READY_TIMEOUT_MS } from "./runtimeDeadlines.js";
|
|
19
19
|
import { serializeAgentErrorRaw } from "./agentError.js";
|
|
20
|
+
import { runCodexInteractiveHost } from "./codexInteractiveHost.js";
|
|
20
21
|
export const AGENT_HOST_CONTROL_PROTOCOL = "yui-agent-host/v4";
|
|
21
22
|
const HOST_CONTROL_MAX_BYTES = 32 * 1024;
|
|
22
23
|
const CODEX_CLIENT_STABLE_MS = 5_000;
|
|
@@ -28,6 +29,11 @@ export async function runAgentHost(input) {
|
|
|
28
29
|
const hostInstanceId = randomUUID();
|
|
29
30
|
let hostSequence = 0;
|
|
30
31
|
let payload = await redeem(input.home, input.runtimeGenerationId, input.ticket);
|
|
32
|
+
if (payload.environment.YUI_SESSION_SCOPE === "global"
|
|
33
|
+
&& payload.environment.YUI_ADAPTER_ID === "codex"
|
|
34
|
+
&& payload.providerControl === undefined) {
|
|
35
|
+
return runCodexInteractiveHost(input.home, payload);
|
|
36
|
+
}
|
|
31
37
|
let session;
|
|
32
38
|
let sessionPayload;
|
|
33
39
|
let activeTurnPayload;
|
|
@@ -882,6 +888,7 @@ export async function waitForAgentHostLaunchAck(input) {
|
|
|
882
888
|
const code = error.code;
|
|
883
889
|
if (code !== "ENOENT" && code !== "ECONNREFUSED")
|
|
884
890
|
throw error;
|
|
891
|
+
await input.assertHostRunning?.();
|
|
885
892
|
}
|
|
886
893
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
887
894
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
5
|
+
import { AGENT_HOST_CONTROL_PROTOCOL, openAgentHostControl } from "./agentHost.js";
|
|
6
|
+
import { openCodexInteractiveConnection } from "./structuredProviderHost.js";
|
|
7
|
+
/**
|
|
8
|
+
* Keep the native TUI and its transparent App Server attachment in one pane.
|
|
9
|
+
* The existing Host acknowledgement carries the ID from that TUI's exact
|
|
10
|
+
* thread/start or thread/resume response, before any user/model Turn.
|
|
11
|
+
*
|
|
12
|
+
* Codex 0.150.1 cannot resume a pre-created empty Thread: no rollout exists
|
|
13
|
+
* until its first message. Observing the TUI's own startup avoids creating a
|
|
14
|
+
* second Thread or manufacturing a bootstrap message to materialize history.
|
|
15
|
+
*/
|
|
16
|
+
export async function runCodexInteractiveHost(home, payload) {
|
|
17
|
+
const environment = payload.environment;
|
|
18
|
+
if (environment.YUI_SESSION_SCOPE !== "global" || environment.YUI_ADAPTER_ID !== "codex"
|
|
19
|
+
|| payload.providerControl !== undefined) {
|
|
20
|
+
throw new Error("Interactive Codex Host requires a global native TUI launch.");
|
|
21
|
+
}
|
|
22
|
+
const remoteIndex = payload.args.indexOf("--remote");
|
|
23
|
+
if (remoteIndex < 0 || payload.args[remoteIndex + 1] !== "unix://") {
|
|
24
|
+
throw new Error("Global Codex must target the default shared daemon.");
|
|
25
|
+
}
|
|
26
|
+
const baseArgs = JSON.parse(environment.YUI_AGENT_BASE_ARGS ?? "[]");
|
|
27
|
+
if (!Array.isArray(baseArgs) || baseArgs.some((arg) => typeof arg !== "string")) {
|
|
28
|
+
throw new Error("Codex proxy base arguments are invalid.");
|
|
29
|
+
}
|
|
30
|
+
const expectedId = environment.YUI_NATIVE_SESSION_ID;
|
|
31
|
+
let snapshot = {
|
|
32
|
+
schemaVersion: 2, state: "starting", adapterId: "codex",
|
|
33
|
+
runtimeGenerationId: payload.runtimeGenerationId, updatedAt: new Date().toISOString()
|
|
34
|
+
};
|
|
35
|
+
const control = await openAgentHostControl(home, payload, () => snapshot, async (request) => {
|
|
36
|
+
if (request.type !== "status") {
|
|
37
|
+
throw new Error("Global Codex uses its native TUI; stop it before switching Sessions.");
|
|
38
|
+
}
|
|
39
|
+
return { protocol: AGENT_HOST_CONTROL_PROTOCOL, outcome: "status", snapshot };
|
|
40
|
+
});
|
|
41
|
+
let child;
|
|
42
|
+
let client;
|
|
43
|
+
let closing = false;
|
|
44
|
+
let startupRequestId;
|
|
45
|
+
let connection;
|
|
46
|
+
let relay;
|
|
47
|
+
const signals = ["SIGTERM", "SIGHUP", "SIGINT"];
|
|
48
|
+
const stop = () => { child?.kill("SIGTERM"); };
|
|
49
|
+
const fail = (error) => {
|
|
50
|
+
if (closing || snapshot.state === "failed")
|
|
51
|
+
return;
|
|
52
|
+
snapshot = {
|
|
53
|
+
...snapshot, state: "failed",
|
|
54
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
55
|
+
updatedAt: new Date().toISOString()
|
|
56
|
+
};
|
|
57
|
+
process.stderr.write(`Yui Codex attachment failed: ${snapshot.detail}\n`);
|
|
58
|
+
client?.terminate();
|
|
59
|
+
stop();
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
// This proxy is a disposable client, not the daemon. No Yui initialization
|
|
63
|
+
// or Thread request is injected; all requests below belong to the TUI.
|
|
64
|
+
connection = await openCodexInteractiveConnection({
|
|
65
|
+
command: payload.command, args: [...baseArgs, "app-server", "proxy"],
|
|
66
|
+
environment, cwd: payload.cwd
|
|
67
|
+
});
|
|
68
|
+
connection.onClose(fail);
|
|
69
|
+
const token = randomBytes(32).toString("hex");
|
|
70
|
+
relay = new WebSocketServer({
|
|
71
|
+
host: "127.0.0.1", port: 0,
|
|
72
|
+
maxPayload: 16 * 1024 * 1024, perMessageDeflate: false,
|
|
73
|
+
verifyClient: (info) => info.req.headers.authorization === `Bearer ${token}`
|
|
74
|
+
});
|
|
75
|
+
relay.on("error", fail);
|
|
76
|
+
relay.on("connection", (socket) => {
|
|
77
|
+
if (client !== undefined || closing) {
|
|
78
|
+
socket.close(1008, "This attachment already has its native TUI.");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
client = socket;
|
|
82
|
+
socket.on("error", fail);
|
|
83
|
+
socket.on("close", () => {
|
|
84
|
+
if (!closing) {
|
|
85
|
+
closing = true;
|
|
86
|
+
connection?.close();
|
|
87
|
+
stop();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
socket.on("message", (data, binary) => {
|
|
91
|
+
try {
|
|
92
|
+
if (binary)
|
|
93
|
+
throw new Error("Codex TUI sent a binary App Server request.");
|
|
94
|
+
const message = jsonObject(JSON.parse(data.toString()));
|
|
95
|
+
if (snapshot.nativeSessionId === undefined
|
|
96
|
+
&& (message.method === "thread/start" || message.method === "thread/resume")) {
|
|
97
|
+
if (startupRequestId !== undefined)
|
|
98
|
+
throw new Error("Codex sent overlapping startup requests.");
|
|
99
|
+
if (typeof message.id !== "string" && typeof message.id !== "number") {
|
|
100
|
+
throw new Error("Codex startup request has no correlation id.");
|
|
101
|
+
}
|
|
102
|
+
const params = jsonObject(message.params);
|
|
103
|
+
if (expectedId === undefined ? message.method !== "thread/start"
|
|
104
|
+
: message.method !== "thread/resume" || params.threadId !== expectedId) {
|
|
105
|
+
throw new Error("Codex TUI startup does not match the reserved Session.");
|
|
106
|
+
}
|
|
107
|
+
startupRequestId = message.id;
|
|
108
|
+
}
|
|
109
|
+
void connection.send(message).catch(fail);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
fail(error);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
connection.onMessage((message) => {
|
|
117
|
+
try {
|
|
118
|
+
let nativeSessionId;
|
|
119
|
+
if (startupRequestId !== undefined && message.id === startupRequestId
|
|
120
|
+
&& message.method === undefined) {
|
|
121
|
+
if (message.error !== undefined) {
|
|
122
|
+
throw new Error(`Codex startup failed: ${JSON.stringify(message.error)}`);
|
|
123
|
+
}
|
|
124
|
+
const id = jsonObject(jsonObject(message.result).thread).id;
|
|
125
|
+
if (typeof id !== "string" || id.length === 0 || id.trim() !== id || id.includes("\0")
|
|
126
|
+
|| (expectedId !== undefined && id !== expectedId)) {
|
|
127
|
+
throw new Error("Codex startup returned an invalid or mismatched Thread identity.");
|
|
128
|
+
}
|
|
129
|
+
nativeSessionId = id;
|
|
130
|
+
}
|
|
131
|
+
if (client?.readyState !== WebSocket.OPEN)
|
|
132
|
+
throw new Error("Codex TUI attachment is closed.");
|
|
133
|
+
client.send(JSON.stringify(message), (error) => {
|
|
134
|
+
if (error) {
|
|
135
|
+
fail(error);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (nativeSessionId === undefined || snapshot.state === "failed")
|
|
139
|
+
return;
|
|
140
|
+
startupRequestId = undefined;
|
|
141
|
+
snapshot = {
|
|
142
|
+
...snapshot, state: "ready", nativeSessionId, conversationId: nativeSessionId,
|
|
143
|
+
updatedAt: new Date().toISOString()
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
fail(error);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
await once(relay, "listening");
|
|
152
|
+
if (snapshot.state === "failed")
|
|
153
|
+
throw new Error(snapshot.detail);
|
|
154
|
+
const address = relay.address();
|
|
155
|
+
if (typeof address !== "object" || address === null)
|
|
156
|
+
throw new Error("Codex TUI relay did not bind.");
|
|
157
|
+
const args = [...payload.args];
|
|
158
|
+
args[remoteIndex + 1] = `ws://127.0.0.1:${address.port}`;
|
|
159
|
+
args.splice(remoteIndex + 2, 0, "--remote-auth-token-env", "YUI_CODEX_REMOTE_AUTH_TOKEN");
|
|
160
|
+
child = spawn(payload.command, args, {
|
|
161
|
+
cwd: payload.cwd,
|
|
162
|
+
env: { ...environment, YUI_CODEX_REMOTE_AUTH_TOKEN: token },
|
|
163
|
+
stdio: "inherit"
|
|
164
|
+
});
|
|
165
|
+
for (const signal of signals)
|
|
166
|
+
process.on(signal, stop);
|
|
167
|
+
const [code] = await once(child, "exit");
|
|
168
|
+
return typeof code === "number" ? code : 1;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
fail(error);
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
closing = true;
|
|
176
|
+
for (const signal of signals)
|
|
177
|
+
process.removeListener(signal, stop);
|
|
178
|
+
stop();
|
|
179
|
+
client?.terminate();
|
|
180
|
+
connection?.close();
|
|
181
|
+
if (relay !== undefined)
|
|
182
|
+
await new Promise((done) => relay.close(() => done()));
|
|
183
|
+
await control.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function jsonObject(value) {
|
|
187
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
188
|
+
throw new Error("Invalid Codex App Server object.");
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
@@ -44,14 +44,6 @@ export function parseExactControlPlaneDescriptor(value) {
|
|
|
44
44
|
export function exactControlPlaneDigest(descriptor) {
|
|
45
45
|
return createHash("sha256").update(serializeExactDescriptor(descriptor)).digest("hex");
|
|
46
46
|
}
|
|
47
|
-
export function exactControlPlaneCommandPrefix(descriptor) {
|
|
48
|
-
return [
|
|
49
|
-
descriptor.executable,
|
|
50
|
-
descriptor.cliEntry,
|
|
51
|
-
EXACT_CONTROL_ARGUMENT,
|
|
52
|
-
exactControlPlaneDigest(descriptor)
|
|
53
|
-
].map(shellQuote).join(" ");
|
|
54
|
-
}
|
|
55
47
|
export function extractExactControlArgument(args) {
|
|
56
48
|
const later = args.indexOf(EXACT_CONTROL_ARGUMENT);
|
|
57
49
|
if (later < 0)
|
|
@@ -80,8 +72,14 @@ export function extractExactControlArgument(args) {
|
|
|
80
72
|
*/
|
|
81
73
|
export async function assertExactControlPlanePreflight(input, options = {}) {
|
|
82
74
|
const descriptor = parseExactControlPlaneDescriptor(input.serializedDescriptor);
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
const frozenDigest = exactControlPlaneDigest(descriptor);
|
|
76
|
+
const requestedDigest = requireDigest(input.digest);
|
|
77
|
+
if (frozenDigest !== requestedDigest) {
|
|
78
|
+
throw new Error("Exact control-plane invocation names another runtime than this Session's frozen "
|
|
79
|
+
+ `descriptor (requested ${requestedDigest}, Session ${frozenDigest}). `
|
|
80
|
+
+ "Yui no longer pins package identity into a Session entry point: invoke the "
|
|
81
|
+
+ "ordinary command for this Session, or start a new Session when the Session "
|
|
82
|
+
+ "itself must move to a different runtime.");
|
|
85
83
|
}
|
|
86
84
|
assertSamePath(descriptor.executable, input.actualExecutable, "Control-plane executable");
|
|
87
85
|
assertSamePath(descriptor.cliEntry, input.actualCliEntry, "Control-plane CLI entry");
|
|
@@ -78,11 +78,32 @@ export async function startStructuredProviderSession(payload, input = {}) {
|
|
|
78
78
|
throw error;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Open an uninitialized, transparent connection for a native TUI. The TUI
|
|
83
|
+
* owns its requests; Yui observes only its exact startup response.
|
|
84
|
+
*/
|
|
85
|
+
export async function openCodexInteractiveConnection(launch) {
|
|
86
|
+
const child = spawn(launch.command, [...launch.args], {
|
|
87
|
+
cwd: launch.cwd,
|
|
88
|
+
env: { ...launch.environment },
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
+
detached: true
|
|
91
|
+
});
|
|
92
|
+
child.stderr.resume();
|
|
93
|
+
try {
|
|
94
|
+
return await CodexProxyWebSocketChannel.connect(child, () => { });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
terminateProcessGroup(child, "SIGTERM");
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
81
101
|
class CodexProxyWebSocketChannel {
|
|
82
102
|
child;
|
|
83
103
|
mirror;
|
|
84
104
|
#pending = new Map();
|
|
85
105
|
#listeners = new Set();
|
|
106
|
+
#closeListeners = new Set();
|
|
86
107
|
#nextId = 1;
|
|
87
108
|
#closedError;
|
|
88
109
|
#ready = false;
|
|
@@ -127,6 +148,17 @@ class CodexProxyWebSocketChannel {
|
|
|
127
148
|
this.#listeners.add(listener);
|
|
128
149
|
return () => this.#listeners.delete(listener);
|
|
129
150
|
}
|
|
151
|
+
onClose(listener) {
|
|
152
|
+
if (this.#closedError !== undefined)
|
|
153
|
+
listener(this.#closedError);
|
|
154
|
+
else
|
|
155
|
+
this.#closeListeners.add(listener);
|
|
156
|
+
return () => this.#closeListeners.delete(listener);
|
|
157
|
+
}
|
|
158
|
+
close() {
|
|
159
|
+
this.#webSocket.terminate();
|
|
160
|
+
this.#close(new Error("Codex App Server proxy client closed."));
|
|
161
|
+
}
|
|
130
162
|
async request(method, params) {
|
|
131
163
|
await this.#readyPromise;
|
|
132
164
|
if (this.#closedError !== undefined)
|
|
@@ -227,6 +259,9 @@ class CodexProxyWebSocketChannel {
|
|
|
227
259
|
pending.reject(error);
|
|
228
260
|
}
|
|
229
261
|
this.#pending.clear();
|
|
262
|
+
for (const listener of this.#closeListeners)
|
|
263
|
+
listener(error);
|
|
264
|
+
this.#closeListeners.clear();
|
|
230
265
|
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
231
266
|
terminateProcessGroup(this.child, "SIGTERM");
|
|
232
267
|
}
|
|
@@ -210,6 +210,23 @@ export class TmuxSessionHost {
|
|
|
210
210
|
throw toRuntimeLaunchFailure(error, "validation", launchContext);
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
const interactiveCodex = request.owner.scope === "global" && request.adapterId === "codex";
|
|
214
|
+
let reuseInteractivePane = false;
|
|
215
|
+
if (interactiveCodex) {
|
|
216
|
+
let status;
|
|
217
|
+
try {
|
|
218
|
+
status = await probeRoleStatus(this.tmux, hostId, request.owner.roleName);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
throw new RuntimeHostContentionError("previous-process", `Global Role pane state is unknown; preserving it: ${error instanceof Error ? error.message : String(error)}`);
|
|
222
|
+
}
|
|
223
|
+
if (status === "running") {
|
|
224
|
+
if (request.mode === "new") {
|
|
225
|
+
throw new RuntimeHostContentionError("previous-process", "Global Role already has a live pane; stop or record its exact Session first.");
|
|
226
|
+
}
|
|
227
|
+
reuseInteractivePane = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
213
230
|
const plannedNativeSessionId = planned.session?.nativeSessionId;
|
|
214
231
|
if (request.mode === "resume"
|
|
215
232
|
&& plannedNativeSessionId !== undefined
|
|
@@ -232,23 +249,26 @@ export class TmuxSessionHost {
|
|
|
232
249
|
});
|
|
233
250
|
const yuiHome = planned.launch.env.YUI_HOME;
|
|
234
251
|
const childLifecycle = planned.launch.childLifecycle;
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
252
|
+
// Global Codex retains the native TUI inside a thin Host that acknowledges
|
|
253
|
+
// its own App Server startup response. Existing live TUIs (including
|
|
254
|
+
// 0.15.0 Sessions) remain directly attachable without replacing them.
|
|
255
|
+
if (reuseInteractivePane
|
|
256
|
+
|| (!interactiveCodex && (yuiHome === undefined
|
|
257
|
+
|| childLifecycle === undefined
|
|
258
|
+
|| planned.launch.providerControl === undefined))) {
|
|
242
259
|
if (request.owner.scope === "task" && request.turnId !== undefined) {
|
|
243
260
|
throw new Error("Managed Task Turn is missing its structured Agent Host contract.");
|
|
244
261
|
}
|
|
245
262
|
let hostCreated;
|
|
246
263
|
try {
|
|
247
|
-
hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, planned.launch);
|
|
264
|
+
hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, reuseInteractivePane ? undefined : planned.launch);
|
|
248
265
|
}
|
|
249
266
|
catch (error) {
|
|
250
267
|
throw toRuntimeLaunchFailure(error, "host-start", launchContext);
|
|
251
268
|
}
|
|
269
|
+
if (reuseInteractivePane && hostCreated) {
|
|
270
|
+
throw new Error("Global Role pane changed during attachment.");
|
|
271
|
+
}
|
|
252
272
|
let binding = createRuntimeBinding({
|
|
253
273
|
id: bindingId,
|
|
254
274
|
runtimeGenerationId: request.runtimeGenerationId,
|
|
@@ -282,6 +302,9 @@ export class TmuxSessionHost {
|
|
|
282
302
|
}
|
|
283
303
|
return binding;
|
|
284
304
|
}
|
|
305
|
+
if (yuiHome === undefined || childLifecycle === undefined) {
|
|
306
|
+
throw new Error("Agent Host launch is missing its Home or child lifecycle.");
|
|
307
|
+
}
|
|
285
308
|
const broker = launchBrokerForHome(yuiHome);
|
|
286
309
|
const sessionManifest = planned.launch.env.YUI_SESSION_MANIFEST;
|
|
287
310
|
const frozenControlPlane = planned.launch.env[YUI_CONTROL_PLANE_DESCRIPTOR];
|
|
@@ -341,17 +364,36 @@ export class TmuxSessionHost {
|
|
|
341
364
|
try {
|
|
342
365
|
hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, hostLaunch);
|
|
343
366
|
if (hostCreated && planned.launch.deferProviderStart !== true) {
|
|
367
|
+
const assertInteractivePane = async () => {
|
|
368
|
+
const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
|
|
369
|
+
if (pane === undefined)
|
|
370
|
+
throw new Error("Global Codex startup has no observable pane state.");
|
|
371
|
+
if (pane.dead) {
|
|
372
|
+
await deadHostLaunchFailure(this.tmux, hostId, request.owner.roleName, pane, launchContext);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
344
375
|
providerSnapshot = await waitForAgentHostLaunchAck({
|
|
345
376
|
home: yuiHome,
|
|
346
377
|
scope: request.owner.scope,
|
|
347
378
|
...(request.owner.scope === "task" ? { taskId: request.owner.taskId } : {}),
|
|
348
379
|
roleName: request.owner.roleName,
|
|
349
380
|
runtimeGenerationId: reservation.runtimeGenerationId,
|
|
350
|
-
requireTurnAck: false
|
|
381
|
+
requireTurnAck: false,
|
|
382
|
+
...(interactiveCodex ? { assertHostRunning: assertInteractivePane } : {})
|
|
351
383
|
});
|
|
384
|
+
if (interactiveCodex) {
|
|
385
|
+
if (providerSnapshot.nativeSessionId === undefined) {
|
|
386
|
+
throw new Error("Global Codex startup acknowledgement has no Thread identity.");
|
|
387
|
+
}
|
|
388
|
+
requireSafeIdentity(providerSnapshot.nativeSessionId, "Codex Thread identity");
|
|
389
|
+
await assertInteractivePane();
|
|
390
|
+
}
|
|
352
391
|
providerDispatchObserved = true;
|
|
353
392
|
}
|
|
354
393
|
if (!hostCreated) {
|
|
394
|
+
if (interactiveCodex) {
|
|
395
|
+
throw new RuntimeHostContentionError("previous-process", "Global Role pane appeared during launch; preserving its existing Session.");
|
|
396
|
+
}
|
|
355
397
|
const controlResult = await sendAgentHostLaunchControl({
|
|
356
398
|
home: yuiHome,
|
|
357
399
|
scope: request.owner.scope,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { serializeTurnInputEnvelope } from "../context/turnInputContract.js";
|
|
2
|
-
import {
|
|
2
|
+
import { roleSessionMayContinue, sameEffectiveLaunch } from "../executor/effectiveLaunch.js";
|
|
3
3
|
import { RuntimeLifecycleBusyError } from "../runtime/lifecycleReservation.js";
|
|
4
4
|
import { managedProviderTurnId } from "../runtime/providerRuntimeIdentity.js";
|
|
5
5
|
import { serializeAgentErrorRaw } from "../runtime/agentError.js";
|
|
@@ -231,8 +231,8 @@ function validateRoleSession(role, turn, existing, mode, session) {
|
|
|
231
231
|
throw new Error(`Ready Role session identity changed: ${role.taskId}/${role.name}.`);
|
|
232
232
|
}
|
|
233
233
|
const compatible = mode === "resume"
|
|
234
|
-
?
|
|
235
|
-
:
|
|
234
|
+
? roleSessionMayContinue(session.effective, turn.effective)
|
|
235
|
+
: sameEffectiveLaunch(session.effective, turn.effective);
|
|
236
236
|
if (!compatible) {
|
|
237
237
|
throw new Error(`Ready Role session effective snapshot changed: ${role.taskId}/${role.name}.`);
|
|
238
238
|
}
|
|
@@ -249,7 +249,7 @@ function requireResumeSession(role, turn, session) {
|
|
|
249
249
|
if (session === null || !hasText(session.nativeSessionId)) {
|
|
250
250
|
throw new Error(`Role resume has no fixed native session: ${role.taskId}/${role.name}.`);
|
|
251
251
|
}
|
|
252
|
-
if (!
|
|
252
|
+
if (!roleSessionMayContinue(session.effective, turn.effective)) {
|
|
253
253
|
throw new Error(`Role resume effective snapshot drifted: ${role.taskId}/${role.name}.`);
|
|
254
254
|
}
|
|
255
255
|
return session.nativeSessionId;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createTurnInput } from "../context/turnInputContract.js";
|
|
2
|
-
import {
|
|
2
|
+
import { roleSessionMayContinue } from "../executor/effectiveLaunch.js";
|
|
3
3
|
import { roleAgentSessionResumeMode } from "../executor/agentExecutor.js";
|
|
4
4
|
import { createTurn } from "../turn/turn.js";
|
|
5
5
|
import { isSchedulerTaskWorkspaceReady } from "./ports.js";
|
|
@@ -63,9 +63,8 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
|
|
|
63
63
|
try {
|
|
64
64
|
const reopening = wakeup.reasons.includes("task-reopened");
|
|
65
65
|
const existingSession = store.getRoleSession(task.id, role.name, reopening ? undefined : role.effective.agentId);
|
|
66
|
-
const compatible = existingSession !== null
|
|
67
|
-
|
|
68
|
-
: effectiveLaunchSnapshotsCompatibleForTaskSession(existingSession.effective, role.effective));
|
|
66
|
+
const compatible = existingSession !== null
|
|
67
|
+
&& roleSessionMayContinue(existingSession.effective, role.effective);
|
|
69
68
|
if (hasNativeSession(existingSession)
|
|
70
69
|
&& existingSession.status === "active"
|
|
71
70
|
&& !compatible
|
|
@@ -660,6 +660,34 @@ const MIGRATIONS = Object.freeze([
|
|
|
660
660
|
name: "v0.15.0-baseline",
|
|
661
661
|
introducedIn: "0.15.0",
|
|
662
662
|
sql: MIGRATION_1_SQL
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
version: 2,
|
|
666
|
+
name: "job-operation-facts",
|
|
667
|
+
introducedIn: "0.15.2",
|
|
668
|
+
// Historical records never carried caller identity or external effect
|
|
669
|
+
// evidence. Preserve that uncertainty rather than inventing attribution.
|
|
670
|
+
// No old executable implementation or runtime dual-read is needed.
|
|
671
|
+
sql: `
|
|
672
|
+
UPDATE durable_jobs SET payload = json_set(payload,
|
|
673
|
+
'$.schemaVersion', 2,
|
|
674
|
+
'$.operation', json_object(
|
|
675
|
+
'requestId', json_extract(payload, '$.idempotencyKey'),
|
|
676
|
+
'inputDigest', json_extract(payload, '$.idempotencyKey'),
|
|
677
|
+
'actorId', 'historical:unrecorded',
|
|
678
|
+
'authorityRef', 'historical:unrecorded',
|
|
679
|
+
'targetId', json_extract(payload, '$.workspace'),
|
|
680
|
+
'capability', 'job.start',
|
|
681
|
+
'implementation', json_object('id', 'yui:job-runner', 'generation', '1'),
|
|
682
|
+
'effect', 'possible',
|
|
683
|
+
'receiptRefs', json('[]'),
|
|
684
|
+
'partialResultRefs', json('[]')
|
|
685
|
+
)
|
|
686
|
+
);
|
|
687
|
+
CREATE UNIQUE INDEX idx_durable_jobs_request
|
|
688
|
+
ON durable_jobs(task_id, json_extract(payload, '$.operation.actorId'),
|
|
689
|
+
json_extract(payload, '$.operation.requestId'));
|
|
690
|
+
`
|
|
663
691
|
}
|
|
664
692
|
]);
|
|
665
693
|
for (let index = 0; index < MIGRATIONS.length; index += 1) {
|
|
@@ -314,13 +314,18 @@ export class SqliteTaskStore {
|
|
|
314
314
|
* and the increment happen in the same write transaction.
|
|
315
315
|
*/
|
|
316
316
|
transactionWithRevisionCas(expectedRevision, execute, options) {
|
|
317
|
-
if (this.#inTransaction)
|
|
317
|
+
if (this.#inTransaction) {
|
|
318
|
+
const current = this.getRevision();
|
|
319
|
+
if (current !== expectedRevision) {
|
|
320
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`, current);
|
|
321
|
+
}
|
|
318
322
|
return execute(this);
|
|
323
|
+
}
|
|
319
324
|
this.#begin();
|
|
320
325
|
try {
|
|
321
326
|
const current = this.getRevision();
|
|
322
327
|
if (current !== expectedRevision) {
|
|
323
|
-
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current})
|
|
328
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${expectedRevision}, found ${current}).`, current);
|
|
324
329
|
}
|
|
325
330
|
const result = execute(this);
|
|
326
331
|
if (this.#dirty) {
|
|
@@ -363,6 +368,9 @@ export class SqliteTaskStore {
|
|
|
363
368
|
if (this.#inTransaction) {
|
|
364
369
|
// Nested inside a synchronous transaction: run without yielding (the
|
|
365
370
|
// caller already holds the write lock).
|
|
371
|
+
if (options.expectedRevision !== undefined && this.getRevision() !== options.expectedRevision) {
|
|
372
|
+
throw new StorageConflictError("Storage revision conflict.", this.getRevision());
|
|
373
|
+
}
|
|
366
374
|
return commands.map((command) => this.#executeCommand(command.op, command.args));
|
|
367
375
|
}
|
|
368
376
|
this.#begin();
|
|
@@ -372,7 +380,7 @@ export class SqliteTaskStore {
|
|
|
372
380
|
if (options.expectedRevision !== undefined) {
|
|
373
381
|
const current = this.getRevision();
|
|
374
382
|
if (current !== options.expectedRevision) {
|
|
375
|
-
throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current})
|
|
383
|
+
throw new StorageConflictError(`Storage revision conflict (expected ${options.expectedRevision}, found ${current}).`, current);
|
|
376
384
|
}
|
|
377
385
|
}
|
|
378
386
|
const results = [];
|
package/dist/storage/storeRpc.js
CHANGED
|
@@ -105,7 +105,7 @@ function isReadOnlyMethod(method) {
|
|
|
105
105
|
function deserializeError(serialized) {
|
|
106
106
|
const { name, message } = serialized;
|
|
107
107
|
if (name === "StorageConflictError")
|
|
108
|
-
return new StorageConflictError(message);
|
|
108
|
+
return new StorageConflictError(message, serialized.currentRevision);
|
|
109
109
|
if (name === "StorageRecordError")
|
|
110
110
|
return new StorageRecordError(message);
|
|
111
111
|
if (name === "StorageCancelledError" || name === "AbortError") {
|
|
@@ -72,7 +72,12 @@ export class StorageRecordError extends Error {
|
|
|
72
72
|
constructor(message) { super(message); this.name = "StorageRecordError"; }
|
|
73
73
|
}
|
|
74
74
|
export class StorageConflictError extends Error {
|
|
75
|
-
|
|
75
|
+
currentRevision;
|
|
76
|
+
constructor(message, currentRevision) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.currentRevision = currentRevision;
|
|
79
|
+
this.name = "StorageConflictError";
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
/**
|
|
78
83
|
* Raised by the persistence worker when an `AbortSignal` cancels an in-flight
|
|
@@ -7,6 +7,7 @@ import { validateAgentProfile } from "../../profile/agentProfile.js";
|
|
|
7
7
|
import { validateRoleSessionSet } from "../../executor/agentExecutor.js";
|
|
8
8
|
import { validateReviewRound } from "../../review/reviewRound.js";
|
|
9
9
|
import { validateTurn } from "../../turn/turn.js";
|
|
10
|
+
import { validateDurableJob } from "../../job/durableJob.js";
|
|
10
11
|
import { validateWorkItem } from "../../workItem/workItem.js";
|
|
11
12
|
import { SqliteTaskStore } from "../sqliteStore.js";
|
|
12
13
|
import { migrateSqliteSchema, storageMigrationPlan } from "../sqliteSchema.js";
|
|
@@ -191,6 +192,8 @@ function validateCurrentStore(home) {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
for (const taskId of store.listTasks().map(({ id }) => id)) {
|
|
195
|
+
for (const job of store.listDurableJobs(taskId))
|
|
196
|
+
validateDurableJob(job);
|
|
194
197
|
for (const item of store.listWorkItems(taskId))
|
|
195
198
|
validateWorkItem(item);
|
|
196
199
|
for (const round of store.listReviewRounds(taskId))
|