diff --git a/src/agent/subagents/runner.ts b/src/agent/subagents/runner.ts index bf0c4c5..1411f50 100644 --- a/src/agent/subagents/runner.ts +++ b/src/agent/subagents/runner.ts @@ -70,16 +70,12 @@ export interface JobRunnerDeps { * flipped); rejects when delivery fails unrecoverably. */ deliver: (steer: SubagentSteerMessage) => Promise; - /** - * Settings-resolved engine per role (model slots wave, 2026-09-20): the - * role-override → slot → factory-pin chain, read FRESH per job (the - * server wiring passes effectiveSettings-based resolution). Closes the - * old gap where per-role settings overrides never reached job engines. - * Test overrides (below) still win; absent → config pins. - */ - /** Settings-resolved engine per role — BYOK (owner, 2026-09-22): the - * chain (role override → slot → NOTHING) may end empty → undefined; - * runJob then refuses the spawn honestly instead of guessing. */ + /** Settings-resolved engine per role (model slots wave, 2026-09-20): + * the role-override → slot chain, read FRESH per job (the server + * wiring passes effectiveSettings-based resolution). Test overrides + * (below) still win. BYOK (owner, 2026-09-22): the chain may end + * EMPTY → undefined; runJob then refuses the spawn honestly instead + * of guessing a model. */ engineFor?: (role: ModelRole) => PiEngine | undefined; /** The search seam (search port wave, 2026-09-20): resolves the LIVE * provider fresh per job — server/search.ts composition injected by @@ -205,16 +201,44 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner { const engine = pickEngine(job.kind); if (!engine) { // BYOK per-need refusal (owner, 2026-09-22): this kind's tier has - // no model — the job record surfaces the honest error (dock + - // teacher read it), and no engine-less loop ever starts. + // no model. Refuse through the SAME convention as every other + // failed job — record the honest error (no status flip: + // finalizeJob owns the terminal flip after delivery), then steer + // the kind's failure shape through finishJob so the wake + // pipeline resumes the lesson and the teacher reads the refusal + // instead of waiting forever on a job it already dispatched. const tier = ROLE_SLOT[KIND_ROLE[job.kind]]; const envelope = modelsNotConfigured([tier]); + const reason = `${envelope.error} (missing: ${tier})`; console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`); - jobStore.updateJob(jobId, { - status: "error", - error: `${envelope.error} (missing: ${tier})`, - activity: "failed", - }); + if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason }); + const resolved = resolveInput(job, input) as + | ResearchToolInput + | DemoToolInput + | VisualToolInput + | PdfToolInput + | undefined; + const pdf = resolved as PdfToolInput | undefined; + const failure: ResearchResult | DemoResult | VisualResult | PdfResult = + job.kind === "research" + ? { brief: `Research failed: ${reason}`, sources: [] } + : job.kind === "demo" + ? { + title: typeof (resolved as DemoToolInput | undefined)?.title === "string" && (resolved as DemoToolInput).title.trim() ? (resolved as DemoToolInput).title : "Interactive demo", + html: "", + tested: true, + notes: `FAILED: ${reason}`, + } + : job.kind === "visual" + ? { svg: "", notes: `FAILED: ${reason}` } + : { + path: typeof pdf?.path === "string" ? pdf.path : "", + question: typeof pdf?.question === "string" ? pdf.question : "", + brief: `PDF failed: ${reason}`, + pagesRead: [], + pagesConsulted: [], + }; + await finishJob(jobId, failure); return; } try { diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index de85f93..3c60738 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -1373,9 +1373,21 @@ describe("BYOK job refusal (no factory floor)", () => { await runner.runJob(job.id); const done = jobStore.getJob(job.id)!; + // The refusal lands through the SAME convention as every other + // failed job (BYOK review fix, Teacher stream 2026-09-22): the kind's + // failure result steers the teacher — a dispatched job is never + // stranded waiting for a result that will never come — and + // finalizeJob flips the record to error after the delivery. assert.equal(done.status, "error", "the job lands error, never a phantom run"); assert.match(done.error ?? "", /No model configured/); assert.match(done.error ?? "", /missing: cheap/); - assert.equal(delivery.steers.length, 0, "no result steer — nothing ran"); + assert.equal(delivery.steers.length, 1, "the refusal STEERED — the teacher is not stranded"); + const text = delivery.steers[0]!.parts[0]; + assert.ok( + text.type === "text" && text.text.includes("Research failed: No model configured"), + "the steer carries the kind's failure shape", + ); + const result = done.result as ResearchResult; + assert.equal(result.sources.length, 0, "the failure result matches the research convention"); }); });