fix(durable): pi.live version 3 gives a stored run without turns a count of 0

A database an earlier unreleased build wrote at pi.live version 2 has a run
without run.turns; a run in flight across the upgrade then counted NaN turns
and failed its next tool round with "Value contains a non-finite number and
is not strict JSON". Version 3's migrate adds turns: 0 wherever it is
missing.
This commit is contained in:
Mario Zechner 2026-10-28 14:27:53 +00:00
parent 2eb6a8d15f
commit 018fdcf43e
4 changed files with 44 additions and 9 deletions

View file

@ -96,7 +96,7 @@
- A call to a tool its request did not offer gets a `pi.tool` task like any call, flagged `unoffered: true` in its input and its `pi.live.tools` slot, instead of a result the generation wrote without one: the task resolves nothing (a deferred tool the agent has still never runs), settles `tool_unavailable`, and runs `afterTool`, whose `control` then counts toward the round (one such call no longer prevents `terminate`). Its slot starts `pending` with a `taskId` instead of `done`, and its result entry lands in a later commit than the tool-calling answer. Controls are applied in call order. `pi.tool` is version 3 and `pi.generation` version 2 (a sequential round keeps such calls pending until their turn): an older Harness leaves both blocked (`task_too_old`) instead of resolving the flagged name ([#11043](https://github.com/earendil-works/pi/issues/11043))
- `SubmissionRecord` gains a `type: "continue"` variant (`placed` without `entry`, `done` with `answer`, `unanswered`), so narrowing a record by `status` alone no longer guarantees an `entry`: check `type === "input"` too. A generation whose context does not end in a user message or tool results, system messages aside, now ends its run `unanswered` with the new `RunFailureReason` `nothing_to_answer` without a request; an empty conversation no longer faults its generation. `ConversationHandle.submit()` takes any `SubmissionDraft` (was `InputSubmissionDraft`), as `Conversation.submit()` does ([#11043](https://github.com/earendil-works/pi/issues/11043))
- `RunFailureReason` (`pi.live.runFailure.reason`) gains `nothing_to_answer` and `turn_limit`, also an `unanswered` submission's `reason`: exhaustive switches over it need cases ([#11043](https://github.com/earendil-works/pi/issues/11043))
- `pi.live.run` has a required `turns` field, the run's tool rounds so far; code that writes `run` itself sets it (`turns: 0`). The unreleased `pi.live` version 2 `migrate` adds `turns: 0` to a stored version 1 run; a database written by an unreleased build at version 2 without `run.turns` is not supported (its run counts `NaN` turns) ([#11043](https://github.com/earendil-works/pi/issues/11043))
- `pi.live.run` has a required `turns` field, the run's tool rounds so far; code that writes `run` itself sets it (`turns: 0`). `pi.live` is version 3: its `migrate` adds `turns: 0` to a stored run without it, version 1 or a version 2 an earlier unreleased build wrote, whose run in flight otherwise failed its next tool round with "Value contains a non-finite number" ([#11043](https://github.com/earendil-works/pi/issues/11043))
### Added

View file

@ -4617,10 +4617,10 @@ type CompactionStatus = {
| field | value |
|---|---|
| kind | `pi.live` |
| version | `2` |
| version | `3` |
| scope/history/fork | conversation, `latest`, `initial` |
| `initial()` | `{}` |
| `migrate()` | from version 1: `reason` of each `compactions` status and of `compactionFailure` renamed `trigger`, and `run.turns` set to `0` (section 3.6); `run.first`, `run.afterRun`, and `runFailure.first` are optional and need no step |
| `migrate()` | from version 1: `reason` of each `compactions` status and of `compactionFailure` renamed `trigger`; from versions 1 and 2: `run.turns` set to `0` where it is missing (section 3.6); `run.first`, `run.afterRun`, and `runFailure.first` are optional and need no step |
| checkpoint | complete base whenever nothing runs: `generation` absent and no `running` tool or nested slot |
| view mount | `docs["pi.live"]` |
| created | with every Harness conversation (section 2.2) |

View file

@ -193,19 +193,20 @@ export type LiveState = {
export const LiveDoc = defineDoc<LiveState>({
kind: "pi.live",
version: 2,
version: 3,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({}),
// Version 1 named a compaction's trigger `reason`, in `compactions` and `compactionFailure`, and had no `run.turns`:
// a run in flight across the upgrade counts its turns from 0.
// Version 1 named a compaction's trigger `reason`, in `compactions` and `compactionFailure`. Versions 1 and 2 (as
// builds before `run.turns` stored it) may lack `run.turns`: a run in flight across the upgrade counts its turns
// from 0. Version 2 already names the trigger `trigger`, so renaming leaves it alone.
migrate: (value) => {
const live = value as { run?: JsonObject; compactions?: JsonObject[]; compactionFailure?: JsonObject };
const { run, compactions, compactionFailure } = live;
return {
...value,
...(run === undefined ? {} : { run: { ...run, turns: 0 } }),
...(run === undefined ? {} : { run: { ...run, turns: typeof run.turns === "number" ? run.turns : 0 } }),
...(compactions === undefined ? {} : { compactions: compactions.map(renameReason) }),
...(compactionFailure === undefined ? {} : { compactionFailure: renameReason(compactionFailure) }),
} as LiveState;

View file

@ -2454,7 +2454,7 @@ describe("per-model compaction overrides", () => {
});
});
describe("pi.live version 2", () => {
describe("pi.live migrations", () => {
it("reads a version 1 value with each compaction's `reason` as `trigger`, and stores it migrated on its next write", async () => {
const storage = new MemoryStorage();
const session = createSession(storage);
@ -2505,9 +2505,43 @@ describe("pi.live version 2", () => {
(await tx.doc(LiveDoc, conversationId)).compactions![0]!.attempt = 3;
}, context);
expect(await stored()).toMatchObject({
version: 2,
version: 3,
value: { compactions: [{ trigger: "threshold", attempt: 3 }], compactionFailure: { trigger: "manual" } },
});
await session.close(context);
});
// #11043: an unreleased build stored version 2 without `run.turns`; a run in flight then counted NaN turns.
it("gives a version 2 run without turns a count of 0", async () => {
const storage = new MemoryStorage();
const session = createSession(storage);
const LiveV2 = defineDoc<JsonObject>({
kind: "pi.live",
version: 2,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({}),
checkpointWhen: () => false,
});
const conversationId = await session.commit(
async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id,
context,
);
await session.commit(async (tx) => {
const live = await tx.doc(LiveV2, conversationId);
live.run = { taskId: 9, inputs: [4] };
live.compactions = [{ taskId: 7, trigger: "threshold", blocking: true, attempt: 2 }];
}, context);
expect(await session.snapshot(LiveDoc, conversationId, context)).toEqual({
run: { taskId: 9, inputs: [4], turns: 0 },
compactions: [{ taskId: 7, trigger: "threshold", blocking: true, attempt: 2 }],
});
// A version 2 run that has a count keeps it.
await session.commit(async (tx) => {
(await tx.doc(LiveV2, conversationId)).run = { taskId: 9, inputs: [4], turns: 3 };
}, context);
expect((await session.snapshot(LiveDoc, conversationId, context))?.run?.turns).toBe(3);
await session.close(context);
});
});