diff --git a/tests/broker-subscriptions.test.mjs b/tests/broker-subscriptions.test.mjs index cd8eef5cb..db8c2f76c 100644 --- a/tests/broker-subscriptions.test.mjs +++ b/tests/broker-subscriptions.test.mjs @@ -741,7 +741,7 @@ test("broker rejects an explicit unsubscribe that would queue behind a hung clea }); test("broker rolls back child threads inherited through a failed provisional claim", async (t) => { - const broker = startBroker("with-delayed-subagent"); + const broker = startBroker("with-resume-inherited-subagent"); t.after(() => broker.stop()); assert.equal(await broker.listening(), true, `broker never listened: ${broker.stderr()}`); @@ -754,8 +754,9 @@ test("broker rolls back child threads inherited through a failed provisional cla firstClient.destroy(); await waitForUnsubscribes(broker.statePath, [threadId]); - // The resume fails after 250 ms; the delayed child arrives at 100 ms while the - // claim is still open, so the claiming socket inherits it. + // The child, and the grandchild under it, arrive while this resume's claim is open, and + // the resume fails only after both — a chain the fixture drives off the resume itself, so + // no part of it depends on how fast this client got here. const secondClient = await connectClient(broker.socketPath); await assert.rejects( secondClient.request("thread/resume", { threadId, persistFullHistory: true }), diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 448adb8c9..24b155e93 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -372,6 +372,38 @@ rl.on("line", (line) => { if (BEHAVIOR === "permission-profiles-unsupported" && message.params.config) { throw new Error("unknown field config.default_permissions"); } + if (BEHAVIOR === "with-resume-inherited-subagent" && message.params.persistFullHistory === true) { + // A causal chain, not a stopwatch: the child arrives while THIS resume's claim is + // open, the grandchild under it, and only then does the resume fail. Hanging any of + // it off turn/start instead made the test depend on the client getting from + // turn/start to resume inside 100ms, which a loaded runner does not promise. + const parentThread = ensureThread(state, message.params.threadId); + setTimeout(() => { + const childState = loadState(); + const child = nextThread(childState, parentThread.cwd, true, parentThread.sandbox, { parentThreadId: parentThread.id }); + const childRecord = ensureThread(childState, child.id); + childRecord.name = "delayed-design-challenger"; + childState.subscriptions = [...new Set([...(childState.subscriptions || []), child.id])]; + saveState(childState); + const childTurnId = nextTurnId(childState); + send({ method: "thread/started", params: { thread: { ...buildThread(childRecord), name: childRecord.name, agentNickname: childRecord.name } } }); + send({ method: "turn/started", params: { threadId: child.id, turn: buildTurn(childTurnId) } }); + send({ method: "turn/completed", params: { threadId: child.id, turn: buildTurn(childTurnId, "completed") } }); + setTimeout(() => { + const nestedState = loadState(); + const grandchild = nextThread(nestedState, parentThread.cwd, true, parentThread.sandbox, { parentThreadId: child.id }); + const grandchildRecord = ensureThread(nestedState, grandchild.id); + grandchildRecord.name = "delayed-design-grandchild"; + nestedState.subscriptions = [...new Set([...(nestedState.subscriptions || []), grandchild.id])]; + saveState(nestedState); + send({ method: "thread/started", params: { thread: { ...buildThread(grandchildRecord), name: grandchildRecord.name, agentNickname: grandchildRecord.name } } }); + setTimeout(() => { + send({ id: message.id, error: { code: -32000, message: "forced resume failure after child arrival" } }); + }, 50); + }, 50); + }, 50); + break; + } if (BEHAVIOR === "with-delayed-subagent" && message.params.persistFullHistory === true) { state.nestedSubagentRequested = true; saveState(state); diff --git a/tests/locking.test.mjs b/tests/locking.test.mjs index eebc69559..a4761674e 100644 --- a/tests/locking.test.mjs +++ b/tests/locking.test.mjs @@ -142,8 +142,13 @@ test("stale legacy lock without process identity does not follow a reused PID fo const oldTime = new Date(Date.now() - 60000); fs.utimesSync(lockDir, oldTime, oldTime); + // Returning at all is the assertion, as above: with staleMs at 30s, a successor can only + // exist because the legacy lock was reclaimed on the spot. The budget is deliberately far + // wider than the work needs -- acquireLockSync() checks its deadline after a failed attempt, + // so under a loaded runner a single stalled attempt used to expire a 200ms budget in the + // moment between the reclaim and the retry that would have succeeded. const successor = acquireLockSync(lockDir, { - timeoutMs: 200, + timeoutMs: 5000, staleMs: 30000, retryDelayMs: 5, isProcessRunning: () => true