feat: abort sub-agent jobs (real kill) + runner usage capture into JobRecord.usage

Wave 4 (agent-side observability + control), reconciled with the dock
contract (master relay 2026-09):

- abortJob in src/server/jobs.ts, exported through the server barrel for
  POST /api/activity/abort (master's thin route): fires the job's
  AbortController (real kill — the in-flight model call aborts, not just
  the next loop check), drops any queued result steer, flips the record
  to error "Aborted by learner" + activity "aborted" synchronously, and
  queues a one-line abort steer (buildAbortSteer) through the same wake
  pipeline so the teacher's promise doesn't hang in the lesson. Shares
  the result steer's deterministic id space, so abort + racing result
  can never both deliver (steerDelivered dedupe).
- Abort registry (src/agent/subagents/aborts.ts) on globalThis, mirroring
  the wake pipeline's module-state pattern; runner registers/unregisters
  per run, so the route's module instance sees the loop's controller.
- Runner usage capture: LoopContext (abortSignal + onUsage) threads into
  all three kind loops; per-step usage folds via accumulateJobUsage into
  JobRecord.usage (model from config MODELS, absent-never-zero), flushed
  in runJob's finally so an aborted job's money record survives the kill.
- Contracts: JobUsage token fields optional + lastStep (most recent
  call's accounting, for the dock's per-blob context line vs
  CONTEXT_LIMITS); SubagentResultMeta.aborted; AbortSteerMessage;
  buildAbortSteer; SteerMessage union generalized.
- demo-builder/visual-maker accept abortSignal + onUsage (visual folds
  generateText usage per iteration).
- stillRunning/finishJob/jobContext guards: an aborted job is never
  overwritten by the runner (terminal no matter what the wake does);
  failJob and the pump's finalizeJob keep the flip.
- UI: abort steer renders a quiet aborted chip (job-aborted-badge).
- Prompt 20-jobs.md: one line about aborted jobs.

Tests: accumulateJobUsage folds (undefined-preservation, lastStep),
runner usage capture, real-kill abort (sync flip, silent unwind,
abort steer wakes), usage-so-far survives the kill, aborting a
delivering job drops the queued result steer, outcome reasons,
abort-chip render. 330/330, typecheck/lint/build green.
This commit is contained in:
amosblomqvist
2026-09-11 03:25:09 +03:00
parent 83a00800e9
commit 17ed37a471
10 changed files with 644 additions and 33 deletions
+15 -2
View File
@@ -1,7 +1,7 @@
import { generateText, tool, type LanguageModel, type ModelMessage, type ToolResultPart } from "ai";
import { z } from "zod";
import { modelFor } from "../models.ts";
import type { VisualResult } from "../../core/contracts.ts";
import type { UsageStepLike, VisualResult } from "../../core/contracts.ts";
import { getHeadless, type HeadlessApi } from "./headless.ts";
/**
@@ -37,6 +37,11 @@ export interface VisualMakerOptions {
maxIterations?: number;
/** Live activity-line callback (updates the job record). */
onActivity?: (line: string) => void;
/** Kills the run mid-model-call (2026-09 real-kill abort semantics). */
abortSignal?: AbortSignal;
/** Money channel: one call per loop iteration (generateText), folded
* into JobRecord.usage. */
onUsage?: (usage: UsageStepLike) => void;
}
const INSTRUCTIONS = `You are a visual maker — a diagram author + renderer for precise, spatial figures. You
@@ -135,7 +140,15 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
try {
for (let iteration = 0; iteration < maxIterations; iteration++) {
const result = await generateText({ model: opts.model ?? modelFor("visualMaker"), system: INSTRUCTIONS, messages, tools });
const result = await generateText({
model: opts.model ?? modelFor("visualMaker"),
system: INSTRUCTIONS,
messages,
tools,
abortSignal: opts.abortSignal,
});
// Money channel: each iteration is one model call.
opts.onUsage?.(result.usage);
messages = [...messages, ...result.response.messages];
const toolCalls = result.toolCalls;