Merge main: wave-4 backend (activity abort endpoint, lastStep capture)
This commit is contained in:
@@ -27,3 +27,7 @@ Dispatch as many research jobs as you genuinely need — in one turn or across t
|
||||
parallel when the questions are independent — but don't fan out on one question; the
|
||||
researcher searches multiple angles on its own. If research fails, you'll get a failure
|
||||
message: say so plainly and continue with what you can establish, flagging the uncertainty.
|
||||
|
||||
A job can also be **aborted by the learner** — you'll get a one-line "was aborted"
|
||||
message instead of the result. Acknowledge it in one sentence and continue teaching
|
||||
without it (offering to re-dispatch is fine if the learner wants the result).
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Job abort registry — WHO can actually kill a running sub-agent.
|
||||
*
|
||||
* The runner creates one AbortController per running job and registers it
|
||||
* here; the abort path (src/server/jobs.ts abortJob, driven by the dock's
|
||||
* END button via POST /api/jobs/[jobId]/abort) fires it through fireJobAbort.
|
||||
* Two module trees meet: the runner (src/agent) and the endpoint
|
||||
* (src/server) — the registry lives on globalThis for the same reason as
|
||||
* the turn registry and the wake pipeline: Next dev gives each route entry
|
||||
* its own module instances, so a module-level Map would be a different
|
||||
* object in each. Placed under src/agent (server→agent imports are an
|
||||
* established direction; agent→server is not — see chat-handler).
|
||||
*
|
||||
* 2026-09 abort semantics (user-decided): REAL KILL, not mark-and-abandon —
|
||||
* the controller aborts the in-flight model call (agent.generate /
|
||||
* generateText abortSignal); headless tool runs are best-effort (the next
|
||||
* model call is where a hung one dies). The route owns the bookkeeping
|
||||
* (status error "Aborted by learner" + the abort steer); the runner only unwinds.
|
||||
*/
|
||||
|
||||
interface LhAbortGlobal {
|
||||
__lhJobAborts?: Map<string, AbortController>;
|
||||
}
|
||||
|
||||
function registry(): Map<string, AbortController> {
|
||||
const g = globalThis as typeof globalThis & LhAbortGlobal;
|
||||
return (g.__lhJobAborts ??= new Map());
|
||||
}
|
||||
|
||||
/** The runner registers its controller at job start (idempotent overwrite). */
|
||||
export function registerJobAbort(jobId: string, controller: AbortController): void {
|
||||
registry().set(jobId, controller);
|
||||
}
|
||||
|
||||
/** The runner unregisters in its finally block (job settled or unwound). */
|
||||
export function unregisterJobAbort(jobId: string): void {
|
||||
registry().delete(jobId);
|
||||
}
|
||||
|
||||
/** Abort a running job's loop. Returns false when no controller is live —
|
||||
* the job's loop is not executing (crashed, or in the delivering-result
|
||||
* phase where the runner has already returned); the caller then owns the
|
||||
* whole abort on the record side. */
|
||||
export function fireJobAbort(jobId: string): boolean {
|
||||
const controller = registry().get(jobId);
|
||||
if (!controller) return false;
|
||||
registry().delete(jobId);
|
||||
controller.abort();
|
||||
return true;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type ToolSet } from "ai";
|
||||
import { z } from "zod";
|
||||
import { modelFor } from "../models.ts";
|
||||
import type { DemoResult } from "../../core/contracts.ts";
|
||||
import type { DemoResult, UsageStepLike } from "../../core/contracts.ts";
|
||||
import { getHeadless, type DemoRunResult, type HeadlessApi } from "./headless.ts";
|
||||
|
||||
/**
|
||||
@@ -32,6 +32,10 @@ export interface DemoBuilderOptions {
|
||||
maxSteps?: 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 agent step, folded into JobRecord.usage. */
|
||||
onUsage?: (usage: UsageStepLike) => void;
|
||||
}
|
||||
|
||||
const INSTRUCTIONS = `You are a demo builder — a specialist sub-agent inside a learning application. You
|
||||
@@ -189,6 +193,8 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
|
||||
try {
|
||||
const result = await agent.generate({
|
||||
prompt,
|
||||
abortSignal: opts.abortSignal,
|
||||
onStepFinish: ({ usage }) => opts.onUsage?.(usage),
|
||||
onToolExecutionStart: ({ toolCall }) => {
|
||||
opts.onActivity?.(toolCall.toolName);
|
||||
},
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ToolLoopAgent, stepCountIs, tool, type LanguageModel, type UIMessage } from "ai";
|
||||
import { z } from "zod";
|
||||
import { modelFor } from "../models.ts";
|
||||
import type { AuditResult, ChatRecord, GraphNode, SessionFindings } from "../../core/contracts.ts";
|
||||
import type {
|
||||
AuditResult,
|
||||
CallUsage,
|
||||
ChatRecord,
|
||||
ExtractionRunUsage,
|
||||
ExtractionStageUsage,
|
||||
GraphNode,
|
||||
SessionFindings,
|
||||
} from "../../core/contracts.ts";
|
||||
import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../../core/graph.ts";
|
||||
|
||||
/**
|
||||
@@ -211,12 +219,6 @@ function createExtractor(model: LanguageModel) {
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(4),
|
||||
// One line per step: a background worker's progress must be legible
|
||||
// from the server log (§5 observability baseline).
|
||||
onStepFinish: (step) =>
|
||||
console.info(
|
||||
`[extraction] extractor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -300,10 +302,6 @@ function createAuditor(model: LanguageModel) {
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(4),
|
||||
onStepFinish: (step) =>
|
||||
console.info(
|
||||
`[extraction] auditor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -312,11 +310,18 @@ function createAuditor(model: LanguageModel) {
|
||||
/** The pipeline phases, in order — what the home status endpoint shows. */
|
||||
export type ExtractionPhase = "extracting" | "auditing" | "merging";
|
||||
|
||||
/** One in-flight pipeline run. Module-level: one process, one registry. */
|
||||
interface RunningExtraction {
|
||||
/** One in-flight pipeline run. Module-level: one process, one registry.
|
||||
* Exported for the status payload's consumer typing (the dock). */
|
||||
export interface RunningExtraction {
|
||||
chatId: string;
|
||||
phase: ExtractionPhase;
|
||||
startedAt: number;
|
||||
/** Live per-stage usage, accumulated as steps bill (the dock's cost /
|
||||
* context-window blobs read this through /api/graph/status). */
|
||||
usage: { extractor?: ExtractionStageUsage; auditor?: ExtractionStageUsage };
|
||||
/** True-cancellation handle (the dock's abort button): one controller per
|
||||
* run, shared across phases; abort() cancels the in-flight model call. */
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
const runningExtractions = new Map<string, RunningExtraction>();
|
||||
@@ -326,6 +331,18 @@ export function extractionStatuses(): RunningExtraction[] {
|
||||
return [...runningExtractions.values()].sort((a, b) => a.startedAt - b.startedAt);
|
||||
}
|
||||
|
||||
/** True cancellation of the in-flight run for a chat (the dock's abort
|
||||
* button): the pending model call receives the signal — the provider
|
||||
* cancels the request, spend stops — and the run ends like a failure:
|
||||
* nothing merged, nothing checkpointed, partial usage still persisted.
|
||||
* Returns false when no run is in flight (idempotent no-op). */
|
||||
export function abortExtraction(chatId: string): boolean {
|
||||
const run = runningExtractions.get(chatId);
|
||||
if (!run) return false;
|
||||
run.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── the pipeline ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExtractionModels {
|
||||
@@ -350,21 +367,62 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
const inFlight = new Set<string>();
|
||||
|
||||
function setPhase(chatId: string, phase: ExtractionPhase): void {
|
||||
const prev = runningExtractions.get(chatId);
|
||||
runningExtractions.set(chatId, {
|
||||
chatId,
|
||||
phase,
|
||||
startedAt: runningExtractions.get(chatId)?.startedAt ?? Date.now(),
|
||||
startedAt: prev?.startedAt ?? Date.now(),
|
||||
usage: prev?.usage ?? {}, // phase changes never lose usage
|
||||
controller: prev?.controller ?? new AbortController(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Accumulate one billed step into the run's registry entry (live). */
|
||||
function recordStep(chatId: string, stage: "extractor" | "auditor", usage: CallUsage, model: string): void {
|
||||
const run = runningExtractions.get(chatId);
|
||||
if (!run) return; // not running (tests driving stages directly)
|
||||
run.usage[stage] ??= { model, steps: [] };
|
||||
run.usage[stage]!.steps.push(usage);
|
||||
}
|
||||
|
||||
/** The SDK's per-step usage, flattened to the persisted CallUsage fields
|
||||
* (the money channel's capture shape, contracts/chat.ts). */
|
||||
function callUsage(usage: {
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number };
|
||||
outputTokenDetails?: { reasoningTokens?: number };
|
||||
}): CallUsage {
|
||||
// Optional-chained: the SDK types the detail objects as required, but
|
||||
// providers (and mocks) may leave them unset.
|
||||
return {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadTokens: usage.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens,
|
||||
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
|
||||
};
|
||||
}
|
||||
|
||||
async function extract(chatId: string): Promise<SessionFindings> {
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||
const signal = runningExtractions.get(chatId)?.controller.signal;
|
||||
signal?.throwIfAborted(); // an abort during a sync gap ends the run here
|
||||
const view = transcriptView(record);
|
||||
console.info(`[extraction ${chatId}] extractor starting (${view.split("\n").length} transcript lines)`);
|
||||
const t0 = performance.now();
|
||||
|
||||
const extraction = await extractorAgent.generate({ prompt: `Transcript:\n\n${view}` });
|
||||
const extraction = await extractorAgent.generate({
|
||||
prompt: `Transcript:\n\n${view}`,
|
||||
abortSignal: signal,
|
||||
onStepFinish: (step) => {
|
||||
recordStep(chatId, "extractor", callUsage(step.usage), step.model.modelId);
|
||||
console.info(
|
||||
`[extraction] extractor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
for (const part of extraction.content) {
|
||||
if (part.type === "tool-result" && part.toolName === "submit_findings") {
|
||||
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
|
||||
@@ -377,6 +435,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
async function audit(chatId: string, findings: SessionFindings): Promise<AuditResult> {
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||
const signal = runningExtractions.get(chatId)?.controller.signal;
|
||||
signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
|
||||
const view = transcriptView(record);
|
||||
const graph = getGraph(graphStore);
|
||||
console.info(
|
||||
@@ -389,6 +449,13 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
prompt:
|
||||
`Transcript:\n\n${view}\n\nProposed findings:\n\n${JSON.stringify(findings, null, 2)}\n\n` +
|
||||
`Current knowledge graph (${graph.nodes.length} nodes):\n\n${graphView(graph.nodes)}`,
|
||||
abortSignal: signal,
|
||||
onStepFinish: (step) => {
|
||||
recordStep(chatId, "auditor", callUsage(step.usage), step.model.modelId);
|
||||
console.info(
|
||||
`[extraction] auditor step ${step.stepNumber}: ${step.finishReason}, tools: ${step.toolCalls.map((c) => c.toolName).join(", ") || "(none)"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
let auditResult: AuditResult | null = null;
|
||||
for (const part of review.content) {
|
||||
@@ -412,6 +479,25 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
return auditResult;
|
||||
}
|
||||
|
||||
/** Append the run's usage record to the chat's extractions log — success
|
||||
* or failure: a failed run still spent money, and its partial usage is
|
||||
* exactly what the record should show (stats absent = never merged).
|
||||
* Idempotent per startedAt (the store dedupes). */
|
||||
function persistRunUsage(chatId: string, durationMs: number, stats: EmissionStats | undefined): void {
|
||||
const run = runningExtractions.get(chatId);
|
||||
if (!run) return;
|
||||
chatStore.appendExtractionUsage(chatId, {
|
||||
startedAt: run.startedAt,
|
||||
at: new Date().toISOString(),
|
||||
durationMs,
|
||||
stats,
|
||||
stages: {
|
||||
extractor: run.usage.extractor ?? { model: "unknown", steps: [] },
|
||||
auditor: run.usage.auditor ?? { model: "unknown", steps: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runExtraction(chatId: string): Promise<EmissionStats> {
|
||||
if (inFlight.has(chatId)) return { nodes: 0, edges: 0, events: 0 };
|
||||
inFlight.add(chatId);
|
||||
@@ -426,12 +512,22 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
// The merger re-reads the chat record itself — events are rebuilt
|
||||
// from the transcript, never from stale copies.
|
||||
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore });
|
||||
persistRunUsage(chatId, Math.round(performance.now() - t0), stats);
|
||||
console.info(
|
||||
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` +
|
||||
`(${Math.round(performance.now() - t0)}ms total)`,
|
||||
);
|
||||
return stats;
|
||||
} catch (err) {
|
||||
persistRunUsage(chatId, Math.round(performance.now() - t0), undefined);
|
||||
// Aborted (the dock's abort button): a deliberate stop, not a
|
||||
// failure — same ledger discipline as a failure (nothing merged,
|
||||
// nothing checkpointed, partial usage persisted), but the run
|
||||
// reports zero emissions instead of throwing.
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
console.info(`[extraction ${chatId}] aborted (${Math.round(performance.now() - t0)}ms) — nothing merged, nothing checkpointed`);
|
||||
return { nodes: 0, edges: 0, events: 0 };
|
||||
}
|
||||
// The throw travels to the caller; this line is what makes the
|
||||
// run's log legible end-to-end (no hanging "pipeline start").
|
||||
console.error(`[extraction ${chatId}] pipeline failed (${Math.round(performance.now() - t0)}ms):`, err);
|
||||
|
||||
+117
-13
@@ -1,16 +1,21 @@
|
||||
import { isToolUIPart, type LanguageModel, type UIMessage } from "ai";
|
||||
import {
|
||||
accumulateJobUsage,
|
||||
buildSubagentSteer,
|
||||
type DemoResult,
|
||||
type DemoToolInput,
|
||||
type JobKind,
|
||||
type JobRecord,
|
||||
type JobUsage,
|
||||
type ResearchResult,
|
||||
type ResearchToolInput,
|
||||
type SubagentSteerMessage,
|
||||
type UsageStepLike,
|
||||
type VisualResult,
|
||||
type VisualToolInput,
|
||||
} from "../../core/contracts.ts";
|
||||
import { registerJobAbort, unregisterJobAbort } from "./aborts.ts";
|
||||
import { MODELS } from "../../../config.ts";
|
||||
import type { ChatStore, JobStore } from "../../core/ports.ts";
|
||||
import { runDemoBuilder } from "./demo-builder.ts";
|
||||
import { createResearcherAgent } from "./researcher.ts";
|
||||
@@ -67,16 +72,70 @@ export interface JobRunner {
|
||||
runJob(jobId: string, input?: ResearchToolInput | DemoToolInput | VisualToolInput): Promise<void>;
|
||||
}
|
||||
|
||||
/** One job loop's injected machinery: the kill switch (real abort, 2026-09
|
||||
* semantics) and the usage tap (money channel — every model call folds into
|
||||
* JobRecord.usage as it completes). */
|
||||
interface LoopContext {
|
||||
/** Fires when abortJob kills the run; abortSignal is passed into every
|
||||
* model call so the spend stops mid-call, not at the next loop check. */
|
||||
abortSignal: AbortSignal;
|
||||
/** Fold one model call's usage into the job record (progress write). */
|
||||
onUsage: (step: UsageStepLike) => void;
|
||||
}
|
||||
|
||||
/** The OpenRouter model id for a job kind — JobUsage.model. The kind→role
|
||||
* table mirrors MODELS in config.ts (research→researcher, demo→demoBuilder,
|
||||
* visual→visualMaker); the id is taken from config so a price-table sync
|
||||
* re-prices jobs for free. Model overrides (tests) still bill under the
|
||||
* configured model — usage is what is being captured, not which instance. */
|
||||
const KIND_MODEL: Record<JobKind, string> = {
|
||||
research: MODELS.researcher.model,
|
||||
demo: MODELS.demoBuilder.model,
|
||||
visual: MODELS.visualMaker.model,
|
||||
};
|
||||
|
||||
export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
const { chatStore, jobStore, deliver } = deps;
|
||||
|
||||
/** Running usage totals per live job (the loop's onUsage folds here,
|
||||
* then writes through the job store so the poll sees progress). */
|
||||
const usageTotals = new Map<string, JobUsage>();
|
||||
|
||||
function makeLoopContext(jobId: string, kind: JobKind): LoopContext {
|
||||
const model = KIND_MODEL[kind];
|
||||
// Real-kill wiring (2026-09 abort semantics): the controller is what
|
||||
// the abort endpoint fires (agent/subagents/aborts.ts registry); the
|
||||
// signal rides into every model call of the loop.
|
||||
const controller = new AbortController();
|
||||
registerJobAbort(jobId, controller);
|
||||
return {
|
||||
abortSignal: controller.signal,
|
||||
onUsage: (step) => {
|
||||
const next = accumulateJobUsage(usageTotals.get(jobId), model, step);
|
||||
usageTotals.set(jobId, next);
|
||||
jobStore.updateJob(jobId, { usage: next }); // merge patch — status untouched
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** True while the job may still write terminal state (result, error,
|
||||
* steer). False once abortJob has flipped it to error ("Aborted by learner") —
|
||||
* the runner then unwinds silently; the abort path owns the record and
|
||||
* the abort steer. */
|
||||
function stillRunning(jobId: string): boolean {
|
||||
return jobStore.getJob(jobId)?.status === "running";
|
||||
}
|
||||
|
||||
/** The kind→runner map: one sub-agent loop per job kind. */
|
||||
// Each kind's runner narrows the union input itself (dispatch-time inputs
|
||||
// are kind-correct by construction; transcript fallbacks are cast after).
|
||||
const KIND_RUNNERS: Record<JobKind, (jobId: string, input?: JobToolInput) => Promise<void>> = {
|
||||
research: (jobId, input) => runResearchJob(jobId, researchModelOverride.current, input),
|
||||
demo: (jobId, input) => runDemoJob(jobId, demoModelOverride.current, input),
|
||||
visual: (jobId, input) => runVisualJob(jobId, visualModelOverride.current, input),
|
||||
const KIND_RUNNERS: Record<
|
||||
JobKind,
|
||||
(jobId: string, input: JobToolInput | undefined, loop: LoopContext) => Promise<void>
|
||||
> = {
|
||||
research: (jobId, input, loop) => runResearchJob(jobId, researchModelOverride.current, input, loop),
|
||||
demo: (jobId, input, loop) => runDemoJob(jobId, demoModelOverride.current, input, loop),
|
||||
visual: (jobId, input, loop) => runVisualJob(jobId, visualModelOverride.current, input, loop),
|
||||
};
|
||||
|
||||
async function runJob(jobId: string, input?: JobToolInput): Promise<void> {
|
||||
@@ -92,7 +151,17 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
jobStore.updateJob(jobId, { status: "error", error: `unknown kind ${job.kind}`, activity: "failed" });
|
||||
return;
|
||||
}
|
||||
await run(jobId, input);
|
||||
try {
|
||||
await run(jobId, input, makeLoopContext(jobId, job.kind));
|
||||
} finally {
|
||||
unregisterJobAbort(jobId);
|
||||
// The last usage write may have been mid-flight when the loop died;
|
||||
// persist what was folded (the abort path patches only status, so
|
||||
// this never resurrects an aborted job — merge patch).
|
||||
const usage = usageTotals.get(jobId);
|
||||
if (usage) jobStore.updateJob(jobId, { usage });
|
||||
usageTotals.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the job's tool input: dispatch passes it directly; only a
|
||||
@@ -132,6 +201,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult): Promise<void> {
|
||||
const job = jobStore.getJob(jobId);
|
||||
if (!job) return;
|
||||
// Aborted mid-run or mid-delivery (abortJob flips the record before
|
||||
// the loop unwinds): no result write, no steer — the abort path owns
|
||||
// the record and queues the abort steer itself.
|
||||
if (job.status !== "running") return;
|
||||
jobStore.updateJob(jobId, { result, activity: "delivering result" });
|
||||
const steer = buildSubagentSteer(job, result);
|
||||
try {
|
||||
@@ -157,7 +230,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
|
||||
// ─── research ───────────────────────────────────────────────────────────
|
||||
|
||||
async function runResearchJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
|
||||
async function runResearchJob(
|
||||
jobId: string,
|
||||
model: LanguageModel | undefined,
|
||||
passedInput: JobToolInput | undefined,
|
||||
loop: LoopContext,
|
||||
): Promise<void> {
|
||||
const ctx = jobContext(jobId);
|
||||
if (!ctx) return;
|
||||
const { job } = ctx;
|
||||
@@ -181,6 +259,10 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
jobStore.updateJob(jobId, { activity: "researching" });
|
||||
const result = await agent.generate({
|
||||
prompt,
|
||||
abortSignal: loop.abortSignal,
|
||||
// Money channel: each agent step is one model call — fold its
|
||||
// usage into the record as it completes (progress write).
|
||||
onStepFinish: ({ usage }) => loop.onUsage(usage),
|
||||
onToolExecutionStart: ({ toolCall }) => {
|
||||
const input = toolCall.input as { query?: string; url?: string } | undefined;
|
||||
if (toolCall.toolName === "web_fetch" && typeof input?.url === "string") {
|
||||
@@ -195,15 +277,24 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
const research: ResearchResult = { brief, sources: parseSources(brief, fetchedUrls) };
|
||||
await finishJob(jobId, research);
|
||||
} catch (err) {
|
||||
// Real-kill abort (2026-09): the abort path flipped the record and
|
||||
// queued the abort steer — unwind silently, keep the usage
|
||||
// folded so far (flushed by runJob's finally).
|
||||
if (loop.abortSignal.aborted) return;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
jobStore.updateJob(jobId, { error: reason });
|
||||
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||
await finishJob(jobId, { brief: `Research failed: ${reason}`, sources: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── demo + visual ──────────────────────────────────────────────────────
|
||||
|
||||
async function runDemoJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
|
||||
async function runDemoJob(
|
||||
jobId: string,
|
||||
model: LanguageModel | undefined,
|
||||
passedInput: JobToolInput | undefined,
|
||||
loop: LoopContext,
|
||||
): Promise<void> {
|
||||
const ctx = jobContext(jobId);
|
||||
if (!ctx) return;
|
||||
const { job } = ctx;
|
||||
@@ -218,19 +309,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
{ title, brief },
|
||||
{
|
||||
model,
|
||||
abortSignal: loop.abortSignal,
|
||||
onUsage: loop.onUsage,
|
||||
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
||||
},
|
||||
);
|
||||
if (isDemoFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
|
||||
if (isDemoFailure(result) && stillRunning(jobId))
|
||||
jobStore.updateJob(jobId, { error: result.notes ?? "demo build failed" });
|
||||
await finishJob(jobId, result);
|
||||
} catch (err) {
|
||||
if (loop.abortSignal.aborted) return;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
jobStore.updateJob(jobId, { error: reason });
|
||||
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||
await finishJob(jobId, { title, html: "", tested: true, notes: `FAILED: ${reason}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function runVisualJob(jobId: string, model?: LanguageModel, passedInput?: JobToolInput): Promise<void> {
|
||||
async function runVisualJob(
|
||||
jobId: string,
|
||||
model: LanguageModel | undefined,
|
||||
passedInput: JobToolInput | undefined,
|
||||
loop: LoopContext,
|
||||
): Promise<void> {
|
||||
const ctx = jobContext(jobId);
|
||||
if (!ctx) return;
|
||||
const { job } = ctx;
|
||||
@@ -244,14 +344,18 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
{ brief },
|
||||
{
|
||||
model,
|
||||
abortSignal: loop.abortSignal,
|
||||
onUsage: loop.onUsage,
|
||||
onActivity: (line) => jobStore.updateJob(jobId, { activity: line }),
|
||||
},
|
||||
);
|
||||
if (isVisualFailure(result)) jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
|
||||
if (isVisualFailure(result) && stillRunning(jobId))
|
||||
jobStore.updateJob(jobId, { error: result.notes ?? "visual build failed" });
|
||||
await finishJob(jobId, result);
|
||||
} catch (err) {
|
||||
if (loop.abortSignal.aborted) return;
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
jobStore.updateJob(jobId, { error: reason });
|
||||
if (stillRunning(jobId)) jobStore.updateJob(jobId, { error: reason });
|
||||
await finishJob(jobId, { svg: "", notes: `FAILED: ${reason}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import type { Phase3 } from "../phases.ts";
|
||||
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
|
||||
import type { EmissionStats } from "./graph.ts";
|
||||
|
||||
// ─── Chat records (the session store's on-disk format) ───────────────────────
|
||||
|
||||
@@ -32,6 +33,41 @@ export interface ChatRecord {
|
||||
* each entry onto its message; aggregates are folds over this log
|
||||
* (core/prices.ts). */
|
||||
turns?: TurnUsage[];
|
||||
/** Append-only background-extraction usage log (2026-09 observability):
|
||||
* one entry per finished pipeline run on THIS chat — extraction is real
|
||||
* spend too. Same parallel-log discipline as `turns` (nothing rides
|
||||
* inside messages). Keyed by startedAt; idempotent appends. */
|
||||
extractions?: ExtractionRunUsage[];
|
||||
}
|
||||
|
||||
// ─── Background-extraction usage (the money channel, extraction side) ────────
|
||||
|
||||
/** One pipeline stage's model usage — the stage's model + every step it
|
||||
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
||||
* folds happen at read time (core/prices.ts). */
|
||||
export interface ExtractionStageUsage {
|
||||
/** The OpenRouter model id that ran this stage. */
|
||||
model: string;
|
||||
steps: CallUsage[];
|
||||
}
|
||||
|
||||
/** One finished (or failed mid-flight) extraction run's usage record.
|
||||
* Appended to the chat's `extractions` log when the run ends — success or
|
||||
* failure, because money was spent either way; `stats` is present only
|
||||
* when the merger actually ran. */
|
||||
export interface ExtractionRunUsage {
|
||||
/** The run's registry startedAt (ms) — the idempotency key. */
|
||||
startedAt: number;
|
||||
at: string;
|
||||
durationMs: number;
|
||||
/** What the run merged into the graph; absent when it failed first. */
|
||||
stats?: EmissionStats;
|
||||
/** Model usage per stage, in pipeline order (merger is deterministic —
|
||||
* no model call, no entry). */
|
||||
stages: {
|
||||
extractor: ExtractionStageUsage;
|
||||
auditor: ExtractionStageUsage;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Usage accounting (the money channel) ─────────────────────────────────────
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
* strength — strength is derived at read time from topology. */
|
||||
export type EdgeType = "follows" | "supports";
|
||||
|
||||
/** What one extraction run merged into the graph ledgers — the merger's
|
||||
* output, also persisted in extraction-run usage records (contracts/chat). */
|
||||
export interface EmissionStats {
|
||||
/** New graph nodes appended. */
|
||||
nodes: number;
|
||||
/** New edge assertions appended. */
|
||||
edges: number;
|
||||
/** Ledger events rebuilt (quiz outcomes + observations). */
|
||||
events: number;
|
||||
}
|
||||
|
||||
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
|
||||
* edges record every (chat, message) where they came up, deep-linkable to
|
||||
* the exact transcript point. */
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
||||
*/
|
||||
import type { UIMessage } from "ai";
|
||||
import type { CallUsage } from "./chat.ts";
|
||||
|
||||
// ─── Async sub-agent jobs (vision §2.5; plan.md Wave 0 protocol) ────────────
|
||||
|
||||
@@ -36,18 +37,64 @@ export interface JobRecord {
|
||||
|
||||
/** A sub-agent run's accumulated usage — the same accounting shape as a
|
||||
* chat turn (contracts/chat.ts CallUsage), flattened to one running total
|
||||
* plus a step count. */
|
||||
* plus a step count. Token fields are optional exactly like CallUsage: a
|
||||
* field NO model call reported stays undefined — absent is not zero (the
|
||||
* money-channel doctrine; cost renders "—", never $0). */
|
||||
export interface JobUsage {
|
||||
/** The OpenRouter model id that ran the sub-agent. */
|
||||
model: string;
|
||||
/** Running totals summed over the run's model calls so far. */
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
reasoningTokens: number;
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
cacheReadTokens: number | undefined;
|
||||
cacheWriteTokens: number | undefined;
|
||||
reasoningTokens: number | undefined;
|
||||
/** How many model calls the run has made (each bills separately). */
|
||||
steps: number;
|
||||
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
||||
* per-blob context line reads last-step input+output against the model's
|
||||
* CONTEXT_LIMITS — never the cumulative totals. Absent until the first
|
||||
* call completes; absent provider fields stay absent (never zero). */
|
||||
lastStep?: CallUsage;
|
||||
}
|
||||
|
||||
/** The per-model-call usage shape we fold into JobUsage — the SDK's
|
||||
* LanguageModelUsage narrowed structurally (no runtime "ai" import: any
|
||||
* object with these fields folds, including the real thing and test fakes). */
|
||||
export interface UsageStepLike {
|
||||
inputTokens?: number | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
|
||||
outputTokens?: number | undefined;
|
||||
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
||||
}
|
||||
|
||||
/** Fold one model call's usage into the run's running total (pure). A
|
||||
* field neither the total nor the step reported stays undefined; once
|
||||
* reported, later unreported calls contribute 0. The `steps` counter is
|
||||
* the billing count: one per model call, reported or not. */
|
||||
export function accumulateJobUsage(
|
||||
total: JobUsage | undefined,
|
||||
model: string,
|
||||
step: UsageStepLike,
|
||||
): JobUsage {
|
||||
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
||||
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
||||
return {
|
||||
model,
|
||||
inputTokens: add(total?.inputTokens, step.inputTokens),
|
||||
outputTokens: add(total?.outputTokens, step.outputTokens),
|
||||
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
||||
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
||||
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
||||
steps: (total?.steps ?? 0) + 1,
|
||||
lastStep: {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +202,11 @@ export interface SubagentResultMeta {
|
||||
jobId: string;
|
||||
kind: JobKind;
|
||||
toolCallId: string;
|
||||
/** Present only on an abort steer (2026-09 abort semantics): the
|
||||
* learner aborted the job (it lands as error "Aborted by learner");
|
||||
* the teacher is told so its promise of a result doesn't hang
|
||||
* silently in the lesson. */
|
||||
aborted?: true;
|
||||
}
|
||||
|
||||
/** The data-subagent-artifact part payload: what the UI renders. */
|
||||
@@ -178,6 +230,20 @@ export interface SubagentSteerMessage extends UIMessage {
|
||||
];
|
||||
}
|
||||
|
||||
/** The abort steer: user-role like any steer, but NO artifact part —
|
||||
* there is no result to render, only the teacher-facing text. Shares the
|
||||
* result steer's deterministic id space (`steer_${jobId}`): steerDelivered
|
||||
* matches on metadata jobId, so an abort and a (racing) result steer
|
||||
* can never BOTH deliver — exactly one wakes the teacher. */
|
||||
export interface AbortSteerMessage extends UIMessage {
|
||||
role: "user";
|
||||
metadata: { subagentResult: SubagentResultMeta & { aborted: true } };
|
||||
parts: [{ type: "text"; text: string }];
|
||||
}
|
||||
|
||||
/** Anything the wake pipeline may queue and deliver. */
|
||||
export type SteerMessage = SubagentSteerMessage | AbortSteerMessage;
|
||||
|
||||
/** Whether a result is the failure shape (html/svg empty, failure brief). */
|
||||
export function isSubagentFailure(kind: JobKind, result: ResearchResult | DemoResult | VisualResult): boolean {
|
||||
if (kind === "research") {
|
||||
@@ -234,6 +300,32 @@ export function buildSubagentSteer(
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the abort steer for an aborted job (pure — shared by the
|
||||
* abort path in src/server/jobs.ts and tests). Same id space as the result
|
||||
* steer; see AbortSteerMessage. */
|
||||
export function buildAbortSteer(
|
||||
job: Pick<JobRecord, "id" | "kind" | "toolCallId">,
|
||||
): AbortSteerMessage {
|
||||
return {
|
||||
id: `steer_${job.id}`,
|
||||
role: "user",
|
||||
metadata: {
|
||||
subagentResult: { jobId: job.id, kind: job.kind, toolCallId: job.toolCallId, aborted: true },
|
||||
},
|
||||
parts: [{ type: "text", text: abortText(job.kind) }],
|
||||
};
|
||||
}
|
||||
|
||||
/** What the teacher reads: one line telling it the result is not coming,
|
||||
* how to acknowledge, and that re-dispatching is allowed. */
|
||||
function abortText(kind: JobKind): string {
|
||||
if (kind === "research")
|
||||
return "Research aborted — the learner aborted the research job, so no brief is coming. Acknowledge it in one line and continue teaching without it (or offer to re-dispatch it).";
|
||||
if (kind === "demo")
|
||||
return "The demo job was aborted by the learner — no demo is coming. Acknowledge it in one line and teach the topic in prose instead (a re-dispatch is fine if the learner wants it).";
|
||||
return "The figure job was aborted by the learner — no figure is coming. Acknowledge it in one line and teach without it (a re-dispatch is fine if the learner wants it).";
|
||||
}
|
||||
|
||||
/** True when the transcript already carries this job's steer (idempotent delivery). */
|
||||
export function steerDelivered(messages: UIMessage[], jobId: string): boolean {
|
||||
return messages.some(
|
||||
|
||||
+4
-9
@@ -42,6 +42,7 @@ import { randomBytes } from "node:crypto";
|
||||
import { isToolUIPart, type UIMessage } from "ai";
|
||||
import type {
|
||||
AuditResult,
|
||||
EmissionStats,
|
||||
GraphEdge,
|
||||
GraphNode,
|
||||
ProvenancePoint,
|
||||
@@ -108,15 +109,6 @@ const samePoint = (a: ProvenancePoint, b: ProvenancePoint): boolean =>
|
||||
|
||||
// ─── the merger (extraction pipeline stage 3 — deterministic) ────────────────
|
||||
|
||||
export interface EmissionStats {
|
||||
/** New graph nodes appended. */
|
||||
nodes: number;
|
||||
/** New edge assertions appended. */
|
||||
edges: number;
|
||||
/** New ledger events appended. */
|
||||
events: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one chat's audited findings into the graph ledgers — the pipeline's
|
||||
* apply stage. Deterministic + idempotent: re-merging the same verdicts (or
|
||||
@@ -470,3 +462,6 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
|
||||
return { nodes, edges, sessions, nodeSessions, nodeObservations };
|
||||
}
|
||||
|
||||
// Re-export: the contract owns the shape; legacy importers still reach it.
|
||||
export type { EmissionStats };
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
JobKind,
|
||||
JobRecord,
|
||||
LedgerEvent,
|
||||
ExtractionRunUsage,
|
||||
NodeMapping,
|
||||
PlanTree,
|
||||
SessionCheckpoint,
|
||||
@@ -41,6 +42,8 @@ export interface ChatStore {
|
||||
* 2026-09): idempotent per messageId — a turn persisted twice (abort
|
||||
* replays, wake-turn retries) appends exactly once. */
|
||||
appendTurnUsage(id: string, entry: TurnUsage): void;
|
||||
/** Append one extraction-run usage entry — idempotent per startedAt. */
|
||||
appendExtractionUsage(id: string, entry: ExtractionRunUsage): void;
|
||||
}
|
||||
|
||||
// ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
|
||||
|
||||
+73
-7
@@ -33,13 +33,15 @@
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import {
|
||||
buildAbortSteer,
|
||||
buildSubagentSteer,
|
||||
isSubagentFailure,
|
||||
steerDelivered,
|
||||
type JobRecord,
|
||||
type SubagentSteerMessage,
|
||||
type SteerMessage,
|
||||
} from "../core/contracts.ts";
|
||||
import { findPendingToolCall } from "../core/pending.ts";
|
||||
import { fireJobAbort } from "../agent/subagents/aborts.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
|
||||
@@ -50,7 +52,7 @@ export type TurnRunner = (chatId: string, messages: UIMessage[]) => Promise<numb
|
||||
export type JobRespawn = (jobId: string) => void;
|
||||
|
||||
interface QueuedSteer {
|
||||
steer: SubagentSteerMessage;
|
||||
steer: SteerMessage;
|
||||
resolve: () => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
@@ -108,7 +110,7 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function jobIdOf(steer: SubagentSteerMessage): string {
|
||||
function jobIdOf(steer: SteerMessage): string {
|
||||
return (steer.metadata as { subagentResult: { jobId: string } }).subagentResult.jobId;
|
||||
}
|
||||
|
||||
@@ -117,7 +119,7 @@ function jobIdOf(steer: SubagentSteerMessage): string {
|
||||
* has completed (and the job status flipped); rejects when delivery fails
|
||||
* unrecoverably. Never rejects for queueing itself.
|
||||
*/
|
||||
export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> {
|
||||
export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const s = state();
|
||||
const chatId = chatIdOf(steer);
|
||||
@@ -145,13 +147,13 @@ export function deliverJobResult(steer: SubagentSteerMessage): Promise<void> {
|
||||
* before firing, so a claim racing the pump converges to exactly one
|
||||
* delivery either way.
|
||||
*/
|
||||
export function claimSteers(chatId: string): SubagentSteerMessage[] {
|
||||
export function claimSteers(chatId: string): SteerMessage[] {
|
||||
const s = state();
|
||||
const queue = s.queues.get(chatId);
|
||||
if (!queue || queue.length === 0) return [];
|
||||
const record = stores.chatStore.getChat(chatId);
|
||||
if (!record) return []; // the handler validated the chat; defensive only
|
||||
const claimed: SubagentSteerMessage[] = [];
|
||||
const claimed: SteerMessage[] = [];
|
||||
for (const entry of queue) {
|
||||
const jobId = jobIdOf(entry.steer);
|
||||
if (steerDelivered(record.messages, jobId)) {
|
||||
@@ -167,7 +169,66 @@ export function claimSteers(chatId: string): SubagentSteerMessage[] {
|
||||
return claimed;
|
||||
}
|
||||
|
||||
function chatIdOf(steer: SubagentSteerMessage): string {
|
||||
/** Result of an abort attempt — the endpoint maps these to HTTP. */
|
||||
export interface AbortOutcome {
|
||||
ok: boolean;
|
||||
reason?: "not_found" | "not_running";
|
||||
}
|
||||
|
||||
/**
|
||||
* ABORT (2026-09 dock semantics, user-decided): real kill, error with
|
||||
* reason, the teacher still learns. The dock's END button reaches this
|
||||
* through POST /api/activity/abort (body {kind: "job", id: jobId}); the
|
||||
* route is master's thin composition — this is the job-side mechanism.
|
||||
* Four things happen, in order:
|
||||
*
|
||||
* 1. KILL the spend: fire the job's registered AbortController (the
|
||||
* runner registered it at loop start) — the in-flight model call
|
||||
* aborts, not just the next loop check. The runner unwinds silently
|
||||
* (its catch checks abortSignal.aborted; finishJob checks status).
|
||||
* 2. DROP any queued result steer for this job (the delivering-result
|
||||
* phase): resolve its entry without firing the wake. If the wake
|
||||
* turn is ALREADY in flight (the pump shifted the entry), it's too
|
||||
* late — the result steer lands, and the cancellation steer dedupes
|
||||
* against it via steerDelivered (same jobId metadata).
|
||||
* 3. FLIP the record to error "Aborted by learner" immediately — the
|
||||
* poll reflects the user's action at once (the user chose
|
||||
* error-with-reason over a distinct cancelled status). finalizeJob
|
||||
* never overwrites a non-running status, so the flip is terminal no
|
||||
* matter what the wake does.
|
||||
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
|
||||
* the same wake pipeline, so the teacher's promise of a result
|
||||
* doesn't hang silently in the lesson.
|
||||
*
|
||||
* The runner writes the usage folded so far in its finally (merge patch),
|
||||
* so an aborted job's money record survives the kill.
|
||||
*/
|
||||
export function abortJob(jobId: string): AbortOutcome {
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
if (!job) return { ok: false, reason: "not_found" };
|
||||
if (job.status !== "running") return { ok: false, reason: "not_running" };
|
||||
|
||||
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
|
||||
|
||||
// Drop any queued steers for this job (their deliver() resolves unfired).
|
||||
const queue = state().queues.get(job.chatId);
|
||||
if (queue) {
|
||||
const remaining = queue.filter((e) => jobIdOf(e.steer) !== jobId);
|
||||
if (remaining.length === 0) state().queues.delete(job.chatId);
|
||||
else state().queues.set(job.chatId, remaining);
|
||||
for (const entry of queue) {
|
||||
if (jobIdOf(entry.steer) === jobId) entry.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
|
||||
void deliverJobResult(buildAbortSteer(job)).catch(() => {
|
||||
/* deliverJobResult marks the job error on unrecoverable failure */
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function chatIdOf(steer: SteerMessage): string {
|
||||
// The chat is resolvable from the job record; the steer's metadata
|
||||
// carries jobId, which indexes the store.
|
||||
const jobId = jobIdOf(steer);
|
||||
@@ -285,6 +346,11 @@ function finalizeJob(jobId: string): void {
|
||||
}
|
||||
|
||||
function failJob(jobId: string, err: Error): void {
|
||||
// An aborted job stays aborted even when its (abort) steer
|
||||
// delivery fails — the user's action is the terminal fact, not the
|
||||
// delivery error. Non-running records are never overwritten here.
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
if (!job || job.status !== "running") return;
|
||||
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
* (chat-transport); the dock is a different consumer with a different
|
||||
* shape, served here. Single sources of truth are unchanged: the job
|
||||
* store and the pipeline registry — this file only composes them.
|
||||
*
|
||||
* The extraction side of the dock's controls also surfaces here:
|
||||
* abortExtraction (true mid-run cancellation, user-decided) is exported
|
||||
* from the pipeline's module for the abort route to call.
|
||||
*/
|
||||
|
||||
import { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
|
||||
@@ -20,8 +24,8 @@ import { chatStore } from "../store/chats.ts";
|
||||
import { listJobs } from "./jobs.ts";
|
||||
import type { JobRecord } from "../core/contracts.ts";
|
||||
|
||||
export { extractionStatuses, type ExtractionPhase };
|
||||
export { chatStore };
|
||||
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
|
||||
export { chatStore } from "../store/chats.ts";
|
||||
|
||||
/** A running sub-agent job, joined with its chat's title for the dock. */
|
||||
export interface ActivityJob extends JobRecord {
|
||||
|
||||
+18
-4
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { PATHS } from "../../config.ts";
|
||||
import type { ChatStore } from "../core/ports.ts";
|
||||
import type { ChatRecord, ChatSummary, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||
import type { ChatRecord, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
||||
import type { UIMessage } from "ai";
|
||||
import { findPendingToolCall } from "../core/pending.ts";
|
||||
import { phaseOfTree } from "../core/phases.ts";
|
||||
@@ -51,9 +51,10 @@ function listChats(): ChatSummary[] {
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => getChat(f.slice(0, -5)))
|
||||
.filter((r): r is ChatRecord => r !== null)
|
||||
.map(({ messages, tree, turns, ...summary }) => ({
|
||||
// turns stay out of the list payload (list = summaries; usage
|
||||
// aggregates ride the full record, the picker never needs them).
|
||||
.map(({ messages, tree, turns, extractions, ...summary }) => ({
|
||||
// turns and extractions stay out of the list payload (list =
|
||||
// summaries; usage aggregates ride the full record, the picker
|
||||
// never needs them).
|
||||
...summary,
|
||||
messageCount: messages.length,
|
||||
// Record-derived status for the session picker: phase from the
|
||||
@@ -90,6 +91,18 @@ function appendTurnUsage(id: string, entry: TurnUsage): void {
|
||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
}
|
||||
|
||||
/** Append one extraction-run usage entry to the record's extractions
|
||||
* log — idempotent per startedAt: a retry that reaches the append twice
|
||||
* (abort replays) does not double-count spend. */
|
||||
function appendExtractionUsage(id: string, entry: ExtractionRunUsage): void {
|
||||
const record = getChat(id);
|
||||
if (!record) throw new Error(`Chat not found: ${id}`);
|
||||
record.extractions = record.extractions?.some((r) => r.startedAt === entry.startedAt)
|
||||
? record.extractions
|
||||
: [...(record.extractions ?? []), entry];
|
||||
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
||||
}
|
||||
|
||||
/** Persist the session plan tree (plan tools call this in execute). */
|
||||
function saveTree(id: string, tree: PlanTree): void {
|
||||
const record = getChat(id);
|
||||
@@ -107,4 +120,5 @@ export const chatStore: ChatStore = {
|
||||
saveMessages,
|
||||
saveTree,
|
||||
appendTurnUsage,
|
||||
appendExtractionUsage,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user