Skip to content

Commit 03db31c

Browse files
committed
fix(runner): make turn_index a per-conversation-turn counter
1 parent 59e5874 commit 03db31c

3 files changed

Lines changed: 117 additions & 8 deletions

File tree

services/runner/src/engines/sandbox_agent/environment.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ import {
112112
import { hydrateHarnessSessionFromDurable } from "./session-continuity-durable.ts";
113113
import {
114114
eligibleAgentSessionId,
115-
nextTurnIndex,
116115
sessionContinuityStore,
117116
} from "./session-continuity.ts";
118117
import { projectScopeFor } from "./session-identity.ts";
@@ -943,11 +942,6 @@ export async function acquireEnvironment(
943942
const localSessionId = continuitySessionKey
944943
? `${continuitySessionKey}:${plan.harness}`
945944
: undefined;
946-
// The index THIS turn will occupy once it completes: recorded post-turn against the SAME
947-
// index read here, so a turn that authors turn N leaves the store agreeing with itself.
948-
environment.continuityTurnIndex = continuitySessionKey
949-
? nextTurnIndex(continuitySessionKey, continuityStore)
950-
: undefined;
951945
// The live sandbox id rides forward as a field on the turn-append row written at turn end
952946
// (see `appendSessionTurn` call in `runTurn`), not a separate pre-turn pointer PUT: the
953947
// turns table is append-only, so there is nothing to overwrite mid-conversation.

services/runner/src/engines/sandbox_agent/run-turn.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ import {
6363
import {
6464
appendSessionTurn,
6565
} from "./session-continuity-durable.ts";
66-
import { sessionContinuityStore } from "./session-continuity.ts";
66+
import {
67+
nextTurnIndex,
68+
sessionContinuityStore,
69+
} from "./session-continuity.ts";
6770
import { priorMessages } from "./transcript.ts";
6871
import { resolveRunUsage } from "./usage.ts";
6972

@@ -83,6 +86,12 @@ export async function runTurn(
8386
): Promise<AgentRunResult> {
8487
const { plan, logger, deps } = env;
8588
const sessionId = env.sessionId;
89+
const continuityStore = deps.sessionContinuityStore ?? sessionContinuityStore;
90+
// `turn_index` is a true conversation-turn counter, not an acquire counter: it advances once per completed turn across every environment serving the session.
91+
// The shared store advances only on `record()` (paused turns record nothing), so park-and-resume consumes one index; compute it at turn start because a warm environment serves many turns.
92+
env.continuityTurnIndex = sessionId
93+
? nextTurnIndex(sessionId, continuityStore)
94+
: undefined;
8695
// Reset the per-turn tool-call id record (the park folds the completed turn's ids into the
8796
// expected next-history fingerprint).
8897
env.lastTurnToolCallIds = [];

services/runner/tests/unit/session-keepalive-engine.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import {
1919
runTurn,
2020
type SandboxAgentDeps,
2121
} from "../../src/engines/sandbox_agent.ts";
22+
import { SessionContinuityStore } from "../../src/engines/sandbox_agent/session-continuity.ts";
2223

2324
interface FakeOptions {
2425
probeError?: Error;
2526
createOtelError?: Error;
27+
stopReasons?: string[];
2628
}
2729

2830
/** One fake otel run per createOtel call, recording which updates it handled. */
@@ -40,9 +42,11 @@ function fakeHarness(options: FakeOptions = {}) {
4042
sandboxDisposed: 0,
4143
sessionDestroyed: 0,
4244
permissionReplies: [] as Array<{ id: string; reply: string }>,
45+
appendedTurnIndexes: [] as number[],
4346
logs: [] as string[],
4447
runs: [] as FakeRun[],
4548
};
49+
const continuityStore = new SessionContinuityStore();
4650

4751
// Captured session-lifetime handlers, so tests can fire events/permissions at any moment
4852
// (during a turn, between turns).
@@ -53,6 +57,7 @@ function fakeHarness(options: FakeOptions = {}) {
5357

5458
const session = {
5559
id: "session-1",
60+
agentSessionId: "agent-session-1",
5661
onEvent(handler: (event: any) => void) {
5762
captured.onEvent = handler;
5863
},
@@ -65,7 +70,7 @@ function fakeHarness(options: FakeOptions = {}) {
6570
async prompt(_blocks: any) {
6671
calls.promptCount += 1;
6772
return {
68-
stopReason: "complete",
73+
stopReason: options.stopReasons?.[calls.promptCount - 1] ?? "complete",
6974
usage: { inputTokens: 1, outputTokens: 1 },
7075
};
7176
},
@@ -163,6 +168,11 @@ function fakeHarness(options: FakeOptions = {}) {
163168
return { kind: "deny" } as const;
164169
},
165170
}),
171+
sessionContinuityStore: continuityStore,
172+
hydrateHarnessSessionFromDurable: async () => {},
173+
appendSessionTurn: async (_sessionId, _harness, turnIndex) => {
174+
calls.appendedTurnIndexes.push(turnIndex);
175+
},
166176
};
167177

168178
return { calls, deps, captured };
@@ -173,6 +183,15 @@ const request: AgentRunRequest = {
173183
messages: [{ role: "user", content: "hello" }],
174184
};
175185

186+
const continuityRequest: AgentRunRequest = {
187+
...request,
188+
sessionId: "sess-1",
189+
streamId: "stream-1",
190+
telemetry: {
191+
exporters: { otlp: { headers: { authorization: "ApiKey abc" } } },
192+
} as any,
193+
};
194+
176195
function updateEvent(update: Record<string, unknown>) {
177196
return { payload: { update } };
178197
}
@@ -282,6 +301,93 @@ describe("acquireEnvironment / runTurn split", () => {
282301
});
283302
});
284303

304+
describe("conversation turn indexes", () => {
305+
it("advances on every completed turn served by the same warm environment", async () => {
306+
const { calls, deps } = fakeHarness();
307+
const acquired = await acquireEnvironment(continuityRequest, deps);
308+
assert.equal(acquired.ok, true);
309+
if (!acquired.ok) return;
310+
311+
for (let turn = 0; turn < 4; turn += 1) {
312+
const result = await runTurn(
313+
acquired.env,
314+
{
315+
...continuityRequest,
316+
messages: [{ role: "user", content: "turn " + turn }],
317+
},
318+
undefined,
319+
undefined,
320+
{ continuation: turn > 0 },
321+
);
322+
assert.equal(result.ok, true);
323+
}
324+
325+
assert.deepEqual(calls.appendedTurnIndexes, [0, 1, 2, 3]);
326+
await acquired.env.destroy();
327+
});
328+
329+
it("shares strictly increasing indexes across two environments for one session", async () => {
330+
const { calls, deps } = fakeHarness({
331+
stopReasons: ["paused", "complete", "complete", "complete"],
332+
});
333+
const first = await acquireEnvironment(continuityRequest, deps);
334+
const second = await acquireEnvironment(continuityRequest, deps);
335+
assert.equal(first.ok, true);
336+
assert.equal(second.ok, true);
337+
if (!first.ok || !second.ok) return;
338+
339+
const parked = await runTurn(
340+
first.env,
341+
continuityRequest,
342+
undefined,
343+
undefined,
344+
{},
345+
);
346+
assert.equal(parked.stopReason, "paused");
347+
await runTurn(second.env, continuityRequest, undefined, undefined, {});
348+
await runTurn(first.env, continuityRequest, undefined, undefined, {
349+
continuation: true,
350+
});
351+
await runTurn(second.env, continuityRequest, undefined, undefined, {
352+
continuation: true,
353+
});
354+
355+
assert.deepEqual(calls.appendedTurnIndexes, [0, 1, 2]);
356+
await first.env.destroy();
357+
await second.env.destroy();
358+
});
359+
360+
it("does not consume an index until a paused conversation turn completes", async () => {
361+
const { calls, deps } = fakeHarness({
362+
stopReasons: ["paused", "complete"],
363+
});
364+
const acquired = await acquireEnvironment(continuityRequest, deps);
365+
assert.equal(acquired.ok, true);
366+
if (!acquired.ok) return;
367+
368+
const parked = await runTurn(
369+
acquired.env,
370+
continuityRequest,
371+
undefined,
372+
undefined,
373+
{},
374+
);
375+
assert.equal(parked.stopReason, "paused");
376+
assert.deepEqual(calls.appendedTurnIndexes, []);
377+
378+
const resumed = await runTurn(
379+
acquired.env,
380+
continuityRequest,
381+
undefined,
382+
undefined,
383+
{ continuation: true },
384+
);
385+
assert.equal(resumed.ok, true);
386+
assert.deepEqual(calls.appendedTurnIndexes, [0]);
387+
await acquired.env.destroy();
388+
});
389+
});
390+
285391
describe("session-lifetime listener demux (currentTurn swap)", () => {
286392
it("each turn's sink sees only its own events; between-turns events are dropped by decision", async () => {
287393
const { calls, deps, captured } = fakeHarness();

0 commit comments

Comments
 (0)