From 9d4cc48c3966f421402a520dbec6c4198f75ed89 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Mon, 26 Oct 2026 21:01:13 -0700 Subject: [PATCH] Record a model for background work a remote Planner ran Under HARNESS=remote an operator may leave MODEL unset, and the attached client chooses its own model. Every background worker recorded config.model as its artifact's model provenance, and the artifact validators refuse an empty model, so research, document descriptions, research briefs and History summaries all failed after their model work had finished, with "model is invalid" or "artifact is invalid". Artifacts now record "attached-client" when no model is configured. What a worker asks for is unchanged: an empty model still reaches the attached client as no request. Assistant-model: Claude Opus 5.5 (fast) Assistant-workflow: inline Assistant-duration: 1h32m converged, estimated unmeasured Assistant-verification: agent-browser manual scenario passed: on a local HARNESS=remote server with MODEL unset and a real attached Planner, /research completed and published a 7-source report; before the fix the same run failed with "model is invalid" Assistant-verification: red-before-fix passed: the new research-workspace test fails with "model is invalid" without the change Assistant-verification: bun test unit passed: GIT_CONFIG_GLOBAL=/dev/null bun test (6737 pass, 0 fail) Assistant-verification: typecheck and lint passed: bun run types and bun run ci User-preference: Do not reveal internal details of downstream deployments in public Chopin artifacts Co-authored-by: Alex Lavaee --- apps/server/src/jobs/document-summary.ts | 6 +++--- apps/server/src/jobs/research-brief.ts | 7 +++++-- apps/server/src/jobs/research-workspace.test.ts | 16 ++++++++++++++++ apps/server/src/jobs/research-workspace.ts | 8 ++++---- apps/server/src/jobs/version-summary.ts | 10 +++++++--- apps/server/src/jobs/worker-session.ts | 8 ++++++++ 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/server/src/jobs/document-summary.ts b/apps/server/src/jobs/document-summary.ts index 0b907a80b..7e6a25260 100644 --- a/apps/server/src/jobs/document-summary.ts +++ b/apps/server/src/jobs/document-summary.ts @@ -5,7 +5,7 @@ import { serialize } from "@chopin/dialect/serialize"; import { assert } from "@chopin/dialect/validate"; import { summaryAgent } from "../harness/agents"; -import { openWorkerSession } from "./worker-session"; +import { openWorkerSession, workerModel } from "./worker-session"; import type { Config } from "../config"; import type { DocumentTarget } from "../plan/service"; import type { JsonValue } from "../storage/model"; @@ -295,7 +295,7 @@ class HarnessSummaryEngine { } partials = reduced; } - return { description: partials[0]!, model: this.#config.model }; + return { description: partials[0]!, model: workerModel(this.#config.model) }; } finally { await worker.close(); } @@ -339,7 +339,7 @@ export function documentSummaryDefinition(options: DocumentSummaryOptions): JobD if (serialize(tree) !== target.source) throw new Error("Document source is not canonical."); let result = target.source.trim() ? await engine(execution, target.source) - : { description: "Empty document", model: options.config.model }; + : { description: "Empty document", model: workerModel(options.config.model) }; return { revision: execution.input.revision, sourceHash: execution.input.sourceHash, diff --git a/apps/server/src/jobs/research-brief.ts b/apps/server/src/jobs/research-brief.ts index 728af6caf..f3cebcf21 100644 --- a/apps/server/src/jobs/research-brief.ts +++ b/apps/server/src/jobs/research-brief.ts @@ -5,7 +5,7 @@ import { assertResearchContext, researchSources } from "../conversation-plan/res import { id, knownKeys, record, text, version } from "../conversation-plan/validation-fields"; import type { JsonValue } from "../storage/model"; import { type JobDefinition, type JobExecution, JobExecutionError } from "./registry"; -import { openWorkerSession } from "./worker-session"; +import { openWorkerSession, workerModel } from "./worker-session"; // Copilot requires a session ceiling of at least 30, even for a single short turn. const MAX_AI_CREDITS = 30; @@ -123,7 +123,10 @@ async function generate( if (!await credential.authorize() || signal.aborted) { throw new JobExecutionError("owner-unavailable"); } - return { ...(result.output as { brief: string; sourceIds: string[] }), model: config.model }; + return { + ...(result.output as { brief: string; sourceIds: string[] }), + model: workerModel(config.model), + }; } finally { signal.removeEventListener("abort", rejectAbort); await close?.(); diff --git a/apps/server/src/jobs/research-workspace.test.ts b/apps/server/src/jobs/research-workspace.test.ts index 495e2e048..0a7a65e76 100644 --- a/apps/server/src/jobs/research-workspace.test.ts +++ b/apps/server/src/jobs/research-workspace.test.ts @@ -784,3 +784,19 @@ describe("research workspace execution", () => { .rejects.toMatchObject({ progressReason: "source-validation-failed" }); }); }); + +it("research artifacts record the attached client when the operator configured no model", async () => { + let definition = researchEvidenceDefinition({ + config: { agent: true, model: "" }, + engine: async () => ({ findings: [], sources: [] }), + }); + let input = { workspaceId: "workspace", turnId: "turn", query: "Rate limiting a login endpoint" }; + let artifact = await definition.execute({ + job: { channelId: "channel" }, + input, + progress: async () => {}, + } as unknown as JobExecution); + + expect(artifact.model).toBe("attached-client"); + expect(parseResearchEvidenceArtifact(artifact as unknown as JsonValue)).toEqual(artifact); +}); diff --git a/apps/server/src/jobs/research-workspace.ts b/apps/server/src/jobs/research-workspace.ts index dba6416c3..54583b6d3 100644 --- a/apps/server/src/jobs/research-workspace.ts +++ b/apps/server/src/jobs/research-workspace.ts @@ -3,7 +3,7 @@ import * as limits from "@chopin/dialect/limits"; import { WEB_SEARCH_MAX_CALLS, WebSearchTimeoutError, webSearchTool } from "../harness/web-search"; import { JobExecutionError } from "./registry"; -import { openWorkerSession, type WorkerSession } from "./worker-session"; +import { openWorkerSession, workerModel, type WorkerSession } from "./worker-session"; import type { WebSearchFailureDiagnostic } from "../harness/web-search"; import type { researchAgent } from "../harness/agents"; import type { Config } from "../config"; @@ -1158,7 +1158,7 @@ export function researchEvidenceDefinition(options: ResearchEvidenceOptions): Jo return parseResearchEvidenceArtifact({ ...input, ...evidence, - model: options.config.model, + model: workerModel(options.config.model), }); }, }; @@ -1232,7 +1232,7 @@ export function researchAnswerDefinition(options: ResearchAnswerOptions): JobDef privateFindings: privateValue.findings, documentRevision: input.document.revision, documentSourceHash: input.document.sourceHash, - model: options.config.model, + model: workerModel(options.config.model), }); } @@ -1261,7 +1261,7 @@ export function researchAnswerDefinition(options: ResearchAnswerOptions): JobDef sources: evidence.sources, documentRevision: input.document.revision, documentSourceHash: input.document.sourceHash, - model: options.config.model, + model: workerModel(options.config.model), }); }, }; diff --git a/apps/server/src/jobs/version-summary.ts b/apps/server/src/jobs/version-summary.ts index c95be2df1..278020c68 100644 --- a/apps/server/src/jobs/version-summary.ts +++ b/apps/server/src/jobs/version-summary.ts @@ -3,7 +3,7 @@ import { parse } from "@chopin/dialect/parse"; import { serialize } from "@chopin/dialect/serialize"; import { historySummaryAgent } from "../harness/agents"; -import { openWorkerSession } from "./worker-session"; +import { openWorkerSession, workerModel } from "./worker-session"; import type { Config } from "../config"; import type { JsonValue, StoredDocumentVersion, SummarizeDocumentVersion } from "../storage/model"; import type { JobDefinition, JobExecution } from "./registry"; @@ -228,7 +228,7 @@ class HarnessVersionSummaryEngine { aborted, ]); let output = result.output as { summaries: { id: string; summary: string }[] }; - return { summaries: output.summaries, model: this.#config.historyModel }; + return { summaries: output.summaries, model: workerModel(this.#config.historyModel) }; } finally { await worker.close(); } @@ -274,7 +274,11 @@ export function versionSummaryDefinition(options: VersionSummaryOptions): JobDef wanted.set(version.id, version.sourceHash); } if (changes.length === 0) { - return { generatorVersion: 1, model: options.config.historyModel, summaries: [] }; + return { + generatorVersion: 1, + model: workerModel(options.config.historyModel), + summaries: [], + }; } let result = await engine(execution, changes); let summaries: VersionSummaryArtifact["summaries"] = []; diff --git a/apps/server/src/jobs/worker-session.ts b/apps/server/src/jobs/worker-session.ts index 73b48c510..7f5f1f7e6 100644 --- a/apps/server/src/jobs/worker-session.ts +++ b/apps/server/src/jobs/worker-session.ts @@ -12,6 +12,14 @@ export type WorkerAgent = { export type WorkerSession = { session: Session; close(): Promise }; +/** + * The model a worker's artifact records. Under `HARNESS=remote` the operator may configure none, and the + * attached client then chooses its own. + */ +export function workerModel(configured: string): string { + return configured || "attached-client"; +} + async function openAbortable( opening: Promise, aborted: Promise,