Skip to content

feat(operations): core primitive, resources, transfer events and ctx.operations (#895, 1/6) - #976

Merged
markhayden merged 12 commits into
mainfrom
feat/operations-progress
Oct 16, 2026
Merged

markhayden merged 12 commits into
mainfrom
feat/operations-progress

Conversation

@markhayden

Copy link
Copy Markdown
Owner

Part of #895 (PR 1 of 6). Spec: .claude/specs/operations-progress/SPEC.md (rev 3.1, approved). Plan: .claude/specs/operations-progress/PLAN.md (rev 6, approved after five Codex review rounds).

This PR builds the core of operations: one progress system for every long-running flow. There is no UI yet, and no existing flow switches over yet. PRs 2–6 add the UI and switch the flows over one at a time.

What's in it

  • T0 — antfly spike (tasks/evidence-antfly-pull.md):
    • inference pull downloads into a separate staging folder, publishes the model atomically at the end, and resumes where it left off after SIGTERM/SIGKILL.
    • Its stderr reports structured progress, so model pulls can be cancelled at any point.
    • It ignores ANTFLY_HOME, so it needs --models-dir.
    • SPEC D12 and PLAN 5.2 are updated with these findings.
  • SDK types (packages/sdk/src/types/operations.ts):
    • plan and stages, the full operation view, the context installers report through, and outcomes;
    • the shared quiet/stuck rule (pure, used by server, CLI and UI);
    • the wire shapes and the plugin API types.
  • Registry (packages/core/src/operations/registry.ts):
    • identical requests attach to the running operation;
    • cancel is allowed only before the point of no return, and "cancelled" is reported only after the work has stopped;
    • two clocks (output proves the work is alive, but only real progress counts as progress);
    • every event carries the complete view and a revision number;
    • one audit record per operation, eviction of old entries, and a log line when the server stops.
  • Resources (resources.ts):
    • three lock files acquired in a fixed order: install-state (the existing ~/.bakin/install.lock), then media-store, then search-engine;
    • waiting can be aborted, and locks left by dead processes are reclaimed;
    • one ownership context, so the legacy withInstallLock (now built on top of this) keeps working inside an operation.
  • runLocal runs an operation in-process. src/core/operations.ts is the per-process registry, wired to SSE and the audit log.
  • One download function (packages/core/src/net/download.ts):
    • every download reports progress, stall and retry events and can be cancelled;
    • downloadText and opTransfer live alongside it;
    • pack bins, model files, Whiskit, plugin sources, artifact upgrades and the antfly engine now go through it (the engine streams instead of loading the whole file into memory);
    • src/core/whiskit/download.ts is deleted.
  • HTTP:
    • GET /api/operations[/:id] and POST /api/operations/:id/cancel, each answering with the server's bootId;
    • a shared startOperationResponse helper for converted routes (202 within 500ms);
    • the routes are declared in core-routes and the OpenAPI docs.
  • ctx.operations for plugins:
    • kinds and fingerprints are prefixed with the plugin id;
    • the core resources are reserved for core plugins;
    • the SDK testing harness runs a real registry that keeps its locks in memory.
  • Docs: .claude/knowledge/operations.md, a CLAUDE.md Key Patterns entry plus the ~/.bakin folder map, the plugin guide's "Long-Running Work" section, and corrections to the install-lock descriptions in three knowledge docs.

Fixed along the way

  • Download race: an attempt that failed very quickly could leave a file-open error behind as an unhandled rejection after the caller had deleted its temp folder. A regression test fails without the fix.
  • Test isolation: resource names were checked by computing their lock path, which looked up the Bakin home folder. The test-isolation guard caught this; name checking is now pure.

Commit strategy

Each commit is green on its own. Reverts go in reverse order:

  • the 1.5 commit 844dd5faf (the caller switch) reverts as one unit;
  • the prepare commits revert after the commits that use them.

The lock file only gained extra holder fields, and older readers ignore them.

Verification

  • bun run test: 10,622 of 10,628 pass (18 skipped). The 6 failures are the compiled-binary suites, which fail on the author's Mac because macOS kills every Bun 1.3.13 compiled binary, even a trivial one. CI on Linux runs them.
  • typecheck, lint (0 errors) and check:cycles pass.
  • ui:conformance --quick passes with the Bits checkout at its pinned commit.
  • docs:validate and docs:validate:routes pass.
  • Isolated boot with safe antfly settings: GET /api/operations returns { bootId, operations: [] }, and the 404s carry the bootId.

UI conformance: no UI changes in this PR (the browser pattern comes in PR2).

🤖 Generated with Claude Code

markhayden and others added 12 commits October 16, 2026 09:50
…895)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
)

Six sequenced PRs (T0 antfly spike, primitive, UI, repair, installs,
search, onboarding/runtime) with additive prepare commits and one atomic
switch commit per flow. Five Codex review rounds; every finding resolved.
Spec gains the team-wide sync route and the lesson-toggle exclusion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
T0 for the operations plan. antfly 0.2.2 pulls into a staging dir with
.part files, publishes atomically, resumes at byte level after SIGTERM or
SIGKILL, and reports structured progress on stderr. The pull ignores
ANTFLY_HOME, so runPull will pass --models-dir. SPEC D12 and PLAN 5.2
now carry the decided progress source, cancel rule and stub-binary
stall test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The leaf types for the operations primitive, single-homed in the SDK and
re-exported by @bakin/core/operations: fixed stage kinds, the canonical
core resource order, complete OperationView, OperationContext (with holds
for inner ownership assertions), outcomes and the HTTP wire shapes.
pointOfNoReturn gains null for flows cancellable throughout (previews,
model pulls per the T0 spike). OperationCancelledError lives in core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te-view events (#895)

createOperationRegistry takes its side effects by injection (publish,
audit, log, bootId, now), so the app wires SSE and audit while tests
build their own. It covers:
- atomic fingerprint attach; validation failures unregister and reach
  every attached caller
- cancel only before the point of no return, terminal after run settles
- stages in plan order, items, bytes and attempts on two clocks
  (output is liveness, never progress)
- complete views with a monotonic rev: milestones publish immediately,
  ticks coalesce to the throttle window
- a stuck sweep that logs once per stage, one audit record per finished
  top-level operation, eviction (1h / 50) and the shutdown note

The quiet threshold table and operationQuietState live in the SDK leaf
module because the CLI and browser judge quiet from the same view. The
operation contract types join the single-home architecture guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p context (#895)

install-state, media-store and search-engine become O_EXCL lock files with
holder metadata {pid, bootId, operationId, title, acquiredAt}. install-state
stays at the existing ~/.bakin/install.lock; the others live under
~/.bakin/locks/, and plugin-private resources under locks/<pluginId>/.

- acquireResources sorts into the canonical order, waits abortably with
  onWait and backoff, releases a lease obtained after an abort, and
  reclaims stale holders (dead pid, or this pid with no live holder).
- tryAcquireResources never waits, for background work and 409 writers.
- One AsyncLocalStorage ownership context (runHolding / heldInContext)
  makes acquisition reentrant within one operation's call chain.

install-lock.ts becomes a thin facade over the resource: same exports and
messages, same file, same context. So withInstallLock and
assertInstallLockHeld keep working inside an operation that holds
install-state, and the two APIs exclude each other. PR4 deletes the
facade. An architecture test keeps the lock literals inside
resources.ts; antfly's service lock is out of scope by construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pp registry (#895)

After the 202 an operation acquires its declared resources in canonical
order. While blocked it shows waitingFor naming the holder and stays
cancellable. After a wait, revalidate can end it with an outcome instead
of running. run executes inside the ownership context, so op.holds and the
legacy inner-writer assertion both pass, and the lease is released before
the terminal view is published.

runLocal runs the same machinery in-process and resolves with the
terminal view. Inside an existing holder (an outer operation or
withInstallLock) it is reentrant and never waits on itself.

src/core/operations.ts is the process registry. It sends operation.updated
in the plugin-event envelope (milestones via the replayable broadcast,
ticks via the ephemeral one), audits with the channel derived from origin,
and logs to the server log. The SIGTERM path notes "Server stopping" in
every live operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#895)

downloadToFile gains onEvent and signal, still beside onProgress until
the caller switch removes it. The events are attempt N of M, bytes, a
stall noticed on a body read, and each fresh-request retry with its
classified reason (stalled, deadline, http, network). An abort skips
retry, removes the partial and rethrows the abort reason. Checksum
mismatches still never retry.

downloadText runs through the same attempt loop (follows redirects),
ready to replace the node:http copy in whiskit. opTransfer maps events
onto an operation stage and item. The tar helpers take a signal and
stream stderr lines to onOutput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive (#895)

The caller switch for the transfer primitive:
- pack bins and model files report through onEvent instead of
  onProgress; onProgress is removed from the primitive.
- Whiskit releases, plugin sources and artifact upgrade indexes use the
  primitive's downloadToFile/downloadText. src/core/whiskit/download.ts
  (node:http, no retry, no stall guard) is deleted.
- The antfly engine streams to disk with the pin verified, instead of
  buffering the archive via arrayBuffer(). It keeps its domain error
  wording through the new typed DownloadChecksumError.

Fixes a race the antfly suite exposed: an attempt that failed before
its sink's async open completed could surface a late ENOENT as an
unhandled rejection once the caller removed its temp dir. A regression
test pins it (RED without the fix).

operations-transfers.test.ts pins the result: installer modules never
buffer a body, Bun.write a response or hand-roll node:http(s), the
Whiskit copy stays gone, and onProgress stays gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…art (#895)

GET /api/operations, GET /api/operations/:id[?children=1] and
POST /api/operations/:id/cancel, each answering with the server boot id. A
404 or cancel response therefore lets a client tell expired (same boot)
from restarted (different boot). Cancel maps the registry result to 202
cancelling, 409 not_cancellable / already_finished, or 404.

startOperationResponse is the start every converted route will share:
- 202 { operationId, bootId } within 500ms while the run is blocked or
  waiting on a resource (success-criterion harness)
- 200 attached for an identical live request
- OperationRejectedError thrown by validate keeps today's status and body
operationOriginFromRequest reads the CLI's X-Bakin-Origin marker.

The routes are declared in core-routes (polling GETs routine) and
published in the generated OpenAPI and API reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plugins start long-running work as operations through ctx.operations.start
and can await one. It is available on the full context and on the route
handler's PluginContextLite. The rules live in core (toPluginOperationInput)
so the host and the SDK harness behave identically:
- kind and fingerprint are namespaced <pluginId>: and the owner is
  plugin:<id>
- origin comes from the triggering request (web or cli), else system
- any plugin may declare its own <pluginId>:<name> resources; the core
  resources are reserved for core plugins

@makinbakin/sdk/testing runs a real registry with in-memory resources
(createMemoryResourceAcquirer, via a new injectable acquireResources dep),
so author tests never touch a real Bakin home. The isolation guard found
that sortResources resolved the content dir just to validate names; name
validation is now pure (assertResourceName). The origin rule moves into
core and is shared by the host routes and the plugin API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gin guide (#895)

New .claude/knowledge/operations.md covers the model, the registry, the
resources (canonical order, stale reclaim, legacy-lock interop), runLocal,
the HTTP surface, the transfer primitive, antfly model pulls and
ctx.operations. CLAUDE.md gains the Key Patterns entry plus install.lock
and locks/ in the ~/.bakin map. The plugin docs-site guide gains
Long-Running Work.

The install-lock passages in agent-packages, capability-packs and
plugin-system now name the install-state resource; agent-packages had
still described the long-gone packages/.lock path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant