Commit Graph
6 Commits
Author SHA1 Message Date
amosblomqvist 17ed37a471 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.
2026-09-11 03:25:09 +03:00
amosblomqvist e33b83ca2d fix: steer-delivery starvation — continuation coalescing (claimSteers)
The bug (seen live): a teacher that ends every turn with a new pending quiz
keeps the wake pipeline's idle gates permanently closed — the steer sits in
'delivering result' forever while the learner answers check after check.

- claimSteers (server/jobs.ts): the chat-handler claims every queued,
  undelivered steer the instant a client turn wins the turn registry, and
  appends it after the learner's answer — conversion-safe, the answered
  quiz carries its output. Every answer flushes pending steers immediately.
- The pump keeps its post-idle duplicate-guard (re-check steerDelivered
  before firing), so a claim racing the pump converges to exactly one
  delivery either way.
- The stale-job sweep now skips chats with a turn in flight (a healthy but
  slow wake no longer gets falsely re-queued as 'recovering').
- Out-of-lane surgical touch (flagged for master review): the 3-line
  handleChatRequest seam (claim after beginTurn, persist+stream 'outgoing').
- Verified live end-to-end on the stuck session: answering the pending quiz
  fired the steer + wake turn within a second; job flipped done/delivered.
2026-09-09 03:04:21 +03:00
amosblomqvist 2f8cf71f52 Wave 2: 409 merge-retry transport, dead pendingJob code, evidence-based timing, prompt-size guard
- components/chat-transport.ts (NEW): createChatTransport(chatId) — the
  chat.tsx seam is now a one-line swap. Its fetch turns a 409 on POST
  /api/chat (a learner send racing the wake pipeline's ~1s adoption window)
  into: wait out the in-flight turn (poll turnInFlight, 30s cap), refetch
  the record, MERGE the outgoing messages onto the fresh server list, re-POST.
  A bare retry would clobber the steer + wake messages via the write-ahead
  persist (steerDelivered makes that loss permanent) — merging is the whole
  point. Unit-tested: pass-through, wait+merge+reorder, dedupe by id,
  repeated-409 loop, bounded give-up.
- src/core/pending.ts: findPendingJobCall deleted (dead since wave 1 —
  job tools resolve server-side with an ack, nothing pends client-side).
  JOB_TOOLS kept (runner.ts still uses it). Header doc rewritten.
- src/server/jobs.ts: GRACE_MS 1500 → 750, with the evidence in the
  comment: the race window is only the learner's answer→POST flight
  (sendAutomaticallyWhen dispatches synchronously; ~10ms localhost,
  <300ms realistic RTT — 750 is 2.5× worst case) and misses self-heal
  via the pump's gate recheck, the 409 backoff ramp, and the new
  transport merge-retry. POLL_MS=400 kept (sampling granularity only).
- tests/jobs.test.ts: prompt-size guard — every fragment ≤8k chars,
  composed prompt ≤60k — the README-leak class (815 bytes ≈ 200 tokens
  per teacher turn before wave 1's fix) can't silently recur.

Gates: typecheck, 279/279 tests, lint, build — all green.
2026-09-05 21:33:13 +03:00
Amos 99fa3f0847 Pi-style ack-and-steer sub-agent protocol: dispatch mid-turn, steer via server wake turn
The job tools (research/demo/visual) switch from client-side turn-breaking
calls to pi's model: execute resolves IMMEDIATELY with a fire-and-forget
ack, the teacher's turn continues, and the result arrives later as an
injected user-role steer message that wakes the teacher in a new
server-side turn.

- contracts.ts: THE ASYNC PROTOCOL rewritten (ack now, steer later); steer
  types (SubagentSteerMessage, metadata.subagentResult + data-subagent-
  artifact part), pure buildSubagentSteer/steerDelivered/isSubagentFailure
- tools/jobs.ts: jobTools(ctx) factory — server-side execute creates the
  job (deduped by toolCallId), fires spawn with the dispatch-time input
  (the part isn't persisted yet at execute — a transcript scan would race)
- runner.ts: runJob(jobId, input?) builds the steer and hands it to the
  injected deliver seam; pipeline owns the done/error flip (a polling
  client re-syncs once, at the end); failures still steer
- server/jobs.ts (new pipeline): per-chat serialized queues on globalThis,
  idle gates (turn liveness + pending question, 1.5s grace), 409 retry
  with backoff, idempotent per jobId, stale-job recovery sweep (re-deliver
  or re-spawn); wake turns run through the same handleChatRequest
  composition via setTurnRunner injection
- chat-handler.ts: wires runner+deliver+tools ctx; drops spawnTurnJobs
  from onFinish; teacher default registry = question tools only
- plan.ts: PlanToolContext split out (plan tools need no job wiring)
- visual-maker: edit_svg added (pi parity — exact-match single
  replacement, fuzzy context on a miss); researcher: pi's kept/dropped
  source accounting restored
- prompts 20-jobs/40-demos: async discipline rewritten (announce and keep
  teaching, no waiting/polling/fabricating, parallel dispatch, teach with
  the artifact when it arrives)
- tests: jobs.test.ts rewritten (dispatch ack mid-turn, runner steers,
  wake pipeline: deferral/409/idempotency/failure), demos.test.tsx runner
  tests ported to runJob + fake deliver; 222 green

FLAGGED surgical touches outside the stream's owned files, for master
review at merge: src/core/contracts.ts (protocol doc + steer types),
src/core/ui-message.ts (job tool + data-part types), src/server/
chat-handler.ts (wiring), src/server/turn-service.ts (spawnTurnJobs
retirement), src/agent/teacher-agent.ts (default tools), src/agent/tools/
plan.ts (context type split).

Verified live on PORT=3003 with real models: teacher dispatched research
mid-turn, researcher ran real searches/fetches, steer injected on quiz
answer, wake turn wove the brief in citing its sources, job flipped done;
a client/wake race correctly 409'd.
2026-09-01 19:15:43 +03:00
Amos 1167157c23 Storage ports: ChatStore/GraphStore/JobStore + adapters + core inversion (stage 3)
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.

The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
  pure — it takes the storage ports as explicit parameters (GraphDeps).
  No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
  live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
  (chatStore/graphStore/jobStore); bodies otherwise verbatim.
  NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
  (they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
  planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
  and chat-handler wire from it; agent (plan tools, sub-agent runner)
  call the port objects. The shell surface (@/src/server exports) is
  unchanged.

Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
2026-08-31 20:00:12 +03:00
Amos e92140f993 Shell/core import law: public API barrels + ESLint enforcement (stage 2)
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):

  components/**  ->  "@/src/core"      (pure core API, browser-safe)
  app/**         ->  "@/src/core" + "@/src/server" (server entries)

Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.

Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
  (the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
  /api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
  transport layer; GET /api/graph's fold side-effect now lives behind
  graphView() instead of inline in the route

Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.

(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
2026-08-31 19:51:17 +03:00