Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a170380
chore(models): refresh stale model defaults (NodeVoice)
HomenShum Oct 14, 2026
32c14de
fix(models): keep worker.model as passed; connected migration test; p…
HomenShum Oct 14, 2026
59099aa
fix(models): guard new planning workers at spawn; per-role request-sh…
HomenShum Oct 14, 2026
4d2e30b
fix(models): migrate stored planning-worker models; real pricing agre…
HomenShum Oct 14, 2026
8ee4eed
fix(models): completeV3Worker no longer restores a retired id on exec…
HomenShum Oct 14, 2026
aa271b1
fix(models): pricing agreement test covers README table and chart DAT…
HomenShum Oct 14, 2026
a9f4da1
Merge origin/main into claude/model-refresh-nodevoice
HomenShum Oct 15, 2026
40bca69
fix(models): bound the retired-model migration pages by bytes; prod-t…
HomenShum Oct 15, 2026
becf8b3
fix(models): run the Convex CLI without a shell; resolve stored execu…
HomenShum Oct 15, 2026
151162e
Merge remote-tracking branch 'origin/main' into claude/model-refresh-…
HomenShum Oct 15, 2026
d13d3e8
fix(models): require an explicit deployment in the runbook script; sh…
HomenShum Oct 15, 2026
37f29d5
fix(models): runbook script refuses to run when a deploy key would ov…
HomenShum Oct 15, 2026
354adf7
fix(models): runbook script refuses --env-file before spawning the Co…
HomenShum Oct 15, 2026
91c3fd3
fix(models): runbook script spawns the Convex CLI with the deploy-key…
HomenShum Oct 15, 2026
c5d0cc0
fix(models): runbook deploy-key preflight uses dotenv's own grammar; …
HomenShum Oct 15, 2026
596fd7e
fix(models): createRoom/setModel and the local server throw on an unk…
HomenShum Oct 15, 2026
868f788
fix(models): requestedModel refuses non-string and falsy ids; hosted …
HomenShum Oct 16, 2026
a258f81
revert(models): restore write-path normalization of unknown model ids…
HomenShum Oct 16, 2026
5584776
test(models): pin D1, an unknown model id is stored as the default on…
HomenShum Oct 16, 2026
2d555a0
fix(models): guard runbook step 1 like the migration; correct the Con…
HomenShum Oct 16, 2026
e15b20c
docs(changelog): roomModelMigration.test.ts has 24 tests at head, not 23
HomenShum Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,68 @@
# NodeVoice changelog

## 2026-10-14 — Remove the retired gpt-4.1-nano router row

OpenAI retires `gpt-4.1-nano` on 2026-10-23. The room model picker no longer
offers it, rooms that already stored it are rewritten to `gpt-5.4-mini` by
`rooms:migrateRetiredRoomModels` (audit rows in `roomModelMigrations`), and every
request the coordinator builds from `room.model` (the turn, the human-steer
interpreter, the planning-worker fallback) resolves it through `validModel`.
New planning workers also store `validModel(room.model)` at spawn, so a host
steer in a room that has not been migrated yet does not create a worker that
sends the retired id. `watchRoom` serializes `validModel(room.model)`, so until
the migration runs the picker and the cost panels show the model the coordinator
actually sends. `gpt-5-nano` / `gpt-5-mini` rows are marked "retires
2026-12-11" (in the dropdown options, not only the hover title) and are removed
in a follow-up PR through the same migration. The `gpt-5.4-mini` literals in
`src/live/pipeline.ts` and `src/providers/openai.ts` now import `DEFAULT_MODEL`. `scripts/model-eval.mjs` carries
`PRICING_AS_OF` (the date its per-M prices were typed, not re-verified). Spec:
model-defaults SPEC Part A section A.2 (NodeVoice).

**Planning workers.** Worker rows created BEFORE this change store `gpt-4.1-nano`
too, and `retryV3Worker` copies the stored model forward. `runV3Worker` resolves an
`execution_plan` row through `validModel(worker.model ?? room.model)`, so a retry
before the migration still requests `gpt-5.4-mini`; the stored rows would
nonetheless keep the retired id, so `rooms:migrateRetiredRoomModels` also
pages `workers` (`{"table":"workers"}`): `execution_plan` rows whose model is not
in `ROUTER_MODELS` become `gpt-5.4-mini` and get an audit row (`workerId` set).
`web_research` rows are never touched, so the `OPENAI_WEB_MODEL` caller argument
is preserved. Pages are 6 rows (`MIGRATION_PAGE_SIZE`): a room's `goal` has no length
cap, so a page of 1 MiB documents read twice (page, then `patch`) must stay under
Convex's 16 MiB per-mutation read limit and 16 MiB write limit (the test also holds writes under a stricter 8 MiB margin). After the deploy run
`node scripts/migrate-retired-models.mjs --prod`, which walks every page of both tables
(a bare `npx convex run` targets the DEV deployment, so the script refuses to spawn unless
the arguments name a deployment: `--prod`, `--deployment-name`, `--preview-name`, or `--url`
with `--admin-key`; it also refuses while `CONVEX_DEPLOY_KEY` / `CONVEX_DEPLOYMENT_TOKEN` is set in the environment,
`.env.local` or `.env` (`KEY=v` and dotenv's `KEY: v`) and refuses `--env-file` (it can carry a key), because the Convex CLI then ignores `--prod` and migrates the key's deployment; whatever that preflight misses, the CLI is spawned with both variables set to `""`, which dotenv never overrides and the CLI reads as unset, so a file key cannot take effect; it also fails on the CLI's "Ignoring `--prod`" warning); run it again and expect `migrated: 0` for both tables. BEFORE the removing
deploy, record the running production rooms with `node scripts/migrate-retired-models.mjs --live-rooms --prod`
(SPEC A.2.5: live rooms are checked before removal). It runs `convex data rooms --format jsonl --limit 100000` through the same
guards as the migration (`convex data` shares the CLI's deployment selection, so a visible deploy key would otherwise make it list another
deployment's rooms) and prints the count and codes of the `"running":true` rooms. `convex data` lists only the newest `--limit`
rooms, rooms are never deleted, and `jsonl` prints no truncation warning, so the script refuses a result that fills the limit.

**Pricing sources (SPEC A.0.6).** The voice/Realtime unit prices at
`README.md:1206-1209` (`gpt-realtime`, `gpt-4o-mini-tts`, `whisper-1`,
`gpt-4o-mini-transcribe`) are as of 2026-07-04: typed in commit `40cd6b6`, the same
day as the router prices, and not re-read from the vendor since. They are not router
ids and the repo has no second source, so no agreement test covers them.

**Pricing agreement (SPEC A.0.6).** `ROUTER_PRICING_AS_OF` (2026-07-04) dates the
`expectedCostUsd` figures; a test recomputes every recorded call's cost from the
eval script's per-M table and checks it against `docs/model-eval-results.json` and
`ROUTER_MODELS`. A second test reads the README `$/turn` table and the
`scripts/generate-chart.mjs` DATA rows (both frozen, read-only) and checks each
against `byModel.costPerTurn` at the 2 significant figures they print.

**Validation:** `tests/roomModelMigration.test.ts` (24 tests) fails on the
previous code (request carried `gpt-4.1-nano`) and passes now, with one
request-shape test per changed coordinator role (turn, steer interpreter,
planning worker spawned through the real steer path). `completeV3Worker` no
longer writes a model outside `ROUTER_MODELS` onto an `execution_plan` row, so an
in-flight planning call cannot undo the workers migration by echoing the retired
id back. `npm run check`,
`check:client`, `check:convex`, `check:citations` and `npm test` pass. No
provider calls made.

## 2026-10-07 — Make setup and proof limits reachable

Developers and coding agents can reach the existing `HANDOFF.md` from the README
Expand Down
10 changes: 5 additions & 5 deletions convex/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { v } from "convex/values";
import { internalAction, action } from "./_generated/server";
import { internal, api } from "./_generated/api";
import { agentForSlot, nextSlot, estimateSpeechMs, deriveHumanSteeringIntentFallback, validProfile, coerceCountTurn, type CountTask, type Slot } from "./shared";
import { agentForSlot, nextSlot, estimateSpeechMs, deriveHumanSteeringIntentFallback, validProfile, validModel, coerceCountTurn, type CountTask, type Slot } from "./shared";
import {
generateAgentTurn,
interpretHumanSteer as interpretHumanSteerWithModel,
Expand All @@ -18,7 +18,7 @@ import type { Id } from "./_generated/dataModel";
* The commit re-validates floor/token server-side (the LLM+TTS work here is
* seconds long and non-transactional) and reports whether it landed.
*/
async function produceTurn(
export async function produceTurn(
ctx: any,
roomId: Id<"rooms">,
room: any,
Expand All @@ -38,7 +38,7 @@ async function produceTurn(
? coerceCountTurn({ text: String(countTask.next), speechAct: "task_action", done: countTask.next >= countTask.target }, countTask)
: await generateAgentTurn({
goal: room.goal,
model: room.model,
model: validModel(room.model), // stale stored ids (retired models) fall back to DEFAULT_MODEL
profile: validProfile(room.profile),
persona: agent.persona,
selfName: agent.name,
Expand Down Expand Up @@ -124,7 +124,7 @@ export const interpretHumanSteer = internalAction({
intent = await interpretHumanSteerWithModel({
text: args.text,
currentGoal: room.goal,
model: room.model,
model: validModel(room.model),
profile: validProfile(room.profile),
transcript,
});
Expand Down Expand Up @@ -191,7 +191,7 @@ export const runV3Worker = internalAction({
const output =
worker.kind === "web_research"
? await runWebResearchWorker({ goal: goal.title, task: taskTitle, roomGoal: room.goal, model: worker.model })
: await runPlanningWorker({ goal: goal.title, task: taskTitle, roomGoal: room.goal, model: worker.model ?? room.model });
: await runPlanningWorker({ goal: goal.title, task: taskTitle, roomGoal: room.goal, model: validModel(worker.model ?? room.model) }); // execution_plan models only ever come from validModel(room.model) (rooms.ts spawn/retry), so a stale stored id falls back to DEFAULT_MODEL here too; web_research above is untouched

await ctx.runMutation(internal.rooms.completeV3Worker, {
workerId: args.workerId,
Expand Down
67 changes: 64 additions & 3 deletions convex/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
activeSlots,
CAPABILITY_PROFILES,
ROUTER_MODELS,
DEFAULT_MODEL,
DEFAULT_GOAL,
agentIndexFromSlot,
isAgentSlot,
Expand Down Expand Up @@ -176,7 +177,7 @@ async function spawnAgentOsGoal(
? [{ kind: "deterministic_count", title: "Prepare exact count sequence", model: undefined as string | undefined }]
: [
{ kind: "web_research", title: "Research current external context", model: "gpt-4.1-mini" },
{ kind: "execution_plan", title: "Draft execution plan", model: room.model },
{ kind: "execution_plan", title: "Draft execution plan", model: validModel(room.model) },
];
for (const spec of workerSpecs) {
const budgetBlocked = workersUsed >= policy.budgetMaxWorkers;
Expand Down Expand Up @@ -307,7 +308,7 @@ async function serializeRoom(ctx: QueryCtx, roomId: Id<"rooms">) {
}),
state: {
goal: room.goal,
model: room.model,
model: validModel(room.model), // an unmigrated room still stores a retired id; show what the coordinator actually sends
agentCount: roomAgentCount(room),
profile: roomProfile(room),
floorOwner: room.floorOwner,
Expand Down Expand Up @@ -417,6 +418,64 @@ export const getV3WorkerRaw = internalQuery({
},
});

/**
* Page size. Convex caps a mutation at 16 MiB read and 16 MiB written, and a document at 1 MiB
* (docs.convex.dev/production/state/limits, read 2026-10-16);
* `goal` has no length cap, so a room can be ~1 MiB. Each stale document is read twice (the page,
* then `patch`) and written once: 6 x 1 MiB x 2 = 12 MiB read, 6 MiB written, with room left for
* the audit inserts. Do not raise this without re-deriving that bound. (maximumBytesRead is not
* used: a byte-limited page can come back incomplete with pageStatus SplitRequired, and this
* migration must never skip rows.)
*/
export const MIGRATION_PAGE_SIZE = 6;

/**
* Rewrite stored model ids that are no longer in ROUTER_MODELS (a retired id such as
* gpt-4.1-nano, OpenAI 2026-10-23) to DEFAULT_MODEL and record {roomId, [workerId], from, to, at}.
* `validModel` only runs on writes, so without this a stale value reaches OpenAI as-is.
*
* Two tables hold such a value: `rooms.model`, and `workers.model` on `execution_plan` rows
* (spawnAgentOsGoal stores the room's model there and retryV3Worker copies it forward, so a
* retry of a pre-change planning worker would otherwise resend the retired id). `web_research`
* workers are left alone: their model is the OPENAI_WEB_MODEL caller argument, not a router row.
*
* Paged, and run against the PRODUCTION deployment once AFTER the deploy that removes the row:
* `node scripts/migrate-retired-models.mjs --prod` walks every page of both tables (a bare
* `npx convex run` targets the dev deployment). Run it a second time: it must report
* `migrated: 0` for both tables. Until the rooms pass finishes, the coordinator read-side
* guard (validModel) covers unmigrated rooms.
*/
export async function retireRoomModels(ctx: MutationCtx, cursor: string | null) {
const page = await ctx.db.query("rooms").paginate({ numItems: MIGRATION_PAGE_SIZE, cursor });
let migrated = 0;
for (const room of page.page) {
if (ROUTER_MODELS.some((m) => m.id === room.model)) continue;
const from = room.model;
await ctx.db.patch(room._id, { model: DEFAULT_MODEL });
await ctx.db.insert("roomModelMigrations", { roomId: room._id, from, to: DEFAULT_MODEL, at: Date.now() });
migrated += 1;
}
return { migrated, isDone: page.isDone, continueCursor: page.continueCursor };
}

export async function retireWorkerModels(ctx: MutationCtx, cursor: string | null) {
const page = await ctx.db.query("workers").paginate({ numItems: MIGRATION_PAGE_SIZE, cursor });
let migrated = 0;
for (const worker of page.page) {
const from = worker.model;
if (worker.kind !== "execution_plan" || !from || ROUTER_MODELS.some((m) => m.id === from)) continue;
await ctx.db.patch(worker._id, { model: DEFAULT_MODEL });
await ctx.db.insert("roomModelMigrations", { roomId: worker.roomId, workerId: worker._id, from, to: DEFAULT_MODEL, at: Date.now() });
migrated += 1;
}
return { migrated, isDone: page.isDone, continueCursor: page.continueCursor };
}

export const migrateRetiredRoomModels = internalMutation({
args: { table: v.optional(v.union(v.literal("rooms"), v.literal("workers"))), cursor: v.optional(v.union(v.string(), v.null())) },
handler: (ctx, args) => (args.table === "workers" ? retireWorkerModels : retireRoomModels)(ctx, args.cursor ?? null),
});

export const insertTraceInternal = internalMutation({
args: { roomId: v.id("rooms"), kind: v.string(), summary: v.string(), payload: v.any() },
handler: async (ctx, args) => {
Expand Down Expand Up @@ -479,7 +538,9 @@ export const completeV3Worker = internalMutation({
await ctx.db.patch(args.workerId, {
status: "completed",
summary: args.summary.slice(0, 500),
...(args.model ? { model: args.model } : {}),
// An in-flight planning call echoes the id it was started with. If the worker migration ran meanwhile,
// writing a retired id back would undo it and retryV3Worker would copy it. web_research rows keep the echo.
...(args.model && (worker.kind !== "execution_plan" || ROUTER_MODELS.some((m) => m.id === args.model)) ? { model: args.model } : {}),
completedAt: now,
updatedAt: now,
});
Expand Down
11 changes: 11 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,15 @@ export default defineSchema({
})
.index("by_room", ["roomId", "createdAt"])
.index("by_goal", ["goalId", "createdAt"]),

// Audit rows written by rooms:migrateRetiredRoomModels when a stored room.model, or an
// execution_plan worker.model (workerId set), is no longer in ROUTER_MODELS
// (e.g. gpt-4.1-nano, retired by OpenAI 2026-10-23).
roomModelMigrations: defineTable({
roomId: v.id("rooms"),
workerId: v.optional(v.id("workers")),
from: v.string(),
to: v.string(),
at: v.number(),
}).index("by_room", ["roomId", "at"]),
});
Loading
Loading