diff --git a/packages/durable/CHANGELOG.md b/packages/durable/CHANGELOG.md index 98f05d090..46658afba 100644 --- a/packages/durable/CHANGELOG.md +++ b/packages/durable/CHANGELOG.md @@ -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 diff --git a/packages/durable/docs/spec.md b/packages/durable/docs/spec.md index 72d630c1b..4f3795e33 100644 --- a/packages/durable/docs/spec.md +++ b/packages/durable/docs/spec.md @@ -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) | diff --git a/packages/durable/src/harness/live.ts b/packages/durable/src/harness/live.ts index d9696f46d..9888a402e 100644 --- a/packages/durable/src/harness/live.ts +++ b/packages/durable/src/harness/live.ts @@ -193,19 +193,20 @@ export type LiveState = { export const LiveDoc = defineDoc({ 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; diff --git a/packages/durable/test/harness-compaction.test.ts b/packages/durable/test/harness-compaction.test.ts index a473b825b..b60eb2dae 100644 --- a/packages/durable/test/harness-compaction.test.ts +++ b/packages/durable/test/harness-compaction.test.ts @@ -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({ + 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); + }); });