fix(codex): preserve native assignments across parent rotation (#160952)

* test(upgrade): isolate native assignment fixture lifecycle

* test(upgrade): use explicit fixture control flow

* test(upgrade): decode native fixture websocket frames

* fix(codex): preserve native assignments across parent rotation

* test(codex): cancel native rotation at the commit boundary

* test(codex): use fresh MCP fixture threads

* test(codex): remove redundant attestation call assertion

* test(codex): tighten rotation fixture types

* test(codex): return a valid workspace resume response

* test(codex): isolate schema lifecycle bindings

* test(codex): route assignment lifecycle through the database broker

* ci: refresh native assignment checks after main repairs

* ci: refresh native validation after parent-audit fixture repair

* ci: refresh native checks after duplicate route repair
This commit is contained in:
Peter Steinberger 2026-09-29 20:43:38 -07:00 • committed by GitHub
parent 7e9d57f290
commit 5367527c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1080 additions and 119 deletions

View file

@ -3259,18 +3259,17 @@ describe("runCodexAppServerAttempt", () => {
async (method) => {
if (method === "turn/start" && compactDuringAcceptance) {
compactDuringAcceptance = false;
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { type: "contextCompaction", id: "compact-during-start" },
},
});
await harness.notify(
itemNotification("item/completed", {
type: "contextCompaction",
id: "compact-during-start",
}),
);
}
if (method === "turn/start") {
turnRequested.resolve();
}
return method === "thread/resume" ? threadStartResult("thread-1") : undefined;
},
{ persistedThreads: [] },
);

View file

@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { threadStartResult as nativeThreadStartResult } from "./codex-app-server.test-fixtures.js";
import { createCodexTestHostCapabilities } from "./host-capability.test-support.js";
import type { CodexThreadStartParams } from "./protocol.js";
import { testCodexAppServerBindingStore } from "./session-binding.test-helpers.js";
import { createCodexTestBindingStore } from "./session-binding.test-helpers.js";
import { createCodexTestModel } from "./test-support.js";
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
import { createAppServerOptions as createBaseAppServerOptions } from "./thread-lifecycle.test-fixtures.js";
@ -20,10 +20,11 @@ import { createAppServerOptions as createBaseAppServerOptions } from "./thread-l
function startOrResumeThread(
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore">,
) {
return startOrResumeThreadImpl({ ...params, bindingStore: testCodexAppServerBindingStore });
return startOrResumeThreadImpl({ ...params, bindingStore });
}
let tempDir: string;
let bindingStore: ReturnType<typeof createCodexTestBindingStore>;
function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
return {
@ -66,6 +67,7 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n
describe("Codex app-server dynamic tool schema boundary contract", () => {
beforeEach(async () => {
bindingStore = createCodexTestBindingStore();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-schema-contract-"));
});

View file

@ -103,7 +103,7 @@ export async function withCodexThreadLifecycleBinding(
type PendingResumeContext = CodexThreadRequestContext & {
binding: CodexAppServerThreadBinding;
clearCurrentBinding: (operation: string) => Promise<void>;
stageBindingReplacement: (operation: string) => void;
releaseRetainedThread: (threadId: string, assertCurrent: () => void) => Promise<boolean>;
transientRestriction: boolean;
};

View file

@ -105,7 +105,7 @@ export async function resumeExistingCodexThread(
lifecycleTiming,
normalizeBindingModelProvider,
throwIfAborted,
clearCurrentBinding,
stageBindingReplacement,
} = context;
let acceptedConfiguration: CodexThreadResumePreparation | undefined;
let disposeConfiguration: (() => void) | undefined;
@ -413,7 +413,7 @@ export async function resumeExistingCodexThread(
embeddedAgentLog.warn("codex app-server thread resume failed; starting a new thread", {
error,
});
await clearCurrentBinding("rotating a stale thread binding");
stageBindingReplacement("rotating a stale thread binding");
} finally {
disposeConfiguration?.();
}

View file

@ -34,7 +34,6 @@ import {
prepareCodexThreadResume,
withCodexThreadLifecycleBinding,
} from "./thread-lifecycle-adoption.js";
import { CodexThreadBindingConflictError } from "./thread-lifecycle-errors.js";
import { resumeExistingCodexThread, startFreshCodexThread } from "./thread-lifecycle-io.js";
import {
buildCodexThreadBindingPolicy,
@ -234,26 +233,18 @@ export async function startOrResumeThread(
}),
);
}
const clearCurrentBinding = async (operation: string) => {
const stageBindingReplacement = (operation: string) => {
const current = binding;
if (!current?.threadId) {
return;
}
assertCodexBindingMayBeReplaced(current, operation, expectedOwnership);
const cleared = await params.bindingStore.mutate(
bindingIdentity,
{
kind: "clear",
threadId: current.threadId,
},
assert,
);
if (!cleared) {
throw new CodexThreadBindingConflictError(current.threadId, operation);
}
assert();
// Replacement CAS needs the predecessor row to preserve same-connection inventory.
replacementPredecessor = current;
binding = undefined;
};
const rotateStaleBinding = async (
const rotateStaleBinding = (
current: CodexAppServerThreadBinding,
changed: string,
details?: Record<string, unknown>,
@ -262,7 +253,7 @@ export async function startOrResumeThread(
threadId: current.threadId,
...details,
});
await clearCurrentBinding("rotating a stale thread binding");
stageBindingReplacement("rotating a stale thread binding");
};
const transientDelegationRestriction = params.params.delegationCapability === "report_only";
const persistentWebSearchRestriction =
@ -274,7 +265,7 @@ export async function startOrResumeThread(
const resumed = await resumePendingCodexThread(params, {
...(await prepareRequestContext()),
binding,
clearCurrentBinding,
stageBindingReplacement,
releaseRetainedThread: (threadId, assertCurrent) =>
releaseRetainedThread(threadId, initialBoundClientId, assertCurrent),
transientRestriction:
@ -290,7 +281,7 @@ export async function startOrResumeThread(
!restrictedToolSurface &&
binding.nativeToolPolicyRestricted === true
) {
await clearCurrentBinding("rotating a host-policy-restricted thread binding");
stageBindingReplacement("rotating a host-policy-restricted thread binding");
}
if (
binding?.threadId &&
@ -300,7 +291,7 @@ export async function startOrResumeThread(
"codex app-server native skill isolation changed; starting a new thread",
{ threadId: binding.threadId },
);
await clearCurrentBinding("rotating stale native skill isolation");
stageBindingReplacement("rotating stale native skill isolation");
}
if (
binding?.threadId &&
@ -313,7 +304,7 @@ export async function startOrResumeThread(
embeddedAgentLog.debug("codex app-server ring-zero restriction changed; rotating thread", {
threadId: binding.threadId,
});
await clearCurrentBinding("rotating a ring-zero thread binding");
stageBindingReplacement("rotating a ring-zero thread binding");
}
if (
binding?.threadId &&
@ -326,7 +317,7 @@ export async function startOrResumeThread(
binding: binding.appServerRuntimeFingerprint,
})
) {
await rotateStaleBinding(binding, "runtime identity", {
rotateStaleBinding(binding, "runtime identity", {
connectionClass: params.appServer.connectionClass,
});
}
@ -347,7 +338,7 @@ export async function startOrResumeThread(
requestedModel: params.params.modelId,
},
);
await clearCurrentBinding("rotating a GPT-5.6 multi-agent thread binding");
stageBindingReplacement("rotating a GPT-5.6 multi-agent thread binding");
}
selectionBinding = binding;
// Capability read failures use managed search for this turn but must not
@ -413,17 +404,11 @@ export async function startOrResumeThread(
// Scheduled configured MCP moved from Codex-native config to OpenClaw dynamic tools.
// A persistent main/named session has one binding: rotate its exact predecessor instead
// of retaining native and scheduled variants that could diverge or widen authority.
assertCodexBindingMayBeReplaced(
predecessorBinding,
"changing configured MCP ownership",
expectedOwnership,
);
stageBindingReplacement("changing configured MCP ownership");
embeddedAgentLog.debug(
"codex app-server configured MCP ownership changed; starting a new thread",
{ threadId: predecessorBinding.threadId },
);
replacementPredecessor = predecessorBinding;
binding = undefined;
preserveExistingBinding = false;
}
if (
@ -446,7 +431,7 @@ export async function startOrResumeThread(
);
preserveExistingBinding = true;
} else {
await rotateStaleBinding(binding, "MCP config");
rotateStaleBinding(binding, "MCP config");
}
binding = undefined;
}
@ -478,7 +463,7 @@ export async function startOrResumeThread(
} else {
// Codex can ignore resume overrides for a loaded thread, so persistent
// search-policy changes and legacy bindings without metadata rotate first.
await rotateStaleBinding(binding, "web search config");
rotateStaleBinding(binding, "web search config");
}
binding = undefined;
}
@ -516,7 +501,7 @@ export async function startOrResumeThread(
!contextEngineBinding ||
!isContextEngineBindingCompatible(binding.contextEngine, contextEngineBinding)
) {
await rotateStaleBinding(binding, "context-engine binding", {
rotateStaleBinding(binding, "context-engine binding", {
engineId: contextEngineBinding?.engineId,
previousEngineId: binding.contextEngine?.engineId,
epoch: contextEngineBinding?.projection?.epoch,
@ -537,14 +522,14 @@ export async function startOrResumeThread(
nextLegacy: legacyUserMcpServersFingerprint,
})
) {
await rotateStaleBinding(binding, "user MCP config");
rotateStaleBinding(binding, "user MCP config");
}
if (
binding?.threadId &&
(binding.networkProxyConfigFingerprint !== networkProxyConfigFingerprint ||
binding.networkProxyProfileName !== params.appServer.networkProxy?.profileName)
) {
await rotateStaleBinding(binding, "network proxy config");
rotateStaleBinding(binding, "network proxy config");
}
if (binding?.threadId) {
const pluginBindingStale = isCodexPluginThreadBindingStale({
@ -555,7 +540,7 @@ export async function startOrResumeThread(
hasBindingPolicyContext: Boolean(binding.pluginAppPolicyContext),
});
if (pluginBindingStale) {
await rotateStaleBinding(binding, "plugin app config");
rotateStaleBinding(binding, "plugin app config");
}
}
if (binding?.threadId) {
@ -565,7 +550,7 @@ export async function startOrResumeThread(
binding.dynamicToolsContainDeferred !== dynamicToolsContainDeferred &&
(binding.dynamicToolsContainDeferred !== undefined || !dynamicToolsContainDeferred)
) {
await rotateStaleBinding(binding, "dynamic tool loading");
rotateStaleBinding(binding, "dynamic tool loading");
}
}
if (binding?.threadId) {
@ -596,7 +581,7 @@ export async function startOrResumeThread(
},
);
} else {
await rotateStaleBinding(binding, "dynamic tool catalog");
rotateStaleBinding(binding, "dynamic tool catalog");
}
} else {
const requestContext = await prepareRequestContext();
@ -612,7 +597,7 @@ export async function startOrResumeThread(
}
if (incognito || warmReuse.kind === "rotate") {
throwIfAborted();
await clearCurrentBinding(
stageBindingReplacement(
incognito
? "rotating an unavailable ephemeral thread binding"
: "rotating a stale plugin app binding",
@ -622,7 +607,7 @@ export async function startOrResumeThread(
const resumed = await resumeExistingCodexThread(params, {
...requestContext,
binding: resumeBinding,
clearCurrentBinding,
stageBindingReplacement,
prebuiltFinalConfigPatch: warmReuse.prebuiltFinalConfigPatch,
prebuiltPluginThreadConfig,
buildLoadedPluginThreadConfig,
@ -644,7 +629,7 @@ export async function startOrResumeThread(
assertCodexBindingMayBeReplaced(binding, "starting a fresh native thread", expectedOwnership);
const requestContext = await prepareRequestContext();
if (initialBoundThreadId && !preserveExistingBinding && !replacementPredecessor) {
if (initialBoundThreadId && !preserveExistingBinding && !configuredMcpOwnershipChanged) {
await releaseRetainedThread(initialBoundThreadId);
}
const started = await startFreshCodexThread(params, {
@ -654,9 +639,8 @@ export async function startOrResumeThread(
rotatedContextEngineBinding,
replacementPredecessor,
});
if (replacementPredecessor) {
// The predecessor remains authoritative through thread/start and exact-owner CAS.
// Release only that prior subscription after the successor has committed.
if (replacementPredecessor && !preserveExistingBinding && configuredMcpOwnershipChanged) {
// Configured MCP migration retains the prior subscription until its replacement commits.
await releaseRetainedThread(replacementPredecessor.threadId, replacementPredecessor.clientId);
}
return publishCodexThreadInferenceBinding(params, started);

View file

@ -144,7 +144,7 @@ export type CodexThreadResumePreparation = {
export type CodexResumeThreadContext = CodexThreadRequestContext & {
binding: CodexAppServerThreadBinding;
clearCurrentBinding: (operation: string) => Promise<void>;
stageBindingReplacement: (operation: string) => void;
prebuiltPluginThreadConfig?: CodexPluginThreadConfig;
buildLoadedPluginThreadConfig?: (
binding: CodexAppServerThreadBinding,

View file

@ -0,0 +1,320 @@
import assert from "node:assert/strict";
import path from "node:path";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import {
claimCodexAppServerLiveThread,
ensureCodexAppServerClientRuntime,
retainCodexAppServerLiveThread,
} from "./client-runtime.js";
import { createCodexNativeSubagentHistoryOwner } from "./native-subagent-history-owner.js";
import type { CodexNativeSubagentPendingAssignment } from "./native-subagent-pending-assignments.js";
import {
bindProductionHarnessHostCapabilitiesForTest,
setupRunAttemptTestHooks,
} from "./run-attempt-test-harness.js";
import {
bindingStoreKey,
createCodexAppServerBindingStore,
type StoredCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestBindingStateStore } from "./session-binding.test-helpers.js";
import { useAutoCleanupTempDirTracker } from "./test-support.js";
import type { CodexStartOrResumeThreadParams } from "./thread-lifecycle-types.js";
import { startOrResumeThread } from "./thread-lifecycle.js";
import {
createAppServerOptions,
createCodexLifecycleHarness,
createParams,
resetThreadLifecycleTestFixtures,
threadStartResult,
} from "./thread-lifecycle.test-fixtures.js";
setupRunAttemptTestHooks();
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(resetThreadLifecycleTestFixtures);
async function fixture() {
const cwd = tempDirs.make("codex-assignment-rotation-");
const params = createParams(path.join(cwd, "session.jsonl"), cwd);
params.agentId = "main";
const identity = {
kind: "session" as const,
agentId: "main",
sessionId: params.sessionId,
sessionKey: params.sessionKey,
};
const key = bindingStoreKey(identity);
const values = new Map<string, StoredCodexAppServerBinding>();
const store = createCodexAppServerBindingStore(createCodexTestBindingStateStore(values));
let starts = 0;
let successor: (() => Promise<ReturnType<typeof threadStartResult>>) | undefined;
const wire = createCodexLifecycleHarness({
respond: async (method) => {
if (method === "config/read") {
return { config: {}, origins: {}, layers: [] };
}
if (method === "configRequirements/read") {
return { requirements: null };
}
if (method === "thread/start") {
starts += 1;
return starts > 1 && successor ? await successor() : threadStartResult(`parent-${starts}`);
}
if (method === "thread/delete") {
return {};
}
throw new Error(`Unexpected native lifecycle request: ${method}`);
},
});
ensureCodexAppServerClientRuntime(wire.client, { agentDir: cwd });
onTestFinished(async () => {
await wire.client.closeAndWait();
});
const options: CodexStartOrResumeThreadParams = {
client: wire.client,
bindingStore: store,
params,
cwd,
dynamicTools: [],
appServer: createAppServerOptions(),
nativeCodeModeEnabled: true,
userMcpServersEnabled: false,
mcpServersFingerprint: "mcp-before",
mcpServersFingerprintEvaluated: true,
appServerRuntimeFingerprint: "connection-A",
};
const parent = await startOrResumeThread(options);
const releasePredecessor = vi.fn(async (threadId: string) => {
await wire.client.request("thread/unsubscribe", { threadId });
});
await retainCodexAppServerLiveThread(
wire.client,
parent.threadId,
releasePredecessor,
parent.liveThreadConfigFingerprint,
);
const owner = createCodexNativeSubagentHistoryOwner({
parentThreadId: parent.threadId,
sessionId: params.sessionId,
binding: parent,
});
assert(owner);
const assignments: CodexNativeSubagentPendingAssignment[] = [
{
runId: "codex-thread:running-child",
childThreadId: "running-child",
nativeParentThreadId: parent.threadId,
owner,
},
{
runId: "codex-thread:finished-child",
childThreadId: "finished-child",
nativeParentThreadId: parent.threadId,
owner,
recordedCompletion: {
childThreadId: "finished-child",
status: "succeeded",
statusLabel: "recorded_task_result",
result: "Retained child result",
completedAt: 200,
},
},
];
const stored = values.get(key);
assert(stored?.state === "active");
values.set(key, {
...stored,
nativeSubagentAssignments: { version: 1, assignments },
nativeSubagentTaskImport: { version: 1, taskIds: ["legacy-running", "legacy-finished"] },
});
const readAssignments = (threadId: string, binding = parent) => {
const currentOwner = createCodexNativeSubagentHistoryOwner({
parentThreadId: threadId,
sessionId: params.sessionId,
binding,
});
assert(currentOwner);
assert(typeof store.readNativeSubagentAssignments === "function");
return store.readNativeSubagentAssignments(identity, currentOwner);
};
const readState = () => {
const current = values.get(key);
if (!current) {
return undefined;
}
const { lease: _lease, ...durable } = current;
return structuredClone(durable);
};
expect(readAssignments(parent.threadId)).toEqual(assignments);
return {
...wire,
options,
identity,
readState,
store,
parent,
assignments,
releasePredecessor,
readAssignments,
setSuccessor: (respond: () => Promise<ReturnType<typeof threadStartResult>>) => {
successor = respond;
},
rotate: (patch: Partial<CodexStartOrResumeThreadParams> = {}) =>
startOrResumeThread({ ...options, mcpServersFingerprint: "mcp-after", ...patch }),
};
}
describe("native assignment custody across ordinary parent rotation", () => {
it("keeps imported assignments until the ordinary replacement commits", async () => {
const f = await fixture();
const before = f.readState();
const entered = createDeferred<void>();
const proceed = createDeferred<void>();
f.setSuccessor(async () => {
entered.resolve();
await proceed.promise;
return threadStartResult("parent-2");
});
const pending = f.rotate();
const settled = pending.then(
(value) => ({ value }),
(error: unknown) => ({ error }),
);
try {
await Promise.race([
entered.promise,
settled.then(() => {
throw new Error("Replacement settled before reaching native thread/start");
}),
]);
expect(f.readState()).toEqual(before);
expect(f.readAssignments(f.parent.threadId)).toEqual(f.assignments);
expect(f.releasePredecessor).toHaveBeenCalledExactlyOnceWith(f.parent.threadId);
} finally {
proceed.resolve();
await settled;
}
const replacement = await pending;
expect(f.store.read(f.identity)?.threadId).toBe("parent-2");
expect(f.readAssignments(replacement.threadId, replacement)).toEqual(f.assignments);
});
it("does not expose the old assignments through a different connection", async () => {
const f = await fixture();
const replacement = await f.rotate({ appServerRuntimeFingerprint: "connection-B" });
expect(replacement.appServerRuntimeFingerprint).toBe("connection-B");
expect(f.readAssignments(replacement.threadId, replacement)).toEqual([]);
});
it.each(["start", "abort", "revoked", "conflict"] as const)(
"preserves authoritative assignments when the successor encounters %s failure",
async (failure) => {
const f = await fixture();
const controller = new AbortController();
let expected = f.readState();
assert(expected?.state === "active");
const predecessor = expected.binding;
const closeHost =
failure === "revoked"
? await bindProductionHarnessHostCapabilitiesForTest(f.options.params, {
profileId: "rotation-operator",
scopes: ["operator.write"],
assertCurrent: () => {},
})
: undefined;
if (closeHost) {
onTestFinished(closeHost);
}
if (failure === "abort") {
const mutate = f.store.mutate.bind(f.store);
vi.spyOn(f.store, "mutate").mockImplementation((identity, mutation, assertCurrent) => {
if (mutation.kind === "replace-thread") {
controller.abort(new Error("Rotation aborted"));
}
return mutate(identity, mutation, assertCurrent);
});
}
f.setSuccessor(async () => {
if (failure === "start") {
throw new Error("Successor start rejected");
}
if (failure === "revoked") {
closeHost?.();
}
if (failure === "conflict") {
expect(
await f.store.mutate(f.identity, {
kind: "replace-thread",
expectedThreadId: f.parent.threadId,
binding: { ...predecessor, threadId: "competing-parent" },
}),
).toBe(true);
expected = f.readState();
}
return threadStartResult("parent-uncommitted");
});
await expect(f.rotate({ signal: controller.signal })).rejects.toThrow(
{
start: "Successor start rejected",
abort: "Rotation aborted",
revoked: "agent harness host capability is no longer active",
conflict: "Codex thread binding changed while committing a fresh thread: parent-1",
}[failure],
);
expect(f.readState()).toEqual(expected);
assert(expected?.state === "active");
expect(f.readAssignments(expected.binding.threadId)).toEqual(f.assignments);
expect(f.request.mock.calls.filter(([method]) => method === "thread/start")).toHaveLength(2);
expect(
f.request.mock.calls
.filter(([method]) => method === "thread/delete")
.map(([, params]) => params),
).toEqual(failure === "start" ? [] : [{ threadId: "parent-uncommitted" }]);
expect(f.releasePredecessor).toHaveBeenCalledExactlyOnceWith(f.parent.threadId);
},
);
it("refuses a claimed predecessor before starting or committing a successor", async () => {
const f = await fixture();
const before = f.readState();
const claim = await claimCodexAppServerLiveThread(f.client, f.parent.threadId);
assert(claim);
try {
await expect(f.rotate()).rejects.toThrow("claimed by active work; stop it first");
expect(f.readState()).toEqual(before);
expect(f.readAssignments(f.parent.threadId)).toEqual(f.assignments);
expect(f.request.mock.calls.filter(([method]) => method === "thread/start")).toHaveLength(1);
expect(f.releasePredecessor).not.toHaveBeenCalled();
expect(() => claim.assertCurrent()).not.toThrow();
} finally {
await claim.release(f.parent.threadId);
}
});
it("keeps an early rotation transient until a later durable turn", async () => {
const f = await fixture();
await f.store.mutate(f.identity, {
kind: "patch",
threadId: f.parent.threadId,
patch: { nativeSkillIsolationFingerprint: "previous-skill-policy" },
});
const before = f.readState();
const transient = await startOrResumeThread({
...f.options,
params: { ...f.options.params, delegationCapability: "report_only" },
});
expect(transient.lifecycle.preserveExistingBinding).toBe(true);
expect(transient.liveThreadConfigFingerprint).toBeUndefined();
expect(f.readState()).toEqual(before);
expect(f.readAssignments(f.parent.threadId)).toEqual(f.assignments);
expect(f.releasePredecessor).not.toHaveBeenCalled();
const replacement = await startOrResumeThread(f.options);
expect(replacement.threadId).toBe("parent-3");
expect(replacement.lifecycle.preserveExistingBinding).toBeUndefined();
expect(f.store.read(f.identity)?.threadId).toBe(replacement.threadId);
expect(f.readAssignments(replacement.threadId, replacement)).toEqual(f.assignments);
expect(f.releasePredecessor).toHaveBeenCalledExactlyOnceWith(f.parent.threadId);
});
});

View file

@ -2831,6 +2831,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
});
const predecessor = await readCodexAppServerBinding(sessionFile);
const params = createParams(sessionFile, workspaceDir);
params.toolsAllow = ["openclaw"];
if (ephemeral) {
@ -2880,8 +2881,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
"mcpServerStatus/list",
ephemeral ? "thread/unsubscribe" : "thread/delete",
]);
expect(request.mock.calls.some(([method]) => method === "turn/start")).toBe(false);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(await readCodexAppServerBinding(sessionFile)).toEqual(predecessor);
},
);

View file

@ -1,3 +1,4 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
@ -25,11 +26,9 @@ import {
writePolicyProbeServer,
} from "./thread-lifecycle.user-mcp-servers.test-support.js";
function createRequest(
startThreadId: string,
options: { readRequirements?: boolean; resumeThreadId?: string } = {},
) {
return vi.fn(async (method: string, _params: unknown) => {
function createRequest(startThreadId: string, options: { readRequirements?: boolean } = {}) {
let startCount = 0;
return vi.fn(async (method: string, params: unknown) => {
if (method === "config/read") {
return { config: {}, origins: {}, layers: [] };
}
@ -37,10 +36,17 @@ function createRequest(
return { requirements: null };
}
if (method === "thread/start") {
return threadStartResult(startThreadId);
startCount += 1;
return threadStartResult(startCount === 1 ? startThreadId : `${startThreadId}-${startCount}`);
}
if (method === "thread/resume" && options.resumeThreadId) {
return threadResumeResult(options.resumeThreadId);
if (method === "thread/resume") {
assert(
params !== null &&
typeof params === "object" &&
"threadId" in params &&
typeof params.threadId === "string",
);
return threadResumeResult(params.threadId);
}
throw new Error(`unexpected method: ${method}`);
});
@ -80,7 +86,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
},
},
};
const request = createRequest("thread-policy", { resumeThreadId: "thread-policy" });
const request = createRequest("thread-policy");
let wire = await createLeasedCodexLifecycleHarness({
agentDir: path.join(tempDir, "agent"),
respond: request,
@ -236,7 +242,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
},
},
} as unknown as EmbeddedRunAttemptParams["config"];
const request = createRequest("thread-beta5", { resumeThreadId: "thread-beta5" });
const request = createRequest("thread-beta5");
let wire = await createLeasedCodexLifecycleHarness({
agentDir: path.join(tempDir, "agent"),
respond: request,
@ -250,6 +256,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
await run();
const currentBinding = await readCodexAppServerBinding(sessionFile);
expect(currentBinding).toBeDefined();
expect(currentBinding?.threadId).toBe("thread-beta5");
const legacyFingerprint = JSON.stringify({
mcp_servers: {
@ -278,6 +285,8 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
"thread/start",
]);
const convergedBinding = await readCodexAppServerBinding(sessionFile);
expect(convergedBinding?.threadId).toBe("thread-beta5-2");
expect(convergedBinding?.threadId).not.toBe(currentBinding?.threadId);
expect(convergedBinding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(convergedBinding?.userMcpServersFingerprint).not.toContain("beta5-access-token");
expect(convergedBinding?.userMcpServersFingerprint).not.toBe(legacyFingerprint);
@ -289,7 +298,7 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
wire = await createLeasedCodexLifecycleHarness({
agentDir: path.join(tempDir, "agent"),
respond: request,
persistedThreads: ["thread-beta5"],
persistedThreads: ["thread-beta5-2"],
});
request.mockClear();
await run();
@ -305,6 +314,13 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
"thread/resume",
"thread/inject_items",
]);
expect(request).toHaveBeenCalledWith(
"thread/resume",
expect.objectContaining({ threadId: convergedBinding?.threadId }),
);
expect((await readCodexAppServerBinding(sessionFile))?.threadId).toBe(
convergedBinding?.threadId,
);
},
);
@ -371,15 +387,14 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
},
},
}) as unknown as EmbeddedRunAttemptParams["config"];
const request = createRequest("thread-with-current-bearer", {
resumeThreadId: "thread-with-stale-bearer",
});
const request = createRequest("thread-with-current-bearer");
await startOrResumeThread({
client: { request } as never,
...lifecycleOptions(sessionFile, workspaceDir, createConfig("Bearer access-token-one")),
});
const firstBinding = await readCodexAppServerBinding(sessionFile);
expect(firstBinding?.threadId).toBe("thread-with-current-bearer");
expect(firstBinding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(firstBinding?.userMcpServersFingerprint).not.toContain("access-token-one");
@ -402,6 +417,8 @@ describe("startOrResumeThread — user mcp.servers projection (regression: #8081
"Bearer access-token-two",
);
const secondBinding = await readCodexAppServerBinding(sessionFile);
expect(secondBinding?.threadId).toBe("thread-with-current-bearer-2");
expect(secondBinding?.threadId).not.toBe(firstBinding?.threadId);
expect(secondBinding?.userMcpServersFingerprint).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(secondBinding?.userMcpServersFingerprint).not.toContain("access-token-two");
expect(secondBinding?.userMcpServersFingerprint).not.toBe(

View file

@ -50,6 +50,10 @@ const nativeAssignmentLogs = [
"native-assignment-eligibility.json",
"native-assignment-baseline.json",
"native-assignment-first-hop.json",
"native-assignment-inventory-after-first-hop.json",
"native-assignment-inventory-before-recovery.json",
"native-assignment-inventory-after-recovery.json",
"native-assignment-inventory-live-final.json",
"native-assignment-proof.json",
"native-assignment-messages.jsonl",
"native-assignment-server.log",

View file

@ -32,6 +32,7 @@ const completeId = "native-upgrade-complete";
const phases = new Set([
"seed-history",
"seed-assignments",
"handoff",
"recover",
"close-loaded",
"close-gone",
@ -44,6 +45,7 @@ const subscriptions = new Map();
const loaded = new Set();
const closeTurns = new Map();
let turnSequence = 0;
let parentSequence = 0;
let seededAssignments = false;
let historyPruned = false;
@ -52,7 +54,7 @@ function readPhase() {
if (!phases.has(phase)) {
throw new Error("Unknown native assignment fixture phase");
}
if (!phase.startsWith("seed-") && !historyPruned) {
if (!phase.startsWith("seed-") && phase !== "handoff" && !historyPruned) {
// Recovery must use the imported locator/result, not rediscover old spawn events.
for (const turn of threads.get(parentId)?.turns ?? []) {
turn.items = turn.items.filter((item) => item.type !== "collabAgentToolCall");
@ -114,29 +116,35 @@ function thread(id) {
return value;
}
function startThread(params, id = parentId) {
function startThread(params, requestedId) {
let id = requestedId;
if (id === undefined) {
parentSequence += 1;
id = parentSequence === 1 ? parentId : `${parentId}-${parentSequence}`;
}
if (threads.has(id)) {
throw new Error("Synthetic thread/start must create a fresh thread");
}
const response = createFakeThreadStartResponse({
params,
threadId: id,
sessionId: "native-upgrade-provider-session",
version,
});
if (!threads.has(id)) {
response.thread.createdAt = Math.floor(Date.now() / 1000);
response.thread.updatedAt = response.thread.createdAt;
if (id !== parentId) {
response.thread.parentThreadId = parentId;
response.thread.source = {
subAgent: {
thread_spawn: { parent_thread_id: parentId, depth: 1, agent_path: `/root/${id}` },
},
};
response.thread.preview = "Synthetic native upgrade assignment";
}
threads.set(id, response.thread);
injectedItemsByThread.set(id, []);
threadConfigurations.set(id, { params: structuredClone(params), response });
response.thread.createdAt = Math.floor(Date.now() / 1000);
response.thread.updatedAt = response.thread.createdAt;
if (id === runningId || id === completeId) {
response.thread.parentThreadId = parentId;
response.thread.source = {
subAgent: {
thread_spawn: { parent_thread_id: parentId, depth: 1, agent_path: `/root/${id}` },
},
};
response.thread.preview = "Synthetic native upgrade assignment";
}
threads.set(id, response.thread);
injectedItemsByThread.set(id, []);
threadConfigurations.set(id, { params: structuredClone(params), response });
loaded.add(id);
return { ...threadConfigurations.get(id).response, thread: thread(id) };
}
@ -255,15 +263,24 @@ function completeTurn(socket, phase, threadId, turn, text, status = "completed")
notify(socket, phase, "thread/status/changed", { threadId, status: { type: "idle" } });
}
function collabItem(socket, phase, turn, item, method) {
function collabItem(socket, phase, threadId, turn, item, method) {
if (method === "item/completed") {
turn.items.push(item);
}
notify(socket, phase, method, { threadId: parentId, turnId: turn.id, item });
notify(socket, phase, method, { threadId, turnId: turn.id, item });
}
function runPhase(socket, phase, turn) {
if (phase === "seed-assignments") {
function runPhase(socket, phase, threadId, turn, input) {
if (turn.status !== "inProgress") {
return;
}
if (
phase === "seed-assignments" &&
threadId === parentId &&
input.some(
(item) => item.type === "text" && item.text.includes("NATIVE_UPGRADE_SEED_ASSIGNMENTS"),
)
) {
if (seededAssignments) {
throw new Error("Native assignments were already seeded");
}
@ -277,6 +294,7 @@ function runPhase(socket, phase, turn) {
collabItem(
socket,
phase,
threadId,
turn,
{
id: `spawn-${id}`,
@ -297,21 +315,32 @@ function runPhase(socket, phase, turn) {
}
return;
}
if (phase === "close-loaded" || phase === "close-gone") {
if (
(phase === "close-loaded" || phase === "close-gone") &&
input.some(
(item) =>
item.type === "text" &&
item.text.includes(`Continue the synthetic native upgrade fixture: ${phase}.`),
)
) {
if (closeTurns.has(socket)) {
throw new Error("Native close confirmation is already pending on this connection");
}
const item = {
id: `${phase}-${turn.id}`,
type: "collabAgentToolCall",
tool: "closeAgent",
status: "inProgress",
senderThreadId: parentId,
senderThreadId: threadId,
receiverThreadIds: [runningId],
agentsStates: {},
};
closeTurns.set(socket, { phase, turn });
collabItem(socket, phase, turn, item, "item/started");
closeTurns.set(socket, { phase, threadId, turn });
collabItem(socket, phase, threadId, turn, item, "item/started");
collabItem(
socket,
phase,
threadId,
turn,
{
...item,
@ -325,7 +354,7 @@ function runPhase(socket, phase, turn) {
completeTurn(
socket,
phase,
parentId,
threadId,
turn,
phase === "seed-history" ? "NATIVE_UPGRADE_RETAINED_HISTORY" : "NATIVE_UPGRADE_PARENT_OK",
);
@ -450,32 +479,41 @@ function handle(socket, phase, message) {
return result({ status: removed ? "unsubscribed" : "notSubscribed" });
}
case "turn/start": {
if (params.threadId !== parentId) {
if ([runningId, completeId].includes(params.threadId)) {
throw new Error("Only parent turns may be started through this fixture");
}
const turnId = `native-parent-turn-${++turnSequence}`;
const turn = startTurn(socket, phase, parentId, turnId);
const turn = startTurn(socket, phase, params.threadId, turnId);
result({ turn });
setImmediate(() => {
try {
runPhase(socket, phase, turn);
runPhase(socket, phase, params.threadId, turn, params.input ?? []);
} catch (error) {
log("fixture-error", phase, { message: error.message });
completeTurn(socket, phase, parentId, turn, "NATIVE_UPGRADE_FIXTURE_ERROR", "failed");
completeTurn(
socket,
phase,
params.threadId,
turn,
"NATIVE_UPGRADE_FIXTURE_ERROR",
"failed",
);
}
});
return;
}
case "turn/interrupt": {
if (params.threadId !== parentId) {
if ([runningId, completeId].includes(params.threadId)) {
throw new Error("This fixture only interrupts parent turns");
}
const turn = thread(parentId).turns.find((value) => value.id === params.turnId);
if (!turn) {
throw new Error("Unknown synthetic parent turn");
const turn = thread(params.threadId).turns.find((value) => value.id === params.turnId);
if (turn?.status !== "inProgress") {
throw new Error("No active synthetic parent turn to interrupt");
}
result({});
completeTurn(socket, phase, parentId, turn, undefined, "interrupted");
setImmediate(() =>
completeTurn(socket, phase, params.threadId, turn, undefined, "interrupted"),
);
return;
}
case "thread/loaded/list": {
@ -495,7 +533,7 @@ function handle(socket, phase, message) {
completeTurn(
socket,
closing.phase,
parentId,
closing.threadId,
closing.turn,
"NATIVE_UPGRADE_CLOSE_OBSERVED",
),

View file

@ -132,7 +132,7 @@ function runParent(phase, wait = true) {
agentId: "native-proof",
message:
phase === "seed-assignments"
? "Spawn two native child agents for this synthetic upgrade fixture."
? "NATIVE_UPGRADE_SEED_ASSIGNMENTS: Spawn two native child agents for this synthetic upgrade fixture."
: `Continue the synthetic native upgrade fixture: ${phase}.`,
deliver: false,
},
@ -160,13 +160,19 @@ function snapshot() {
)
.all(SESSION_KEY)
.map((row) => Object.assign({}, row));
const baselinePath = artifact("native-assignment-baseline.json");
const bindingKey = fs.existsSync(baselinePath) ? readJson(baselinePath).binding.key : undefined;
const bindings = db
.prepare(
"SELECT entry_key, value_json FROM plugin_state_entries WHERE plugin_id = 'codex' AND namespace = 'app-server-thread-bindings'",
)
.all()
.map((row) => ({ key: row.entry_key, value: JSON.parse(row.value_json) }))
.filter((row) => row.value.binding?.threadId === "native-upgrade-parent");
.filter((row) =>
bindingKey
? row.key === bindingKey
: row.value.binding?.threadId === "native-upgrade-parent",
);
assert.equal(bindings.length, 1, "native parent binding was missing or ambiguous");
return { databasePath, tasks, binding: bindings[0] };
} finally {
@ -174,6 +180,243 @@ function snapshot() {
}
}
function captureInventory(stage) {
const observation = { stage, capturedAt: new Date().toISOString() };
const fingerprint = (value) => createHash("sha256").update(value).digest("hex");
try {
const baseline = readJson(artifact("native-assignment-baseline.json"));
const db = new DatabaseSync(
path.join(required("OPENCLAW_STATE_DIR"), "state", "openclaw.sqlite"),
{
readOnly: true,
},
);
try {
const row = db
.prepare(
"SELECT value_json FROM plugin_state_entries WHERE plugin_id = 'codex' AND namespace = 'app-server-thread-bindings' AND entry_key = ?",
)
.get(baseline.binding.key);
const stored = row ? JSON.parse(row.value_json) : undefined;
const binding = stored?.binding;
observation.binding = stored
? {
state: stored.state,
sessionId: stored.sessionId,
threadId: binding?.threadId,
connectionFingerprint: binding?.appServerRuntimeFingerprint
? fingerprint(
JSON.stringify([
binding.appServerRuntimeFingerprint,
binding.connectionScope ?? null,
binding.authProfileId ?? null,
]),
)
: null,
import: stored.nativeSubagentTaskImport
? {
version: stored.nativeSubagentTaskImport.version,
taskIds: stored.nativeSubagentTaskImport.taskIds,
}
: undefined,
assignments: (stored.nativeSubagentAssignments?.assignments ?? []).map((entry) => ({
runId: entry.runId,
childThreadId: entry.childThreadId,
nativeParentThreadId: entry.nativeParentThreadId,
nativeTurnId: entry.nativeTurnId,
owner: {
parentThreadId: entry.owner?.parentThreadId,
sessionId: entry.owner?.sessionId,
lifecycleRevision: entry.owner?.lifecycleRevision,
connectionFingerprint: entry.owner?.connectionFingerprint,
},
recordedCompletion: entry.recordedCompletion
? {
status: entry.recordedCompletion.status,
resultSha256: fingerprint(entry.recordedCompletion.result),
hasResultMarker: entry.recordedCompletion.result.includes(RESULT_MARKER),
}
: undefined,
})),
}
: null;
} finally {
db.close();
}
const sessions = new DatabaseSync(
path.join(
required("OPENCLAW_STATE_DIR"),
"agents",
"native-proof",
"agent",
"openclaw-agent.sqlite",
),
{ readOnly: true },
);
try {
const row = sessions
.prepare("SELECT current_session_id, entry_json FROM session_nodes WHERE session_key = ?")
.get(SESSION_KEY);
const entry = row ? JSON.parse(row.entry_json) : undefined;
observation.session = row
? {
currentSessionId: row.current_session_id,
sessionId: entry.sessionId,
lifecycleRevision: entry.lifecycleRevision,
agentHarnessId: entry.agentHarnessId,
}
: null;
} finally {
sessions.close();
}
} catch (error) {
// Read failures remain explicit evidence without copying raw state or error text.
observation.unavailable = { name: error.name, code: error.code };
}
writeJson(`native-assignment-inventory-${stage}.json`, observation);
}
async function handoff() {
setPhase("handoff");
const seeded = nativeMessages().find(
(entry) =>
entry.phase === "seed-assignments" &&
entry.method === "item/completed" &&
entry.params?.threadId === "native-upgrade-parent" &&
entry.params?.item?.tool === "spawnAgent",
);
assert(seeded?.params.turnId, "native handoff has no witnessed seed-parent turn");
const socket = new WebSocket(readJson(artifact("native-assignment-ready.json")).url);
const deadline = AbortSignal.timeout(30_000);
let sequence = 0;
const pending = new Map();
let interruption;
let failure;
const fail = (error) => {
failure ??= error;
for (const request of pending.values()) {
request.reject(error);
}
pending.clear();
interruption?.reject(error);
interruption = undefined;
};
socket.addEventListener("message", ({ data }) => {
try {
const message = JSON.parse(data);
if (message.method) {
if (
interruption &&
message.method === "turn/completed" &&
message.params?.threadId === "native-upgrade-parent" &&
message.params.turn?.id === interruption.turnId
) {
const observed = interruption;
interruption = undefined;
if (message.params.turn.status === "interrupted") {
observed.resolve();
} else {
observed.reject(new Error("Native handoff did not interrupt the seed-parent turn"));
}
}
return;
}
const request = pending.get(message.id);
if (request) {
pending.delete(message.id);
if (message.error) {
request.reject(new Error(message.error.message));
} else {
request.resolve(message.result);
}
}
} catch (error) {
fail(error);
}
});
socket.addEventListener("error", () => fail(new Error("Native handoff transport failed")));
socket.addEventListener("close", () => fail(new Error("Native handoff transport closed")));
const abort = () => fail(new Error("Native handoff timed out"));
deadline.addEventListener("abort", abort, { once: true });
const request = (method, params) =>
new Promise((resolve, reject) => {
deadline.throwIfAborted();
if (failure) {
throw failure;
}
const id = ++sequence;
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
try {
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener(
"error",
() => reject(new Error("Native handoff failed to connect")),
{ once: true },
);
deadline.addEventListener(
"abort",
() => reject(new Error("Native handoff connect timed out")),
{ once: true },
);
});
await request("initialize", { clientInfo: { name: "native-upgrade-handoff", version: "1" } });
socket.send(JSON.stringify({ method: "initialized", params: {} }));
await request("thread/resume", { threadId: "native-upgrade-parent" });
const parent = (
await request("thread/read", { threadId: "native-upgrade-parent", includeTurns: true })
).thread;
assert.deepEqual(
parent.turns.filter((turn) => turn.status === "inProgress").map((turn) => turn.id),
[seeded.params.turnId],
);
const children = {};
for (const threadId of ["native-upgrade-running", "native-upgrade-complete"]) {
children[threadId] = await request("thread/read", { threadId, includeTurns: true });
}
// Codex acknowledges interruption before publishing turn/completed. Arm the
// exact terminal witness before the request and join both protocol events.
const interrupted = new Promise((resolve, reject) => {
deadline.throwIfAborted();
if (failure) {
throw failure;
}
interruption = { turnId: seeded.params.turnId, resolve, reject };
});
await Promise.all([
interrupted,
request("turn/interrupt", {
threadId: "native-upgrade-parent",
turnId: seeded.params.turnId,
}),
]);
const settled = (
await request("thread/read", { threadId: "native-upgrade-parent", includeTurns: true })
).thread;
assert.equal(settled.status.type, "idle");
assert(settled.turns.every((turn) => turn.status !== "inProgress"));
for (const threadId of Object.keys(children)) {
assert.deepEqual(
await request("thread/read", { threadId, includeTurns: true }),
children[threadId],
"parent interruption changed a native child",
);
}
assert.deepEqual(
snapshot().tasks,
readJson(artifact("native-assignment-baseline.json")).tasks,
"native handoff changed published source rows",
);
// The first candidate Gateway may recover retained work before the explicit live probe.
setPhase("recover");
} finally {
deadline.removeEventListener("abort", abort);
socket.close();
}
}
function assignments(observation) {
return observation.binding.value.nativeSubagentAssignments?.assignments ?? [];
}
@ -416,24 +659,34 @@ function assertCloseWitness(phase, childLoaded) {
.some(
(entry) =>
entry.method === "turn/completed" &&
entry.params?.threadId === "native-upgrade-parent" &&
entry.params?.threadId === messages[started].params.threadId &&
entry.params?.turn?.id === messages[started].params.turnId,
),
"native close parent completed before confirmation",
);
assert.deepEqual(response.result, {
data: childLoaded
? ["native-upgrade-parent", "native-upgrade-running"]
: ["native-upgrade-parent"],
nextCursor: null,
});
assert.equal(response.result.nextCursor, null, "native closure snapshot was incomplete");
assert.equal(
response.result.data.filter((id) => id === messages[started].params.threadId).length,
1,
"closing parent was not loaded exactly once",
);
assert.equal(
response.result.data.filter((id) => id === "native-upgrade-running").length,
childLoaded ? 1 : 0,
);
assert(
!response.result.data.includes("native-upgrade-complete"),
"finished child entered closure inventory",
);
}
async function live(candidateVersion) {
captureInventory("before-recovery");
const before = readJson(artifact("native-assignment-baseline.json"));
const firstHop = readJson(artifact("native-assignment-first-hop.json"));
assert.equal(firstHop.candidateVersion, candidateVersion);
runParent("recover");
captureInventory("after-recovery");
// The foreground run can settle before its detached completion continuation. Join the
// observable delivery and native terminal event before switching the backend's phase.
await waitForWitness(() => {
@ -449,7 +702,7 @@ async function live(candidateVersion) {
messages.some(
(entry) =>
entry.method === "turn/completed" &&
entry.params?.threadId === "native-upgrade-parent" &&
entry.params?.threadId === delivered[0].params.threadId &&
entry.params?.turn?.id === reply.result.turn.id,
),
"completion continuation did not settle",
@ -545,8 +798,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
);
} else if (operation === "post-update") {
postUpdate(candidateVersion);
} else if (operation === "handoff") {
await handoff();
} else if (operation === "inventory") {
assert.equal(candidateVersion, "after-first-hop");
captureInventory(candidateVersion);
} else if (operation === "live") {
await live(candidateVersion);
try {
await live(candidateVersion);
} finally {
captureInventory("live-final");
}
} else {
throw new Error(`Unknown native assignment operation: ${operation}`);
}

View file

@ -2544,6 +2544,9 @@ if [ "$SCENARIO" = "legacy-operator-state" ]; then
fi
phase patch-restored-index node scripts/e2e/lib/upgrade-survivor/legacy-operator-restored-index.mjs patch
phase stop-restored-index-baseline stop_gateway
if [ "$native_assignment_enabled" = "1" ]; then
phase handoff-native-assignments node scripts/e2e/lib/upgrade-survivor/native-assignments.mjs handoff
fi
phase restore-baseline-index node scripts/e2e/lib/upgrade-survivor/legacy-operator-restored-index.mjs restore
# Do not restart the published Gateway after restoring stale metadata over its current SQLite state.
fi
@ -2615,6 +2618,9 @@ if [ "$SCENARIO" = "legacy-operator-state" ]; then
else
phase legacy-operator-first-hop-cron probe_legacy_operator_migration
fi
if [ "$native_assignment_enabled" = "1" ]; then
phase capture-native-assignment-first-hop-runtime node scripts/e2e/lib/upgrade-survivor/native-assignments.mjs inventory after-first-hop
fi
fi
phase mobile-pairing-candidate-first verify_mobile_pairing_once \
candidate-first "$MOBILE_PAIRING_CANDIDATE_FIRST_EVIDENCE"

View file

@ -8,6 +8,7 @@ prepare_diagnostics_capture() {
"$ARTIFACT_DIR"/update-noop.{json,err} \
"$ARTIFACT_DIR/candidate-cohort.json" \
"$ARTIFACT_DIR"/native-assignment-{eligibility,baseline,first-hop,proof,ready,phase}.json \
"$ARTIFACT_DIR"/native-assignment-inventory-{after-first-hop,before-recovery,after-recovery,live-final}.json \
"$ARTIFACT_DIR/native-assignment-messages.jsonl" "$ARTIFACT_DIR/native-assignment-server.log" \
"$ARTIFACT_DIR"/native-recover{,-wait}.{out,err} \
"$ARTIFACT_DIR/backup-rollback.json" \

View file

@ -968,6 +968,10 @@ it("does not reuse sibling or startup observations when an attempt fails before
...turnLogs,
...cronCliLogs,
...nativeRecoveryLogs,
"native-assignment-inventory-after-first-hop.json",
"native-assignment-inventory-before-recovery.json",
"native-assignment-inventory-after-recovery.json",
"native-assignment-inventory-live-final.json",
...baselineGatewayLogs,
"sibling-refusal-update.json",
"sibling-refusal-status.json",

View file

@ -0,0 +1,323 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../../packages/gateway-client/src/websocket-data.js";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
type Turn = { id: string; status: string; items: unknown[] };
type Thread = {
id: string;
parentThreadId?: string;
source: unknown;
status: { type: string };
turns: Turn[];
};
type Result = { thread?: Thread; turn?: Turn; data?: string[]; nextCursor?: string | null };
type Notification = {
method: string;
params: {
threadId?: string;
turnId?: string;
turn?: Turn;
item?: { tool?: string; status?: string };
};
};
let child: ChildProcessWithoutNullStreams;
let socket: WebSocket;
let phaseFile: string;
let logFile: string;
let sequence = 0;
const pending = new Map<
number,
{ resolve: (value: Result) => void; reject: (error: Error) => void }
>();
const notifications: Notification[] = [];
const waiting = new Set<() => void>();
const dirs = useAutoCleanupTempDirTracker((cleanup) =>
afterAll(async () => {
socket?.terminate();
if (child && child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGTERM");
await exited;
}
cleanup();
}),
);
function request(method: string, params: object = {}): Promise<Result> {
const id = ++sequence;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function notificationWhere(matches: (entry: Notification) => boolean): Promise<Notification> {
const observed = notifications.find(matches);
if (observed) {
return observed;
}
return await new Promise<Notification>((resolve) => {
const check = () => {
const entry = notifications.find(matches);
if (entry) {
waiting.delete(check);
resolve(entry);
}
};
waiting.add(check);
});
}
async function completed(threadId: string, turnId: string) {
return await notificationWhere(
(entry) =>
entry.method === "turn/completed" &&
entry.params.threadId === threadId &&
entry.params.turn?.id === turnId,
);
}
beforeAll(async () => {
const root = dirs.make("native-upgrade-protocol-");
phaseFile = path.join(root, "phase.json");
logFile = path.join(root, "messages.jsonl");
const readyFile = path.join(root, "ready.json");
const driver = path.join(root, "driver.mjs");
const fixture = pathToFileURL(
path.resolve("scripts/e2e/lib/upgrade-survivor/native-assignment-app-server.mjs"),
);
fs.writeFileSync(
driver,
[
'import fs from "node:fs";',
`await import(${JSON.stringify(fixture.href)});`,
`process.stdout.write(fs.readFileSync(${JSON.stringify(readyFile)}, "utf8"));`,
].join("\n"),
);
fs.writeFileSync(phaseFile, JSON.stringify({ phase: "seed-assignments" }));
child = spawn(
process.execPath,
[
driver,
"--package-root",
process.cwd(),
"--ready-file",
readyFile,
"--phase-file",
phaseFile,
"--log-file",
logFile,
],
{ stdio: "pipe" },
);
const lines = createInterface({ input: child.stdout });
const [line] = await Promise.race([
once(lines, "line"),
once(child, "exit").then(([code]) => {
throw new Error(`Native fixture exited before ready: ${code}`);
}),
]);
lines.close();
const ready = JSON.parse(line);
socket = new WebSocket(ready.url);
socket.on("message", (data) => {
const message = JSON.parse(rawDataToString(data));
if (message.method) {
notifications.push(message);
for (const check of waiting) {
check();
}
return;
}
const response = pending.get(message.id);
pending.delete(message.id);
if (message.error) {
response?.reject(new Error(message.error.message));
} else {
response?.resolve(message.result);
}
});
await once(socket, "open");
await request("initialize", { clientInfo: { name: "native-fixture-regression", version: "1" } });
socket.send(JSON.stringify({ method: "initialized", params: {} }));
});
it("isolates native seeding, interruption, and closure from incidental parent turns", async () => {
const parent = (await request("thread/start", { cwd: process.cwd() })).thread!;
expect(parent.id).toBe("native-upgrade-parent");
const seeded = (
await request("turn/start", {
threadId: parent.id,
input: [{ type: "text", text: "NATIVE_UPGRADE_SEED_ASSIGNMENTS" }],
})
).turn!;
await completed("native-upgrade-complete", "native-complete-turn");
const before = await request("thread/read", { threadId: parent.id, includeTurns: true });
const running = await request("thread/read", {
threadId: "native-upgrade-running",
includeTurns: true,
});
const finished = await request("thread/read", {
threadId: "native-upgrade-complete",
includeTurns: true,
});
expect(running.thread?.status.type).toBe("active");
expect(running.thread?.turns).toMatchObject([
{ id: "native-running-turn", status: "inProgress" },
]);
expect(finished.thread?.turns).toMatchObject([
{
id: "native-complete-turn",
status: "completed",
items: [{ type: "agentMessage", text: "NATIVE_UPGRADE_PENDING_RESULT" }],
},
]);
const incidental = (await request("thread/start", { cwd: process.cwd() })).thread!;
expect(incidental.id).not.toBe(parent.id);
expect(incidental.parentThreadId).toBeUndefined();
expect(incidental.source).toBe("unknown");
expect(incidental.turns).toEqual([]);
const incidentalTurn = (
await request("turn/start", {
threadId: incidental.id,
input: [{ type: "text", text: "An unrelated turn while seeding is still active." }],
})
).turn!;
await completed(incidental.id, incidentalTurn.id);
expect(await request("thread/read", { threadId: parent.id, includeTurns: true })).toEqual(before);
fs.writeFileSync(phaseFile, JSON.stringify({ phase: "handoff" }));
await request("turn/interrupt", { threadId: parent.id, turnId: seeded.id });
await completed(parent.id, seeded.id);
const interrupted = (await request("thread/read", { threadId: parent.id, includeTurns: true }))
.thread!;
expect(interrupted.status.type).toBe("idle");
expect(interrupted.turns.find((turn) => turn.id === seeded.id)?.status).toBe("interrupted");
expect(interrupted.turns.some((turn) => turn.status === "inProgress")).toBe(false);
expect(
await request("thread/read", { threadId: "native-upgrade-running", includeTurns: true }),
).toEqual(running);
expect(
await request("thread/read", { threadId: "native-upgrade-complete", includeTurns: true }),
).toEqual(finished);
const messages = fs
.readFileSync(logFile, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(messages.filter((entry) => entry.direction === "fixture-error")).toEqual([]);
const interruptRequest = messages.find(
(entry) => entry.direction === "request" && entry.method === "turn/interrupt",
);
const interruptResponse = messages.findIndex(
(entry) => entry.direction === "response" && entry.id === interruptRequest.id,
);
const interruptNotification = messages.findIndex(
(entry) =>
entry.method === "turn/completed" &&
entry.params?.threadId === parent.id &&
entry.params?.turn?.id === seeded.id,
);
expect(interruptResponse).toBeGreaterThanOrEqual(0);
expect(interruptNotification).toBeGreaterThan(interruptResponse);
for (const phase of ["close-loaded", "close-gone"]) {
fs.writeFileSync(phaseFile, JSON.stringify({ phase }));
const prompt = `Continue the synthetic native upgrade fixture: ${phase}.`;
const closing = (
await request("turn/start", {
threadId: parent.id,
input: [{ type: "text", text: prompt }],
})
).turn!;
await notificationWhere(
(entry) =>
entry.method === "item/completed" &&
entry.params.threadId === parent.id &&
entry.params.turnId === closing.id &&
entry.params.item?.tool === "closeAgent" &&
entry.params.item.status === "completed",
);
const incidentalClosePhase = (
await request("turn/start", {
threadId: incidental.id,
input: [{ type: "text", text: "An unrelated turn with no close request." }],
})
).turn!;
const incidentalOutcome = await notificationWhere(
(entry) =>
entry.params.threadId === incidental.id &&
((entry.method === "turn/completed" && entry.params.turn?.id === incidentalClosePhase.id) ||
(entry.method === "item/completed" &&
entry.params.turnId === incidentalClosePhase.id &&
entry.params.item?.tool === "closeAgent")),
);
expect(incidentalOutcome.method, `${phase}: an incidental turn must not request closure`).toBe(
"turn/completed",
);
expect(incidentalOutcome.params.turn?.status).toBe("completed");
const refused = (
await request("turn/start", {
threadId: incidental.id,
input: [{ type: "text", text: prompt }],
})
).turn!;
expect((await completed(incidental.id, refused.id)).params.turn?.status).toBe("failed");
expect(
notifications.some(
(entry) =>
entry.params.threadId === incidental.id &&
entry.params.turnId === refused.id &&
entry.params.item?.tool === "closeAgent",
),
).toBe(false);
const waitingParent = (
await request("thread/read", { threadId: parent.id, includeTurns: true })
).thread!;
expect(waitingParent.turns.find((turn) => turn.id === closing.id)?.status).toBe("inProgress");
expect(
(await request("thread/read", { threadId: "native-upgrade-running" })).thread?.status.type,
).toBe("active");
const loaded = await request("thread/loaded/list");
expect(loaded.nextCursor).toBeNull();
expect(loaded.data?.filter((id) => id === parent.id)).toHaveLength(1);
expect(loaded.data?.filter((id) => id === "native-upgrade-running")).toHaveLength(
phase === "close-loaded" ? 1 : 0,
);
expect(loaded.data).not.toContain("native-upgrade-complete");
expect((await completed(parent.id, closing.id)).params.turn?.status).toBe("completed");
const confirmed = (await request("thread/read", { threadId: parent.id, includeTurns: true }))
.thread!;
expect(confirmed.turns.find((turn) => turn.id === closing.id)?.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "agentMessage", text: "NATIVE_UPGRADE_CLOSE_OBSERVED" }),
]),
);
}
const finalMessages = fs
.readFileSync(logFile, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(
finalMessages
.filter((entry) => entry.direction === "fixture-error")
.map((entry) => entry.message),
).toEqual([
"Native close confirmation is already pending on this connection",
"Native close confirmation is already pending on this connection",
]);
});

View file

@ -154,6 +154,7 @@ export const databaseWorkerExtensionTestFiles = [
"extensions/codex/src/app-server/event-projector.output-fidelity.native.test.ts",
"extensions/codex/src/app-server/inference-proxy.native.test.ts",
"extensions/codex/src/app-server/settled-turn-finalizer.native.test.ts",
"extensions/codex/src/app-server/thread-lifecycle.assignment-rotation.test.ts",
"extensions/codex/src/app-server/thread-lifecycle.native.test.ts",
"extensions/codex/src/app-server/thread-lifecycle.binding.test.ts",
"extensions/codex/src/app-server/thread-lifecycle.native-config.test.ts",