fix(runner): the BYOK refusal steers — a dispatched job is never stranded
The BYOK refusal path (engineFor came up empty) only flipped the job record to error and returned: no steer. But the teacher had already dispatched — the ack promised a result as a new message — so the lesson stalled at the dock card with the turn over and the teacher waiting forever on a brief that would never arrive. Every other failed job steers (the research/demo/visual/pdf catch paths all deliver the kind's failure shape through finishJob); the refusal now follows the SAME convention: - record the honest error (envelope wording + missing tier) WITHOUT flipping status — finalizeJob owns the terminal flip after delivery; - build the kind's failure result (Research failed:/FAILED:/PDF failed: shapes the isSubagentFailure heuristics read) and steer it through finishJob, so the wake pipeline resumes the lesson and the teacher reads the refusal and tells the learner. Also cleaned the stale engineFor doc (the model-slots-era block still said 'factory-pin chain ... absent → config pins' — two comments had accumulated on the field; merged into one BYOK-accurate block). Platform's refusal test pinned the old no-steer semantics; updated to assert the new convention (steer delivered, record flipped by finalizeJob after). The other piEngineFor callers (teacher-agent, pi-run, visual-maker, pdf-reader) are deliberate honest-thrower fallbacks (opts.engine ?? piEngineFor(role)) — missed-injection detectors, kept as-is.
This commit is contained in:
@@ -70,16 +70,12 @@ export interface JobRunnerDeps {
|
|||||||
* flipped); rejects when delivery fails unrecoverably.
|
* flipped); rejects when delivery fails unrecoverably.
|
||||||
*/
|
*/
|
||||||
deliver: (steer: SubagentSteerMessage) => Promise<void>;
|
deliver: (steer: SubagentSteerMessage) => Promise<void>;
|
||||||
/**
|
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
|
||||||
* Settings-resolved engine per role (model slots wave, 2026-09-20): the
|
* the role-override → slot chain, read FRESH per job (the server
|
||||||
* role-override → slot → factory-pin chain, read FRESH per job (the
|
* wiring passes effectiveSettings-based resolution). Test overrides
|
||||||
* server wiring passes effectiveSettings-based resolution). Closes the
|
* (below) still win. BYOK (owner, 2026-09-22): the chain may end
|
||||||
* old gap where per-role settings overrides never reached job engines.
|
* EMPTY → undefined; runJob then refuses the spawn honestly instead
|
||||||
* Test overrides (below) still win; absent → config pins.
|
* of guessing a model. */
|
||||||
*/
|
|
||||||
/** 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. */
|
|
||||||
engineFor?: (role: ModelRole) => PiEngine | undefined;
|
engineFor?: (role: ModelRole) => PiEngine | undefined;
|
||||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||||
* provider fresh per job — server/search.ts composition injected by
|
* 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);
|
const engine = pickEngine(job.kind);
|
||||||
if (!engine) {
|
if (!engine) {
|
||||||
// BYOK per-need refusal (owner, 2026-09-22): this kind's tier has
|
// BYOK per-need refusal (owner, 2026-09-22): this kind's tier has
|
||||||
// no model — the job record surfaces the honest error (dock +
|
// no model. Refuse through the SAME convention as every other
|
||||||
// teacher read it), and no engine-less loop ever starts.
|
// 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 tier = ROLE_SLOT[KIND_ROLE[job.kind]];
|
||||||
const envelope = modelsNotConfigured([tier]);
|
const envelope = modelsNotConfigured([tier]);
|
||||||
|
const reason = `${envelope.error} (missing: ${tier})`;
|
||||||
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
|
console.info(`[job ${jobId}] refused — ${JSON.stringify(envelope)}`);
|
||||||
jobStore.updateJob(jobId, {
|
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||||
status: "error",
|
const resolved = resolveInput(job, input) as
|
||||||
error: `${envelope.error} (missing: ${tier})`,
|
| ResearchToolInput
|
||||||
activity: "failed",
|
| 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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
+13
-1
@@ -1373,9 +1373,21 @@ describe("BYOK job refusal (no factory floor)", () => {
|
|||||||
await runner.runJob(job.id);
|
await runner.runJob(job.id);
|
||||||
|
|
||||||
const done = jobStore.getJob(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.equal(done.status, "error", "the job lands error, never a phantom run");
|
||||||
assert.match(done.error ?? "", /No model configured/);
|
assert.match(done.error ?? "", /No model configured/);
|
||||||
assert.match(done.error ?? "", /missing: cheap/);
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user