mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 00:35:27 +08:00
feat(durable): first runnable chat turn (Package 15)
Submissions with request-ID deduplication and handles, the pi.live run control and presentation document, the built-in pi.generation task (system prompt preparation, streamed durable partials, retries, deferred polling, abort), typed entry tokens, stream options and retry policy, TaskRuntime reads, Tx.settleSubmission, registry conversation setups, Storage.scanSubmissions, Harness.inspect, and byTaskId attribution. Waiting calls enable scheduling. The scratch tour is split into test/examples.
This commit is contained in:
@@ -15,6 +15,11 @@
|
||||
- `RegistryReader` now requires `subscribe()`.
|
||||
- Removed `Tx.setTask()`; a task changes its own state by returning the next state from its `runtime.commit()` callback.
|
||||
- `Session.subscribeClose()` listeners now run synchronously when close begins, after admission is sealed.
|
||||
- `defineEntry<D>()` now takes the entry's `data` type instead of a record type; `Entry<D>.is()` narrows to `TypedEntry<D>`.
|
||||
- Added the required `Storage.scanSubmissions()` scan of submissions by conversation and status.
|
||||
- `createRegistry()` now pre-registers the built-in `pi.generation` task and `pi` conversation setup, which cannot be disposed or replaced, and `Harness.open()` rejects a registry whose snapshot lacks either.
|
||||
- `RegistrySnapshot` now requires `conversationSetups()`.
|
||||
- `Tx` now requires `settleSubmission()`.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -28,6 +33,17 @@
|
||||
- Added `createRegistry()` for tools, tool wrappers, hooks, tasks, and system prompt sections with batched publication and stable keyed ordering.
|
||||
- Added `defineEntry()` typed entry kinds.
|
||||
- Added the durable task runtime: `defineTask()`, registry-resolved phase handlers with checkpoint progress rules decided on the Session line, migration at reservation, typed runtime commits, memos, `sleep()`, and invocation-owned watches, plus `Harness.resume()`, `getTask()`, `waitForTask()`, `abortTask()`, and task-aware `waitForIdle()` on the Harness and conversations. Open reconciles running tasks to pending; tasks without a fitting definition stay blocked until registration, and aborting them settles them as `orphaned`.
|
||||
- Added the first runnable chat turn: `Conversation.submit()` with request-ID deduplication, `Submission` handles (`status()`, `wait()`, `abort()`), `Harness.submission()`, `Harness.abortSubmission()`, and `ConversationBusy` for submissions to a busy conversation.
|
||||
- Added the built-in `pi.generation` task: positional system prompt preparation from registered sections (tags, wrappers, failures, minimal patches, order rewrites, and head-cut rebaselines), model requests through `Models`, durable throttled partials in the `pi.live` document (`LiveDoc`), retries with backoff, deferred-response polling and cancellation, and aborted-partial conversion.
|
||||
- Added `Tx.settleSubmission()`, which run tasks use to settle the inputs they answer.
|
||||
- Added built-in entry tokens `UserEntry`, `AssistantEntry`, `SystemEntry`, and `ToolResultEntry`, and token-first `tx.entry()` and `tx.appendEntry()` overloads.
|
||||
- Added `streamOptions` and `retry` to `ConversationConfig` with `get/setStreamOptions()` and `get/setRetryPolicy()` on conversations.
|
||||
- Added `snapshot()`, `snapshotAsOf()`, `context()`, `now()`, and `report()` to `TaskRuntime`.
|
||||
- Added `Harness.inspect()`: live tasks with their derived scheduler state (running, ready, waiting, or blocked with its reason), queued and placed submissions, and registry wrapper failures.
|
||||
- Entries appended by a task's runtime commits now record the task as `byTaskId`.
|
||||
- Added `registry.conversations.setup()`: setups run in every Harness commit that creates or forks a conversation, including raw `Tx.createConversation()` and `Tx.forkConversation()`, before host `init`. The built-in `pi` setup runs first and stages the default configuration with every registered tool active (forks keep their copied configuration) and an empty `pi.live`.
|
||||
- `Conversation.submit()`, `Submission.wait()`, `waitForTask()`, and `waitForIdle()` now enable task scheduling, so they never wait on a Harness whose `resume()` was not called.
|
||||
- Scheduler-written `faulted` and `orphaned` outcomes of a run task now settle the run's input submissions `unanswered` and clear its run control in the same commit.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ facades, membranes, document routing, view projection, events, or clone chains.
|
||||
|
||||
- Obsolete `pico` and `pico4` prototypes were removed.
|
||||
- `pico3` remains.
|
||||
- Packages 1–14 are implemented in `packages/durable`; Package 10 was already satisfied by Chord's canonical structural diff implementation.
|
||||
- Packages 1–15 are implemented in `packages/durable`; Package 10 was already satisfied by Chord's canonical structural diff implementation.
|
||||
|
||||
## 1. Records, cursors, and memory tables
|
||||
|
||||
@@ -351,8 +351,8 @@ and the new definition can reserve the task.
|
||||
|
||||
Deferred to later packages; do not stub them in Package 14:
|
||||
- Orphaning writes the terminal `orphaned` record and retires task documents.
|
||||
Unanswered input submissions and clearing turn control are added in Package
|
||||
15, when submissions and turn control exist.
|
||||
Unanswered input submissions and clearing run control are added in Package
|
||||
15, when submissions and run control exist.
|
||||
- `TaskRuntime.hooks` (the hook runner) is added in Package 16 with hook
|
||||
dispatch. Package 14 adds `runtime.registry` (the phase's registry snapshot)
|
||||
and `runtime.models`.
|
||||
@@ -387,9 +387,9 @@ a public `markTask()` were dropped from §2.2.
|
||||
|
||||
Implement the smallest real input-to-answer vertical path. Extend Package 14's
|
||||
`orphaned` settlement to mark affected input submissions unanswered and clear
|
||||
matching turn control in the same commit, through the Harness hook for
|
||||
matching run control in the same commit, through the Harness hook for
|
||||
scheduler-written outcomes (§5.4); the scheduler learns nothing about turns or
|
||||
task kinds. Implement the final `pi.live` document (§8.2) with `turn` and
|
||||
task kinds. Implement the final `pi.live` document (§8.2) with `run` and
|
||||
`generation`, the built-in entry kinds (§8.1), and the `pi.generation` task
|
||||
(§8.3). The inbox document is defined in Package 17 with its first writer. Add
|
||||
input `Submission` admission, request-ID deduplication, reacquisition and
|
||||
@@ -540,6 +540,11 @@ range selection; summarization; retry policy; staleness checks; and headed
|
||||
summary entries. Wire generation's real overflow path directly to the collapse
|
||||
task, and complete `Conversation.collapse()` so it returns the admitted task ID.
|
||||
|
||||
Generation preparation no longer rechecks the transcript before appending its
|
||||
system entries (§7.4, §12): only run tasks and turn boundaries write to a busy
|
||||
conversation. A collapse summary for a busy conversation must therefore be
|
||||
placed at a turn boundary or as a step of the run, never appended concurrently.
|
||||
|
||||
Test model context before and after collapse, raw history preservation, provider
|
||||
failure, declined and stale work, manual/threshold/overflow admission, late-join
|
||||
presentation state, and reopen from every phase. Rerun generation overflow
|
||||
|
||||
@@ -53,6 +53,10 @@ The core rule is:
|
||||
- A **definition** is a typed token describing one document or document family.
|
||||
- A **source** exposes committed document changes to Chord without exposing a
|
||||
mutable object.
|
||||
- A **turn** is one assistant response and the tool calls it makes. A **run**
|
||||
is the sequence of turns from an admitted input to the final answer; turn
|
||||
boundaries (section 6) sit between its turns. A conversation is busy while a
|
||||
run is active.
|
||||
|
||||
Required invariants:
|
||||
|
||||
@@ -222,6 +226,10 @@ type SubmissionRecord =
|
||||
}
|
||||
));
|
||||
|
||||
type SubmissionSettlement =
|
||||
| { readonly status: "done"; readonly answer: EntryId }
|
||||
| { readonly status: "unanswered"; readonly reason: string; readonly detail?: JsonValue };
|
||||
|
||||
type SubmissionCreate = SubmissionRecord extends infer Record
|
||||
? Record extends SubmissionRecord
|
||||
? Omit<Record, "id">
|
||||
@@ -403,6 +411,27 @@ type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
|
||||
readonly state: Extract<TaskState<JsonValue, R>, { status: "terminal" }>;
|
||||
};
|
||||
|
||||
type TaskInspection = {
|
||||
readonly record: TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
readonly state:
|
||||
| { readonly kind: "running" }
|
||||
| { readonly kind: "ready"; readonly migrates: boolean }
|
||||
| { readonly kind: "waiting"; readonly on: readonly TaskId[] }
|
||||
| {
|
||||
readonly kind: "blocked";
|
||||
readonly reason: "missing_task" | "task_too_old" | "migration_failed";
|
||||
readonly error?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type HarnessInspection = {
|
||||
readonly scheduling: "paused" | "running" | "closing";
|
||||
readonly tasks: readonly TaskInspection[];
|
||||
/** Queued and placed submissions in ID order. */
|
||||
readonly submissions: readonly SubmissionRecord[];
|
||||
readonly registry: readonly RegistryFailure[];
|
||||
};
|
||||
|
||||
type ConversationWatch = WatchHandle<ConversationView>;
|
||||
|
||||
type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H>
|
||||
@@ -459,6 +488,7 @@ interface Harness extends Session {
|
||||
createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;
|
||||
|
||||
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
|
||||
inspect(context: Context): Promise<HarnessInspection>;
|
||||
submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
|
||||
abortSubmission(
|
||||
id: SubmissionId,
|
||||
@@ -489,7 +519,7 @@ settlement.
|
||||
`Harness.open()` binds the Session to one application-owned registry (section
|
||||
7.1). `createRegistry()` pre-registers the built-in task definitions (section 8);
|
||||
they cannot be disposed or replaced, and open rejects a registry whose snapshot
|
||||
lacks any of them.
|
||||
lacks any of them or the built-in `pi` conversation setup.
|
||||
Open changes surviving `running` tasks to `pending` and does nothing else to
|
||||
task records: it never migrates or terminalizes a task because its definition
|
||||
is missing or unmigratable. Such a task stays `pending` and is **blocked**: the
|
||||
@@ -509,7 +539,11 @@ no configured model produces a durable `no_model` generation failure.
|
||||
`resume()` is idempotent while running and only enables scheduling. It does not
|
||||
repeat open-time reconciliation, and it throws after close. Work that must happen
|
||||
before any task runs, such as registration or seeding, happens before
|
||||
`resume()`.
|
||||
`resume()`. Calls that ask for progress also enable scheduling, so they never
|
||||
wait on a paused Harness: `Conversation.submit()`, `Submission.wait()`,
|
||||
`Harness.waitForTask()`, `Harness.waitForIdle()`, and
|
||||
`Conversation.waitForIdle()`. Recovered work starts with them. A viewer that only
|
||||
reads never enables scheduling.
|
||||
|
||||
`createConversation({ ownership, init })` and
|
||||
`fork(at, { ownership, init })` commit atomically: the conversation, its
|
||||
@@ -525,11 +559,16 @@ the ordinary `asOf` fork policy; `init` may then override it with
|
||||
`tx.doc(ConversationConfig, id)`. Other documents follow their own definitions
|
||||
without special handling.
|
||||
|
||||
Raw `tx.createConversation()` and `tx.forkConversation()` remain available.
|
||||
They write no configuration of their own; a conversation without a configuration
|
||||
document reads as `ConversationConfig.definition.initial()` until something
|
||||
writes it. Callers that want Harness defaults use the Harness conveniences or
|
||||
write the document in the same commit.
|
||||
Every Harness commit that creates or forks a conversation runs the registry's
|
||||
conversation setups (section 7.1) in the same commit, whether through the
|
||||
conveniences or through raw `tx.createConversation()` and `tx.forkConversation()`,
|
||||
for example inside a tool commit. The built-in `pi` setup runs first: an
|
||||
independent conversation gets the default configuration and a fork keeps its
|
||||
`asOf` copy, and both get an empty `pi.live`. Applications register setups for
|
||||
their own documents the same way. The conveniences add only `init` and its checks,
|
||||
which run after every setup. A conversation created by a plain Session has no
|
||||
built-in documents; its configuration reads as
|
||||
`ConversationConfig.definition.initial()` until something writes it.
|
||||
|
||||
The built-in conversation configuration document is final at version 1:
|
||||
|
||||
@@ -559,7 +598,8 @@ generation or append a system entry. Request preparation later compares the
|
||||
desired configuration with transcript history and appends the required
|
||||
positional system baseline or delta.
|
||||
|
||||
Default active tools for a root or independent conversation are the names of
|
||||
Default active tools for a root or independent conversation, including one
|
||||
created through `tx.createConversation()` in a Harness commit, are the names of
|
||||
every tool registered when its creation commit runs, in registry order.
|
||||
`setActiveTools()` rejects the whole operation without a write when a name is
|
||||
duplicated or when a name that was not already active in that conversation is
|
||||
@@ -591,7 +631,7 @@ offers an unregistered name, the appended system delta lists it in
|
||||
declaration. When the model calls a tool that is not offered or whose
|
||||
implementation is unavailable at execution time, the tool task appends an error
|
||||
tool result with `details: { code: "tool_unavailable" }` stating that the tool is
|
||||
not available, and the turn continues so the model can react.
|
||||
not available, and the run continues so the model can react.
|
||||
|
||||
A `Conversation.commit()` is a Session commit bound to that conversation.
|
||||
`tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
|
||||
@@ -623,7 +663,7 @@ wait aborts only that waiter.
|
||||
Conversation handles are stateless; compare them by `id`. Hosts discover
|
||||
conversations through lookups and scans. An activity view that lists active
|
||||
conversations and reports conversations becoming active or idle is specified
|
||||
with turn control (Package 17); there is no creation listener.
|
||||
with run control (Package 17); there is no creation listener.
|
||||
|
||||
`submit()` returns after durable admission, not settlement. An input submission
|
||||
creates a user message with the admission timestamp; `whenBusy` defaults to
|
||||
@@ -643,6 +683,18 @@ the terminal receipt. Aborting an already terminal task returns `terminal`; an
|
||||
unknown ID rejects. Explicit task abort includes a background task. Cancelling a
|
||||
task or idle wait does not abort work.
|
||||
|
||||
`inspect()` returns live work at one point on the Session line, for recovery
|
||||
decisions after open, viewers, and diagnostics. It writes nothing, does not
|
||||
enable scheduling, and runs no task code. Each pending or running task carries
|
||||
its state under the current registry: `running` with an active invocation;
|
||||
`ready` when the next scheduling pass would reserve it, with `migrates` when its
|
||||
definition is newer and has `migrate`; `waiting` on unfinished dependencies; or
|
||||
`blocked` (section 5.4). A migration shows as failed only after the scheduler
|
||||
tried it, or when the newer definition has no `migrate`; inspection never runs
|
||||
one to find out. Blocked reasons are derived, never stored. Queued and placed
|
||||
submissions and the registry's wrapper failures complete the view. Finished
|
||||
tasks, settled submissions, transcripts, and documents use their own APIs.
|
||||
|
||||
`Conversation.viewState()` returns its current structural mount as a disposable
|
||||
read-only Chord state. `Conversation.watch()` atomically captures that immutable
|
||||
revision and registers for later exact complete-commit frames. Its `WatchHandle`
|
||||
@@ -915,6 +967,8 @@ interface Tx {
|
||||
createTask<I, S extends { phase: string }, R, H extends object>(
|
||||
task: Task<I, S, R, H>, input: I, options?: TaskOptions,
|
||||
): Promise<TaskId<R>>;
|
||||
/** Settle a queued or placed submission; only a placed input can be answered. A settled one stays unchanged. */
|
||||
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
|
||||
|
||||
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
|
||||
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
|
||||
@@ -1418,7 +1472,8 @@ commits around one effect, but each durable checkpoint is a full replacement.
|
||||
`TaskRuntime.commit()` rereads and gates the current durable task on the Session
|
||||
line before invoking its callback. It rejects when the invocation has ended, the
|
||||
Harness is closing, the task is terminal, or a run invocation's task carries an
|
||||
abort mark. Its `tx.createTask()` defaults to the task's conversation. When the
|
||||
abort mark. Its `tx.createTask()` defaults to the task's conversation, and every
|
||||
entry it appends records the task as `byTaskId`. When the
|
||||
callback returns a state, the runtime replaces the task's state in the same
|
||||
commit, so the checkpoint or outcome is atomic with the callback's entries,
|
||||
documents, and child tasks and is type-checked against the task's checkpoint and
|
||||
@@ -1633,19 +1688,19 @@ cannot take the task, the marking commit settles it directly; otherwise the
|
||||
scheduler settles it when it would reserve the abort invocation. Only an abort (direct, by
|
||||
conversation, or by cascade) orphans a task; a missing definition alone never
|
||||
does. The orphaning commit performs the cleanup the task's code cannot: affected
|
||||
input submissions become unanswered with the reason, any matching active turn
|
||||
input submissions become unanswered with the reason, any matching active run
|
||||
control is cleared, and task-scoped documents retire. No transcript entry is
|
||||
written; the terminal task record and unanswered submissions carry the reason.
|
||||
Faulting a turn task performs the same control/submission cleanup with a
|
||||
Faulting a run task performs the same control/submission cleanup with a
|
||||
`faulted` outcome.
|
||||
|
||||
The scheduler knows nothing about turns or task kinds. The Harness, which owns
|
||||
submissions, turn control, and the built-in tasks, gives it one hook that the
|
||||
The scheduler knows nothing about runs or task kinds. The Harness, which owns
|
||||
submissions, run control, and the built-in tasks, gives it one hook that the
|
||||
scheduler calls in the same commit for every terminal outcome it writes itself
|
||||
(`faulted` and `orphaned`). The hook ignores tasks whose kind is not a built-in
|
||||
turn kind, so it never creates `pi.live` elsewhere. It settles the turn when
|
||||
`pi.live.turn` names the task (section 8): its inputs become `unanswered` with reason `faulted`
|
||||
(detail: the error message) or the blocked reason, and `turn` and `generation`
|
||||
run kind, so it never creates `pi.live` elsewhere. It settles the run when
|
||||
`pi.live.run` names the task (section 8): its inputs become `unanswered` with reason `faulted`
|
||||
(detail: the error message) or the blocked reason, and `run` and `generation`
|
||||
are removed. Outcomes a task commits for itself do their own settlement.
|
||||
|
||||
At every normal phase boundary (section 5.1, rule 5), the step refreshes the
|
||||
@@ -1662,8 +1717,8 @@ never settles never hands over.
|
||||
|
||||
## 6. Submissions and inbox
|
||||
|
||||
Submission records back awaitable host objects. Their record transitions use
|
||||
Session-private transaction operations, not the public `Tx` interface. The inbox
|
||||
Submission records back awaitable host objects. Admission is Harness-internal;
|
||||
run tasks settle the inputs they answer with `tx.settleSubmission()`. The inbox
|
||||
itself is an ordered conversation document containing tagged items:
|
||||
|
||||
```ts
|
||||
@@ -1672,26 +1727,26 @@ type InboxItem =
|
||||
| { readonly id: SubmissionId; readonly mode: "write"; readonly entry: EntryDraft };
|
||||
```
|
||||
|
||||
Turn control lives in the built-in live document `pi.live` (section 8). Its
|
||||
optional `turn` value names the task currently responsible for the turn and its
|
||||
placed input-submission IDs. `turn !== undefined` defines `busy`; get-or-create
|
||||
Run control lives in the built-in live document `pi.live` (section 8). Its
|
||||
optional `run` value names the task currently responsible for the run and its
|
||||
placed input-submission IDs. `run !== undefined` defines `busy`; get-or-create
|
||||
of the idle document does not. The value remains while generation, tools, and
|
||||
post-tools hand work to one another. The ID list is mutable state because a
|
||||
boundary adds placed steering inputs to a running turn; every terminal path
|
||||
boundary adds placed steering inputs to an active run; every terminal path
|
||||
settles exactly the listed inputs.
|
||||
|
||||
Admission and terminal transitions:
|
||||
|
||||
| action | submission state | other writes |
|
||||
|---|---|---|
|
||||
| idle input submission | `placed`, with user entry | create turn controller/generation |
|
||||
| idle input submission | `placed`, with user entry | create run controller/generation |
|
||||
| busy input submission | `queued` | append steer/follow-up inbox item |
|
||||
| idle write submission | `done`, with entry | append entry; no turn |
|
||||
| idle write submission | `done`, with entry | append entry; no run |
|
||||
| busy write submission | `queued` | append write inbox item |
|
||||
| boundary places user item | `placed`, with entry | add ID to current/successor turn |
|
||||
| boundary places user item | `placed`, with entry | add ID to current/successor run |
|
||||
| boundary places write | `done`, with entry | append entry |
|
||||
| turn answers | input `done`, with required answer entry | clear/hand off turn controller |
|
||||
| turn fails or aborts | input `unanswered`, with reason | clear/hand off turn controller |
|
||||
| run answers | input `done`, with required answer entry | clear/hand off run controller |
|
||||
| run fails or aborts | input `unanswered`, with reason | clear/hand off run controller |
|
||||
| withdraw queued item | `unanswered`, reason `aborted` | remove inbox item |
|
||||
| stale item | `unanswered`, reason `stale` | remove inbox item |
|
||||
|
||||
@@ -1712,12 +1767,12 @@ Boundary selection is deterministic by item ID:
|
||||
| `final` | all | first/all by mode | first/all by mode |
|
||||
|
||||
A queued self-head write cuts older pending user items: those submissions become
|
||||
stale, the write is placed, and the current turn terminates. Other head writes
|
||||
stale, the write is placed, and the current run terminates. Other head writes
|
||||
whose target predates the caller's newest known head are stale.
|
||||
|
||||
At ordinary `postTools`, generation continues even with no queued trigger;
|
||||
selected steer IDs join that continuation. A terminating/handoff post-tools
|
||||
boundary uses final behavior instead. At `final`, the current turn's placed
|
||||
boundary uses final behavior instead. At `final`, the current run's placed
|
||||
input submissions settle first; selected user IDs start one successor generation. Writes
|
||||
never trigger generation by themselves. A final boundary without continuation
|
||||
or user triggers leaves the conversation idle.
|
||||
@@ -1779,6 +1834,24 @@ type PromptSectionWrapper<Tool extends ToolRegistration> = (
|
||||
section: PromptSection<Tool>,
|
||||
) => PromptSection<Tool>;
|
||||
|
||||
/**
|
||||
* Stages the documents every new conversation gets, inside the creating commit, after fork copies and before host
|
||||
* `init`. Table reads throw; a fork (`conversation.parent`) already holds its copied documents. A throw fails the
|
||||
* creation.
|
||||
*/
|
||||
type ConversationSetup = (
|
||||
tx: Tx,
|
||||
conversation: ConversationRecord,
|
||||
registry: RegistrySnapshot<ToolRegistration>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type RegistryFailure = {
|
||||
readonly kind: "tool" | "section";
|
||||
/** Tool name or section key. */
|
||||
readonly name: string;
|
||||
readonly error: unknown;
|
||||
};
|
||||
|
||||
interface RegistryReader<Tool extends ToolRegistration = ToolRegistration> {
|
||||
/** Immutable view of the whole current registry. */
|
||||
snapshot(): RegistrySnapshot<Tool>;
|
||||
@@ -1801,11 +1874,9 @@ interface RegistrySnapshot<Tool extends ToolRegistration> {
|
||||
/** Composed sections in registry order; a section whose wrapper failed is absent. */
|
||||
sections(): readonly PromptSection<Tool>[];
|
||||
/** Wrapper failures of this state; the Harness reports them where it uses them. */
|
||||
failures(): readonly {
|
||||
readonly kind: "tool" | "section";
|
||||
readonly name: string;
|
||||
readonly error: unknown;
|
||||
}[];
|
||||
failures(): readonly RegistryFailure[];
|
||||
/** Conversation setups in registry order, the built-in `pi` setup first. */
|
||||
conversationSetups(): readonly { readonly key: string; readonly setup: ConversationSetup }[];
|
||||
}
|
||||
|
||||
interface Registry<Tool extends ToolRegistration = ToolRegistration> extends RegistryReader<Tool> {
|
||||
@@ -1825,6 +1896,9 @@ interface Registry<Tool extends ToolRegistration = ToolRegistration> extends Reg
|
||||
add(task: AnyTask): Registration;
|
||||
list(): readonly AnyTask[];
|
||||
};
|
||||
readonly conversations: {
|
||||
setup(key: string, setup: ConversationSetup): Registration;
|
||||
};
|
||||
readonly systemPrompt: {
|
||||
section(
|
||||
key: string,
|
||||
@@ -1847,11 +1921,12 @@ so application metadata never enters the transcript.
|
||||
|
||||
Registration rules:
|
||||
|
||||
- `createRegistry()` starts with the built-in task definitions of section 8
|
||||
registered first. They have no `Registration`, so they cannot be disposed, and
|
||||
the duplicate-name rule rejects any other task with a built-in name.
|
||||
`tasks.list()` includes them. Hooks register against built-in tasks like any
|
||||
other task.
|
||||
- `createRegistry()` starts with the built-in task definitions of section 8 and
|
||||
the built-in `pi` conversation setup registered first. They have no
|
||||
`Registration`, so they cannot be disposed, and the duplicate rule rejects
|
||||
another task with a built-in name or another `pi` setup. `tasks.list()`
|
||||
includes them. Hooks register against built-in tasks like any other task.
|
||||
Setup keys are unique and ordered like section keys.
|
||||
- Tool names, section keys, and task names are unique among published
|
||||
registrations. Tool wrapper keys are unique per tool name, section wrapper
|
||||
keys per section key, and hook keys per task name. Duplicates reject.
|
||||
@@ -1881,7 +1956,7 @@ one per phase-handler invocation and passes it through the runtime; handlers and
|
||||
hooks read that snapshot and never take their own. At every normal phase boundary
|
||||
the scheduler takes a fresh one (section 5.4). A tool task keeps the composed tool
|
||||
it pinned until execution settles. Different phases may observe different
|
||||
registry states; nothing requires one turn to see a single registry state across
|
||||
registry states; nothing requires one run to see a single registry state across
|
||||
its generation, tool, and post-tools tasks. Host operations, such as the create
|
||||
conveniences, take one snapshot inside their commit.
|
||||
|
||||
@@ -2036,7 +2111,7 @@ argument validation, replay policy, and execution.
|
||||
A tool call is accepted only if it was offered in the request's effective
|
||||
system/tool history. A call to a tool that is not offered or has no registered
|
||||
implementation produces the `tool_unavailable` error result described in section
|
||||
2.2 instead of failing the turn. The tool task resolves the composed tool once
|
||||
2.2 instead of failing the run. The tool task resolves the composed tool once
|
||||
from its snapshot before argument validation and pins it until execution
|
||||
settles, even across later snapshot refreshes. Arguments must satisfy both the offered
|
||||
declaration and the pinned implementation's schema; they are validated before
|
||||
@@ -2177,10 +2252,10 @@ Generation preparation takes these steps against its phase snapshot:
|
||||
runtime). The results are the desired sections.
|
||||
4. Compare desired sections and tool declarations with the replayed state and
|
||||
append one positional `pi.system` entry when they differ.
|
||||
5. The commit appending that entry first checks that the conversation's tail
|
||||
entry and configuration are unchanged since step 1. Entries only append, so an
|
||||
unchanged tail also means an unchanged active head. If either changed,
|
||||
preparation reruns from step 1 with the same registry snapshot.
|
||||
|
||||
Preparation does not recheck the transcript before appending: only the Harness
|
||||
writes to a busy conversation, through submissions, run tasks, and boundaries,
|
||||
so the transcript it read is still current (section 12).
|
||||
|
||||
Model and thinking level are request options, not prompt state.
|
||||
|
||||
@@ -2192,7 +2267,8 @@ which each key was first registered. A section whose `render` returns
|
||||
`undefined` is omitted, which is how a section varies by conversation. With
|
||||
`tag` omitted or true, text is wrapped as `<key>\n...\n</key>`. A section that
|
||||
throws keeps its shown text, if any, and is reported; the request is still sent.
|
||||
Abort errors propagate. With no registered sections, the desired section set is
|
||||
Errors thrown after the generation invocation is cancelled propagate; an abort
|
||||
error of the section's own, such as its fetch timing out, is an ordinary failure. With no registered sections, the desired section set is
|
||||
empty.
|
||||
|
||||
A minimal prompt is one untagged section:
|
||||
@@ -2325,8 +2401,8 @@ messages out of later requests, so no separate usage or notice kind exists.
|
||||
|
||||
```ts
|
||||
type LiveState = {
|
||||
/** Turn control (section 6); present exactly while the conversation is busy. */
|
||||
turn?: { taskId: TaskId; inputs: SubmissionId[] };
|
||||
/** Run control (section 6); present exactly while the conversation is busy. */
|
||||
run?: { taskId: TaskId; inputs: SubmissionId[] };
|
||||
/** Presentation of the current generation attempt. */
|
||||
generation?: {
|
||||
attempt: number;
|
||||
@@ -2346,16 +2422,21 @@ type LiveState = {
|
||||
| version | `1` |
|
||||
| scope/history/fork | conversation, `latest`, `initial` |
|
||||
| `initial()` | `{}` |
|
||||
| checkpoint | base when no field is present, otherwise every 64 deltas |
|
||||
| checkpoint | complete base whenever `generation` is absent |
|
||||
| view mount | `docs["pi.live"]` |
|
||||
| created | with every Harness conversation (section 2.2) |
|
||||
|
||||
Nothing is in flight at every turn boundary and while idle, so the stored delta
|
||||
chain spans at most one generation, including its retries and deferred polls, or
|
||||
one tool round, and each base is small. Tool progress adds its own condition with the tool task.
|
||||
|
||||
Tool progress and compaction status join this document with the tool and
|
||||
collapse tasks. Partials are normalized to strict JSON before assignment. Every
|
||||
terminal path of a turn task removes `turn` and `generation` in the commit that
|
||||
settles the turn's inputs. Settlement stages each input's new status through a
|
||||
Session-private transaction operation that resolves the transaction's latest
|
||||
candidate submission record, falling back to committed state, during assembly,
|
||||
like task-document validation (section 3.3), so it is not a caller table read and works after the commit's first table write.
|
||||
terminal path of a run task removes `run` and `generation` in the commit that
|
||||
settles the run's inputs. `tx.settleSubmission()` stages each input's new status
|
||||
and resolves the transaction's latest candidate submission record, falling back
|
||||
to committed state, during assembly, like task-document validation (section 3.3),
|
||||
so it is not a caller table read and works after the commit's first table write.
|
||||
|
||||
### 8.3 Generation
|
||||
|
||||
@@ -2368,6 +2449,7 @@ type GenerationCheckpoint =
|
||||
attempt: number;
|
||||
model: ModelRef;
|
||||
thinkingLevel: ModelThinkingLevel;
|
||||
streamOptions: ConversationStreamOptions;
|
||||
/** Newest entry included in the request. */
|
||||
cutoff: EntryId;
|
||||
}
|
||||
@@ -2377,13 +2459,13 @@ type GenerationResult = { entryId: EntryId };
|
||||
```
|
||||
|
||||
`pi.generation` is version 1 and starts at `{ phase: "prepare", attempt: 1 }`.
|
||||
The turn's inputs live in `pi.live.turn`, not in the task input.
|
||||
The run's inputs live in `pi.live.run`, not in the task input.
|
||||
|
||||
- `prepare` runs section 7.4. A read-only commit captures the tail and the
|
||||
configuration. When no model is configured or `models.getModel()` does not
|
||||
know it, the task fails with `no_model`. Otherwise the final commit appends
|
||||
the planned `pi.system` entries and moves to `request` with the new tail as
|
||||
`cutoff`.
|
||||
- `prepare` runs section 7.4 against the committed configuration. When no model
|
||||
is configured or `models.getModel()` does not know it, the task fails with
|
||||
`no_model`. Otherwise one commit appends the planned `pi.system` entries and
|
||||
moves to `request` with the new tail as `cutoff` and the configuration's model,
|
||||
thinking level, and stream options.
|
||||
- `request` and `poll` resolve the checkpoint's model through
|
||||
`models.getModel()`; an unknown model fails the task with `no_model`, like
|
||||
`prepare`. `request` converts a leftover partial (below) before it resolves
|
||||
@@ -2391,14 +2473,14 @@ The turn's inputs live in `pi.live.turn`, not in the task input.
|
||||
- `request` first converts a committed partial left in `pi.live` by an
|
||||
interrupted attempt into an aborted `pi.assistant` entry. It then streams the
|
||||
model context through `cutoff` with the invocation signal, the thinking level
|
||||
as `reasoning` (omitted for `off`), and the conversation's `streamOptions`,
|
||||
as `reasoning` (omitted for `off`), and the pinned `streamOptions`,
|
||||
committing throttled partials. Recovery resends the same messages with the
|
||||
pinned model and thinking level; stream options are read when sending.
|
||||
same pinned model, thinking level, and stream options.
|
||||
- Before classifying, the handler stops the partial throttle and awaits any
|
||||
partial commit in flight, so no stale partial lands after the outcome. The
|
||||
terminal message is classified in one commit that also clears the partial:
|
||||
- `stop`/`length`: append the answer, settle the turn's inputs `done`, remove
|
||||
`turn` and `generation`, and complete with `{ entryId }`. The same commit
|
||||
- `stop`/`length`: append the answer, settle the run's inputs `done`, remove
|
||||
`run` and `generation`, and complete with `{ entryId }`. The same commit
|
||||
applies the final boundary (section 6).
|
||||
- `toolUse`: append the assistant entry and continue through the tool chain.
|
||||
- `error` that `isRetryableAssistantError()` accepts while the conversation's
|
||||
@@ -2406,7 +2488,7 @@ The turn's inputs live in `pi.live.turn`, not in the task input.
|
||||
so `maxRetries` counts retries after the first attempt): append the error entry and move to
|
||||
`retry` with `until = now + retryDelayMs(policy, attempt)`.
|
||||
- any other `error`, or `aborted` without an abort mark: append the error
|
||||
entry, settle the inputs `unanswered` with `model_error`, remove `turn` and
|
||||
entry, settle the inputs `unanswered` with `model_error`, remove `run` and
|
||||
`generation`, and fail.
|
||||
- `deferred`: move to `poll` with `pollAt = now + (handle.pollAfterMs ?? 5000)`.
|
||||
- `retry` sleeps until `until`, then returns to `prepare` with the next attempt,
|
||||
@@ -2416,7 +2498,7 @@ The turn's inputs live in `pi.live.turn`, not in the task input.
|
||||
as above.
|
||||
- The abort handler calls `models.cancelDeferred()` in `poll` when the model is
|
||||
known (a failure is reported), converts a committed partial, settles the inputs `unanswered` with
|
||||
`aborted`, removes `turn` and `generation`, and ends `aborted`.
|
||||
`aborted`, removes `run` and `generation`, and ends `aborted`.
|
||||
|
||||
Input submissions settle `unanswered` with one of these reasons: `no_model`,
|
||||
`model_error` (detail: provider error text), `aborted`, `faulted` (detail: error
|
||||
@@ -2681,6 +2763,11 @@ type TaskQuery = {
|
||||
readonly background?: boolean;
|
||||
};
|
||||
|
||||
type SubmissionQuery = {
|
||||
readonly conversationId?: ConversationId;
|
||||
readonly status?: SubmissionRecord["status"];
|
||||
};
|
||||
|
||||
type DocumentPoint = Seq | "current";
|
||||
|
||||
type DocumentAddress = {
|
||||
@@ -2753,6 +2840,7 @@ interface Storage {
|
||||
scanTasks(query: TaskQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, Cursor>>;
|
||||
|
||||
submission(id: SubmissionId, context: Context): Promise<SubmissionRecord | undefined>;
|
||||
scanSubmissions(query: SubmissionQuery, limit: number, cursor: Cursor | undefined, context: Context): Promise<Page<SubmissionRecord, Cursor>>;
|
||||
submissionByRequest(conversationId: ConversationId, requestId: string, context: Context): Promise<SubmissionRecord | undefined>;
|
||||
|
||||
findDocument(address: DocumentAddress, at: DocumentPoint, context: Context): Promise<DocumentRecord | undefined>;
|
||||
@@ -2916,6 +3004,10 @@ These are contracts, not invitations to add defensive machinery:
|
||||
unsupported.
|
||||
- **Read after write:** read every required table row before the first table
|
||||
write. Document drafts remain usable afterward; table reads do not.
|
||||
- **Writing to a busy conversation:** only the Harness appends to a conversation
|
||||
with an active run. Raw entries appended by `Harness.commit()` or a custom
|
||||
task while a generation prepares its request can misplace its system prompt entries;
|
||||
use a write submission.
|
||||
- **Long transactions:** an async commit callback holds the Session mutation
|
||||
line. Never await models, tools, processes, network calls, humans, a nested
|
||||
Session commit, or a Session waiter inside it. Use methods on the current `Tx`.
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import type { Entry } from "./harness/types.ts";
|
||||
import type { EntryRecord } from "./types.ts";
|
||||
import type { JsonValue } from "@earendil-works/chord";
|
||||
import type { Entry, EntryRecord, TypedEntry } from "./types.ts";
|
||||
|
||||
/** Define a typed entry kind whose `is()` guard narrows by `EntryRecord.kind`. */
|
||||
export function defineEntry<E extends EntryRecord>(kind: string): Entry<E> {
|
||||
export function defineEntry<D extends JsonValue = never>(kind: string): Entry<D> {
|
||||
if (typeof kind !== "string" || kind.length === 0) throw new TypeError("Entry kind must be a non-empty string");
|
||||
return {
|
||||
kind,
|
||||
is: (entry: EntryRecord | undefined): entry is E => entry !== undefined && entry.kind === kind,
|
||||
is: (entry: EntryRecord | undefined): entry is TypedEntry<D> => entry !== undefined && entry.kind === kind,
|
||||
};
|
||||
}
|
||||
|
||||
/** User input: `model` is `[UserMessage]`. Written by submissions. */
|
||||
export const UserEntry = defineEntry("pi.user");
|
||||
/** Provider result with any stop reason: `model` is `[AssistantMessage]`. Written by generation. */
|
||||
export const AssistantEntry = defineEntry("pi.assistant");
|
||||
/** Positional prompt and tool change: `model` is `[SystemMessage]` with empty `content`. */
|
||||
export const SystemEntry = defineEntry("pi.system");
|
||||
/** Tool result: `model` is `[ToolResultMessage]`. Written by tool tasks. */
|
||||
export const ToolResultEntry = defineEntry("pi.tool-result");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ConversationId } from "./types.ts";
|
||||
|
||||
/** A transaction read a table after its first table write. Read every required row before writing. */
|
||||
export class ReadAfterWrite extends Error {
|
||||
constructor(method: string) {
|
||||
@@ -13,3 +15,14 @@ export class StorageRejected extends Error {
|
||||
this.name = "StorageRejected";
|
||||
}
|
||||
}
|
||||
|
||||
/** A submission reached a busy conversation and was not admitted. */
|
||||
export class ConversationBusy extends Error {
|
||||
readonly conversationId: ConversationId;
|
||||
|
||||
constructor(conversationId: ConversationId) {
|
||||
super(`Conversation ${conversationId} is busy`);
|
||||
this.name = "ConversationBusy";
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import type { ConversationRetryPolicy, ConversationStreamOptions } from "./types.ts";
|
||||
|
||||
/** Durable per-conversation model, thinking level, and desired tool loadout. */
|
||||
/** Durable per-conversation model, thinking level, request options, and desired tool loadout. */
|
||||
export type ConversationConfigState = {
|
||||
model?: { provider: string; modelId: string };
|
||||
thinkingLevel: ModelThinkingLevel;
|
||||
/** Desired tool names in offered order; names may be unregistered in the current process. */
|
||||
activeTools: string[];
|
||||
/** Forwarded to every generation request. */
|
||||
streamOptions?: ConversationStreamOptions;
|
||||
/** Durable generation attempt retries; absent uses `DEFAULT_RETRY_POLICY`. */
|
||||
retry?: ConversationRetryPolicy;
|
||||
};
|
||||
|
||||
export const DEFAULT_RETRY_POLICY: ConversationRetryPolicy = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 2000,
|
||||
maxAgentDelayMs: 60000,
|
||||
};
|
||||
|
||||
/** Built-in configuration document; rewindable so forks start from the configuration at their fork entry. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { SessionImpl } from "../session/session.ts";
|
||||
import type { ContextEdit, ConversationId, Cursor, EntryId, EntryRecord, Storage } from "../types.ts";
|
||||
import type { ContextView } from "./types.ts";
|
||||
|
||||
@@ -14,17 +15,39 @@ export type ContextBounds = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture the bounds of the current context with two O(1) reads. Run this on the Session line; entries at or below
|
||||
* the tail are immutable, so `deriveContext()` can then scan them off the line.
|
||||
* Capture the bounds of the current context, or of the context cut off at the visible entry `at`, with two O(1)
|
||||
* reads. Run this on the Session line; entries at or below the tail are immutable, so `deriveContext()` can then scan
|
||||
* them off the line.
|
||||
*/
|
||||
export async function captureContextBounds(
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
context: Context,
|
||||
at?: EntryId,
|
||||
): Promise<ContextBounds | undefined> {
|
||||
const tail = (await storage.scanEntries({ conversationId }, 1, undefined, context)).items[0];
|
||||
if (tail === undefined) return undefined;
|
||||
return { head: await storage.findLatestHeadMarker(conversationId, tail.id, context), tail: tail.id };
|
||||
let tail: EntryId | undefined;
|
||||
if (at === undefined) {
|
||||
tail = (await storage.scanEntries({ conversationId }, 1, undefined, context)).items[0]?.id;
|
||||
if (tail === undefined) return undefined;
|
||||
} else {
|
||||
if ((await storage.entry(conversationId, at, context)) === undefined) {
|
||||
throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
|
||||
}
|
||||
tail = at;
|
||||
}
|
||||
return { head: await storage.findLatestHeadMarker(conversationId, tail, context), tail };
|
||||
}
|
||||
|
||||
/** Committed context of one conversation: bounds captured on the Session line, entries derived off it. */
|
||||
export async function readContext(
|
||||
session: SessionImpl,
|
||||
storage: Storage,
|
||||
conversationId: ConversationId,
|
||||
context: Context,
|
||||
at?: EntryId,
|
||||
): Promise<ContextView> {
|
||||
const bounds = await session.readOnLine(() => captureContextBounds(storage, conversationId, context, at));
|
||||
return deriveContext(storage, conversationId, bounds, context);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { type Context, copyJson, type Draft, type JsonValue } from "@earendil-works/chord";
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type DeferredHandle,
|
||||
isRetryableAssistantError,
|
||||
type Message,
|
||||
type Model,
|
||||
type ModelThinkingLevel,
|
||||
retryDelayMs,
|
||||
type SimpleStreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { AssistantEntry, SystemEntry } from "../entries.ts";
|
||||
import { defineTask } from "../tasks.ts";
|
||||
import type { ConversationId, EntryId, NextTaskState, TaskRuntime, Tx } from "../types.ts";
|
||||
import { ConversationConfig, DEFAULT_RETRY_POLICY } from "./config.ts";
|
||||
import { endRun, LiveDoc, type LiveState } from "./live.ts";
|
||||
import { planSystemEntries, renderSections, replaySections } from "./prompt.ts";
|
||||
import type { ConversationStreamOptions, ModelRef } from "./types.ts";
|
||||
|
||||
export type GenerationInput = Record<string, never>;
|
||||
|
||||
export type GenerationCheckpoint =
|
||||
| { phase: "prepare"; attempt: number }
|
||||
| {
|
||||
phase: "request";
|
||||
attempt: number;
|
||||
model: ModelRef;
|
||||
thinkingLevel: ModelThinkingLevel;
|
||||
/** Configured request options when preparation committed; a resend after recovery uses them unchanged. */
|
||||
streamOptions: ConversationStreamOptions;
|
||||
/** Newest entry included in the request. */
|
||||
cutoff: EntryId;
|
||||
}
|
||||
| { phase: "retry"; attempt: number; until: number }
|
||||
| { phase: "poll"; attempt: number; model: ModelRef; handle: DeferredHandle; pollAt: number };
|
||||
|
||||
export type GenerationResult = { entryId: EntryId };
|
||||
|
||||
type Runtime = TaskRuntime<GenerationInput, GenerationCheckpoint, GenerationResult, object>;
|
||||
type Next = NextTaskState<GenerationCheckpoint, GenerationResult>;
|
||||
|
||||
const PARTIAL_THROTTLE_MS = 100;
|
||||
const DEFAULT_POLL_AFTER_MS = 5000;
|
||||
|
||||
/**
|
||||
* Built-in generation task: prepares the positional system prompt, requests or polls the model, retries, and classifies
|
||||
* the response. The run's inputs live in `pi.live.run`.
|
||||
*/
|
||||
export const GenerationTask = defineTask<GenerationInput, GenerationCheckpoint, GenerationResult>({
|
||||
name: "pi.generation",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "prepare", attempt: 1 }),
|
||||
phases: {
|
||||
/**
|
||||
* Render the system prompt and append the positional `pi.system` entries it needs, then move to `request`. Only
|
||||
* the Harness writes to a busy conversation, so the transcript read here is still the tail at the commit.
|
||||
*/
|
||||
prepare: async (task, runtime, context) => {
|
||||
const { conversationId, registry } = runtime;
|
||||
for (const failure of registry.failures()) if (failure.kind === "section") runtime.report(failure.error);
|
||||
const { model, thinkingLevel, streamOptions } =
|
||||
(await runtime.snapshot(ConversationConfig, conversationId, context)) ??
|
||||
ConversationConfig.definition.initial();
|
||||
if (model === undefined || runtime.models.getModel(model.provider, model.modelId) === undefined) {
|
||||
return failNoModel(runtime, model, context);
|
||||
}
|
||||
const view = await runtime.context(conversationId, context);
|
||||
const shown = replaySections(view.messages);
|
||||
const input = {
|
||||
conversationId,
|
||||
tools: [],
|
||||
shown: Object.fromEntries(shown),
|
||||
model,
|
||||
thinkingLevel,
|
||||
read: runtime,
|
||||
};
|
||||
const report = (error: unknown) => runtime.report(error);
|
||||
const desired = await renderSections(registry.sections(), input, shown, report, context);
|
||||
const entries = planSystemEntries(view, desired, runtime.now());
|
||||
await runtime.commit(async (tx) => {
|
||||
let cutoff = (await tx.scanEntries({ conversationId }, 1)).items[0]?.id;
|
||||
for (const entry of entries) cutoff = (await tx.appendEntry(SystemEntry, conversationId, entry)).id;
|
||||
if (cutoff === undefined) throw new Error(`Conversation ${conversationId} has no entries to send`);
|
||||
const { attempt } = task.state.checkpoint;
|
||||
const request = { attempt, model, thinkingLevel, streamOptions: streamOptions ?? {}, cutoff };
|
||||
return { status: "running", checkpoint: { phase: "request", ...request } };
|
||||
}, context);
|
||||
},
|
||||
request: async (task, runtime, context) => {
|
||||
const { attempt, model: ref, thinkingLevel, streamOptions, cutoff } = task.state.checkpoint;
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
await convertPartial(tx, live, conversationId);
|
||||
live.generation = { attempt };
|
||||
return undefined;
|
||||
}, context);
|
||||
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
||||
if (model === undefined) return failNoModel(runtime, ref, context);
|
||||
const view = await runtime.context(conversationId, context, cutoff);
|
||||
const options: SimpleStreamOptions = {
|
||||
...streamOptions,
|
||||
signal: runtime.signal,
|
||||
...(thinkingLevel === "off" ? {} : { reasoning: thinkingLevel }),
|
||||
};
|
||||
const message = await streamResponse(runtime, model, view.messages, options, attempt, context);
|
||||
await classify(runtime, attempt, ref, undefined, message, context);
|
||||
},
|
||||
retry: async (task, runtime, context) => {
|
||||
const { attempt, until } = task.state.checkpoint;
|
||||
await runtime.sleep(until, context);
|
||||
await runtime.commit(async (tx) => {
|
||||
(await tx.doc(LiveDoc, runtime.conversationId)).generation = { attempt: attempt + 1 };
|
||||
return { status: "running", checkpoint: { phase: "prepare", attempt: attempt + 1 } };
|
||||
}, context);
|
||||
},
|
||||
poll: async (task, runtime, context) => {
|
||||
const { attempt, model: ref, handle, pollAt } = task.state.checkpoint;
|
||||
const model = runtime.models.getModel(ref.provider, ref.modelId);
|
||||
if (model === undefined) return failNoModel(runtime, ref, context);
|
||||
await runtime.sleep(pollAt, context);
|
||||
const message = await runtime.models.fetchDeferred(model, handle, { signal: runtime.signal });
|
||||
await classify(runtime, attempt, ref, pollAt, message, context);
|
||||
},
|
||||
},
|
||||
abort: async (task, runtime, context) => {
|
||||
const checkpoint = task.state.checkpoint;
|
||||
if (checkpoint.phase === "poll") {
|
||||
const model = runtime.models.getModel(checkpoint.model.provider, checkpoint.model.modelId);
|
||||
if (model !== undefined) {
|
||||
try {
|
||||
await runtime.models.cancelDeferred(model, checkpoint.handle, { signal: runtime.signal });
|
||||
} catch (error) {
|
||||
runtime.report(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
await convertPartial(tx, live, conversationId);
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "aborted" });
|
||||
return { status: "terminal", outcome: { status: "aborted" } };
|
||||
}, context);
|
||||
},
|
||||
});
|
||||
|
||||
/** Settle the run's inputs `unanswered` with `no_model` and fail. */
|
||||
async function failNoModel(runtime: Runtime, ref: ModelRef | undefined, context: Context): Promise<void> {
|
||||
const message =
|
||||
ref === undefined ? "No model is configured" : `Model ${ref.provider}/${ref.modelId} is not available`;
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "no_model" });
|
||||
return { status: "terminal", outcome: { status: "failed", error: { message, detail: { reason: "no_model" } } } };
|
||||
}, context);
|
||||
}
|
||||
|
||||
/** Append a committed partial left by an interrupted attempt as an aborted assistant entry; the caller replaces `generation`. */
|
||||
async function convertPartial(tx: Tx, live: Draft<LiveState>, conversationId: ConversationId): Promise<void> {
|
||||
const partial = live.generation?.message;
|
||||
if (partial === undefined) return;
|
||||
const message = copyJson(partial) as unknown as AssistantMessage;
|
||||
await tx.appendEntry(AssistantEntry, conversationId, { model: [{ ...message, stopReason: "aborted" }] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one request and return the terminal message. Partials commit as trailing writes at most every 100 ms with one
|
||||
* commit in flight; `finally` stops the throttle and awaits that commit, so no stale partial lands after the outcome.
|
||||
*/
|
||||
async function streamResponse(
|
||||
runtime: Runtime,
|
||||
model: Model<Api>,
|
||||
messages: readonly Message[],
|
||||
options: SimpleStreamOptions,
|
||||
attempt: number,
|
||||
context: Context,
|
||||
): Promise<AssistantMessage> {
|
||||
let pending: AssistantMessage | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let inFlight: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
const flush = (): void => {
|
||||
timer = undefined;
|
||||
const partial = pending;
|
||||
pending = undefined;
|
||||
if (partial === undefined || stopped) return;
|
||||
inFlight = (async () => {
|
||||
// Copy synchronously: the provider keeps mutating its partial.
|
||||
const message = copyJson(partial, { omitUndefinedProperties: true });
|
||||
await runtime.commit(async (tx) => {
|
||||
const live = await tx.doc(LiveDoc, runtime.conversationId);
|
||||
live.generation ??= { attempt };
|
||||
assignJson(live.generation as Draft<Record<string, JsonValue>>, "message", message);
|
||||
return undefined;
|
||||
}, context);
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
// Rejections after an abort mark or close are expected; the committed state stays consistent.
|
||||
if (!runtime.signal.aborted) runtime.report(error);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
if (pending !== undefined && !stopped) timer = setTimeout(flush, PARTIAL_THROTTLE_MS);
|
||||
});
|
||||
};
|
||||
try {
|
||||
const events = runtime.models.streamSimple(model, { messages: [...messages] }, options);
|
||||
for await (const event of events) {
|
||||
if (event.type === "done" || event.type === "error") continue;
|
||||
pending = event.partial;
|
||||
if (timer === undefined && inFlight === undefined) timer = setTimeout(flush, PARTIAL_THROTTLE_MS);
|
||||
}
|
||||
return await events.result();
|
||||
} finally {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
await inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a terminal provider message in one commit that also clears the partial. `previousPollAt` is set when the
|
||||
* message came from polling, so a still deferred result polls strictly later.
|
||||
*/
|
||||
async function classify(
|
||||
runtime: Runtime,
|
||||
attempt: number,
|
||||
ref: ModelRef,
|
||||
previousPollAt: number | undefined,
|
||||
message: AssistantMessage,
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
// An abort mark or close: the abort invocation or the reopened run handles the committed state.
|
||||
runtime.signal.throwIfAborted();
|
||||
const conversationId = runtime.conversationId;
|
||||
if (message.stopReason === "deferred" && message.deferred !== undefined) {
|
||||
const handle = message.deferred;
|
||||
const pollAt = Math.max(
|
||||
runtime.now() + (handle.pollAfterMs ?? DEFAULT_POLL_AFTER_MS),
|
||||
previousPollAt === undefined ? Number.NEGATIVE_INFINITY : previousPollAt + 1,
|
||||
);
|
||||
await runtime.commit(async (tx) => {
|
||||
(await tx.doc(LiveDoc, conversationId)).generation = { attempt, deferred: { pollAt } };
|
||||
return { status: "running", checkpoint: { phase: "poll", attempt, model: ref, handle, pollAt } };
|
||||
}, context);
|
||||
return;
|
||||
}
|
||||
const policy = (await runtime.snapshot(ConversationConfig, conversationId, context))?.retry ?? DEFAULT_RETRY_POLICY;
|
||||
const retry =
|
||||
message.stopReason === "error" &&
|
||||
isRetryableAssistantError(message) &&
|
||||
policy.enabled &&
|
||||
attempt <= policy.maxRetries;
|
||||
const until = retry ? runtime.now() + retryDelayMs(policy, attempt) : 0;
|
||||
await runtime.commit(async (tx): Promise<Next> => {
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry = await tx.appendEntry(AssistantEntry, conversationId, { model: [message] });
|
||||
switch (message.stopReason) {
|
||||
case "stop":
|
||||
case "length":
|
||||
// Until the tool chain exists, a tool call settles as the answer.
|
||||
case "toolUse":
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer: entry.id });
|
||||
return { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
|
||||
}
|
||||
if (retry) {
|
||||
live.generation = { attempt, retry: { at: until, error: message.errorMessage ?? "" } };
|
||||
return { status: "running", checkpoint: { phase: "retry", attempt, until } };
|
||||
}
|
||||
const text = message.errorMessage ?? `Model response ended with stop reason ${message.stopReason}`;
|
||||
endRun(tx, live, runtime.taskId, { status: "unanswered", reason: "model_error", detail: text });
|
||||
return {
|
||||
status: "terminal",
|
||||
outcome: { status: "failed", error: { message: text, detail: { reason: "model_error" } } },
|
||||
};
|
||||
}, context);
|
||||
}
|
||||
|
||||
type JsonContainer = Record<string, JsonValue> | JsonValue[];
|
||||
|
||||
/**
|
||||
* Assign `value` at `target[key]` leaf by leaf. Chord records a container assignment as one full set and only emits an
|
||||
* append when a string leaf is reassigned with a longer string, so writing the partial whole would store and publish the
|
||||
* complete message on every flush.
|
||||
*/
|
||||
function assignJson(target: JsonContainer, key: string | number, value: JsonValue): void {
|
||||
const slots = target as Record<string | number, JsonValue>;
|
||||
const current = slots[key];
|
||||
if (isRecord(current) && isRecord(value)) {
|
||||
for (const name of Object.keys(current)) if (!Object.hasOwn(value, name)) delete current[name];
|
||||
for (const [name, child] of Object.entries(value)) assignJson(current, name, child);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(current) && Array.isArray(value) && current.length <= value.length) {
|
||||
const items = current as JsonValue[];
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
if (index < items.length) assignJson(items, index, value[index]!);
|
||||
else items.push(value[index]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current !== value) slots[key] = value;
|
||||
}
|
||||
|
||||
function isRecord(value: JsonValue | undefined): value is Record<string, JsonValue> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Context, Draft, JsonValue } from "@earendil-works/chord";
|
||||
import { withoutAbortSignal } from "@earendil-works/chord/context";
|
||||
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
||||
import { SessionImpl } from "../session/session.ts";
|
||||
import type { Transaction } from "../session/transaction.ts";
|
||||
import type {
|
||||
ConversationId,
|
||||
ConversationOwnership,
|
||||
@@ -12,27 +13,39 @@ import type {
|
||||
EntryRecord,
|
||||
Page,
|
||||
Storage,
|
||||
SubmissionId,
|
||||
TaskId,
|
||||
TaskRecord,
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import { ROOT_CONVERSATION_ID } from "../types.ts";
|
||||
import { ConversationConfig, type ConversationConfigState } from "./config.ts";
|
||||
import { captureContextBounds, deriveContext } from "./context.ts";
|
||||
import { ConversationConfig, type ConversationConfigState, DEFAULT_RETRY_POLICY } from "./config.ts";
|
||||
import { readContext } from "./context.ts";
|
||||
import { settleSchedulerOutcome } from "./live.ts";
|
||||
import { BUILTIN_SETUP_KEY, BUILTIN_TASKS } from "./registry.ts";
|
||||
import { TaskScheduler } from "./scheduler.ts";
|
||||
import { Submissions } from "./submissions.ts";
|
||||
import type {
|
||||
ContextView,
|
||||
Conversation,
|
||||
ConversationCreateOptions,
|
||||
ConversationInit,
|
||||
ConversationRetryPolicy,
|
||||
ConversationStreamOptions,
|
||||
HarnessInspection,
|
||||
HarnessOptions,
|
||||
Harness as HarnessType,
|
||||
ModelRef,
|
||||
RegistryReader,
|
||||
RegistrySnapshot,
|
||||
SettledTask,
|
||||
Submission,
|
||||
SubmissionDraft,
|
||||
ToolRegistration,
|
||||
} from "./types.ts";
|
||||
import { scanAll } from "./util.ts";
|
||||
|
||||
const SCAN_PAGE_SIZE = 256;
|
||||
|
||||
type CreateTarget =
|
||||
| { readonly kind: "root" }
|
||||
@@ -50,6 +63,7 @@ type ConversationHost<Tool extends ToolRegistration> = {
|
||||
readonly storage: Storage;
|
||||
readonly registry: RegistryReader<Tool>;
|
||||
readonly tasks: TaskScheduler;
|
||||
readonly submissions: Submissions;
|
||||
create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise<Conversation>;
|
||||
};
|
||||
|
||||
@@ -95,14 +109,37 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
|
||||
}, context);
|
||||
}
|
||||
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T> {
|
||||
return this.#host.harness.commitWith(change, context, this.id);
|
||||
async getStreamOptions(context: Context): Promise<ConversationStreamOptions> {
|
||||
return (await this.#config(context)).streamOptions ?? {};
|
||||
}
|
||||
|
||||
async context(context: Context): Promise<ContextView> {
|
||||
const storage = this.#host.storage;
|
||||
const bounds = await this.#host.harness.readOnLine(() => captureContextBounds(storage, this.id, context));
|
||||
return deriveContext(storage, this.id, bounds, context);
|
||||
setStreamOptions(options: ConversationStreamOptions, context: Context): Promise<void> {
|
||||
return this.#editConfig((config) => {
|
||||
config.streamOptions = options;
|
||||
}, context);
|
||||
}
|
||||
|
||||
async getRetryPolicy(context: Context): Promise<ConversationRetryPolicy> {
|
||||
return (await this.#config(context)).retry ?? { ...DEFAULT_RETRY_POLICY };
|
||||
}
|
||||
|
||||
setRetryPolicy(policy: ConversationRetryPolicy | undefined, context: Context): Promise<void> {
|
||||
return this.#editConfig((config) => {
|
||||
if (policy === undefined) delete config.retry;
|
||||
else config.retry = policy;
|
||||
}, context);
|
||||
}
|
||||
|
||||
submit(submission: SubmissionDraft, context: Context): Promise<Submission> {
|
||||
return this.#host.submissions.submit(this.id, submission, context);
|
||||
}
|
||||
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T> {
|
||||
return this.#host.harness.commitWith(change, context, { conversationId: this.id });
|
||||
}
|
||||
|
||||
context(context: Context): Promise<ContextView> {
|
||||
return readContext(this.#host.harness, this.#host.storage, this.id, context);
|
||||
}
|
||||
|
||||
entries(
|
||||
@@ -128,6 +165,7 @@ class ConversationImpl<Tool extends ToolRegistration> implements Conversation {
|
||||
}
|
||||
|
||||
waitForIdle(context: Context): Promise<void> {
|
||||
this.#host.tasks.resume();
|
||||
return this.#host.tasks.waitForIdle(this.id, context);
|
||||
}
|
||||
|
||||
@@ -151,26 +189,32 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
|
||||
readonly #registry: RegistryReader<Tool>;
|
||||
readonly #host: ConversationHost<Tool>;
|
||||
readonly #tasks: TaskScheduler;
|
||||
readonly #submissions: Submissions;
|
||||
readonly #snapshots = new WeakMap<Transaction, RegistrySnapshot<Tool>>();
|
||||
#closed = false;
|
||||
|
||||
constructor(storage: Storage, options: HarnessOptions<Tool>, context: Context) {
|
||||
super(storage);
|
||||
this.#storage = storage;
|
||||
this.#registry = options.registry;
|
||||
const now = options.now ?? Date.now;
|
||||
this.#tasks = new TaskScheduler({
|
||||
session: this,
|
||||
storage,
|
||||
registry: options.registry,
|
||||
models: options.models,
|
||||
now: options.now ?? Date.now,
|
||||
now,
|
||||
report: options.onReport ?? (() => {}),
|
||||
settleOutcome: settleSchedulerOutcome,
|
||||
context: withoutAbortSignal(context),
|
||||
});
|
||||
this.#submissions = new Submissions(this, storage, now, () => this.#tasks.resume());
|
||||
this.#host = {
|
||||
harness: this,
|
||||
storage,
|
||||
registry: options.registry,
|
||||
tasks: this.#tasks,
|
||||
submissions: this.#submissions,
|
||||
create: (target, init, context) => this.#create(target, init, context),
|
||||
};
|
||||
}
|
||||
@@ -191,15 +235,40 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
|
||||
>;
|
||||
}
|
||||
|
||||
inspect(context: Context): Promise<HarnessInspection> {
|
||||
return this.readOnLine(async () => {
|
||||
const snapshot = this.#registry.snapshot();
|
||||
const { scheduling, tasks } = this.#tasks.inspect(snapshot);
|
||||
const scan = (status: "queued" | "placed") =>
|
||||
scanAll((cursor) => this.#storage.scanSubmissions({ status }, SCAN_PAGE_SIZE, cursor, context));
|
||||
const submissions = [...(await scan("queued")), ...(await scan("placed"))].sort((a, b) => a.id - b.id);
|
||||
return { scheduling, tasks, submissions, registry: snapshot.failures() };
|
||||
});
|
||||
}
|
||||
|
||||
submission(id: SubmissionId, context: Context): Promise<Submission | undefined> {
|
||||
return this.#submissions.get(id, context);
|
||||
}
|
||||
|
||||
abortSubmission(
|
||||
id: SubmissionId,
|
||||
context: Context,
|
||||
conversationId?: ConversationId,
|
||||
): Promise<"aborted" | "already_placed" | "settled" | "not_found"> {
|
||||
return this.#submissions.abort(id, context, conversationId);
|
||||
}
|
||||
|
||||
abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal"> {
|
||||
return this.#tasks.abort(id, context);
|
||||
}
|
||||
|
||||
waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>> {
|
||||
this.#tasks.resume();
|
||||
return this.#tasks.waitForTask(id, context) as Promise<SettledTask<R>>;
|
||||
}
|
||||
|
||||
waitForIdle(context: Context): Promise<void> {
|
||||
this.#tasks.resume();
|
||||
return this.#tasks.waitForIdle(undefined, context);
|
||||
}
|
||||
|
||||
@@ -229,40 +298,44 @@ class HarnessImpl<Tool extends ToolRegistration> extends SessionImpl implements
|
||||
|
||||
async #create(target: CreateTarget, init: ConversationInit | undefined, context: Context): Promise<Conversation> {
|
||||
this.#assertOpen();
|
||||
const id =
|
||||
target.kind === "root"
|
||||
? await this.commitWith(async (tx) => {
|
||||
if ((await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) return ROOT_CONVERSATION_ID;
|
||||
return this.#stageConfiguration(tx, await tx.createRootConversation(), false, init);
|
||||
}, context)
|
||||
: await this.commitWith(async (tx) => {
|
||||
const record =
|
||||
target.kind === "fork"
|
||||
? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership })
|
||||
: await tx.createConversation({ ownership: target.ownership });
|
||||
return this.#stageConfiguration(tx, record, target.kind === "fork", init);
|
||||
}, context);
|
||||
const id = await this.commitWith(async (tx) => {
|
||||
if (target.kind === "root" && (await tx.conversation(ROOT_CONVERSATION_ID)) !== undefined) {
|
||||
return ROOT_CONVERSATION_ID;
|
||||
}
|
||||
const record =
|
||||
target.kind === "root"
|
||||
? await tx.createRootConversation()
|
||||
: target.kind === "fork"
|
||||
? await tx.forkConversation(target.parentId, target.at, { ownership: target.ownership })
|
||||
: await tx.createConversation({ ownership: target.ownership });
|
||||
if (init !== undefined) await this.#runInit(tx, record.id, init);
|
||||
return record.id;
|
||||
}, context);
|
||||
return new ConversationImpl(id, this.#host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a new conversation's configuration and `init` writes. Independent conversations start with every
|
||||
* registered tool active; forks keep the configuration copied from their fork entry.
|
||||
*/
|
||||
async #stageConfiguration(
|
||||
tx: Tx,
|
||||
record: ConversationRecord,
|
||||
forked: boolean,
|
||||
init: ConversationInit | undefined,
|
||||
): Promise<ConversationId> {
|
||||
const snapshot = this.#registry.snapshot();
|
||||
if (!forked) (await tx.doc(ConversationConfig, record.id)).activeTools = [...snapshot.toolNames()];
|
||||
if (init === undefined) return record.id;
|
||||
const baseline = [...(await tx.doc(ConversationConfig, record.id)).activeTools];
|
||||
await init(tx, record.id);
|
||||
// `init` writes are trusted like any raw write; only names it newly activates must be registered.
|
||||
requireRegistered(snapshot, (await tx.doc(ConversationConfig, record.id)).activeTools, baseline);
|
||||
return record.id;
|
||||
/** Run every registered conversation setup, built-ins first, in each commit that creates or forks a conversation. */
|
||||
protected override async conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void> {
|
||||
const snapshot = this.#snapshotOf(tx);
|
||||
for (const { setup } of snapshot.conversationSetups()) await setup(tx, record, snapshot);
|
||||
}
|
||||
|
||||
/** Run `init` in the creating commit; its writes are trusted, but names it newly activates must be registered. */
|
||||
async #runInit(tx: Transaction, id: ConversationId, init: ConversationInit): Promise<void> {
|
||||
const snapshot = this.#snapshotOf(tx);
|
||||
const baseline = [...(await tx.doc(ConversationConfig, id)).activeTools];
|
||||
await init(tx, id);
|
||||
requireRegistered(snapshot, (await tx.doc(ConversationConfig, id)).activeTools, baseline);
|
||||
}
|
||||
|
||||
/** One registry snapshot per commit, shared by its conversation setups and `init` checks. */
|
||||
#snapshotOf(tx: Transaction): RegistrySnapshot<Tool> {
|
||||
let snapshot = this.#snapshots.get(tx);
|
||||
if (snapshot === undefined) {
|
||||
snapshot = this.#registry.snapshot();
|
||||
this.#snapshots.set(tx, snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
#assertOpen(): void {
|
||||
@@ -293,6 +366,15 @@ export const Harness = {
|
||||
context: Context,
|
||||
): Promise<Harness> {
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const snapshot = options.registry.snapshot();
|
||||
const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === undefined).map(
|
||||
(task) => `task ${task.definition.name}`,
|
||||
);
|
||||
if (!snapshot.conversationSetups().some(({ key }) => key === BUILTIN_SETUP_KEY))
|
||||
missing.push("conversation setup pi");
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Registry lacks built-in ${missing.join(", ")}; create it with createRegistry()`);
|
||||
}
|
||||
const harness = new HarnessImpl(storage, options, context);
|
||||
try {
|
||||
await harness.openTasks(context);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { Draft, JsonRepresentation, JsonValue } from "@earendil-works/chord";
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { defineDoc } from "../documents.ts";
|
||||
import type { Transaction } from "../session/transaction.ts";
|
||||
import type { SubmissionId, SubmissionSettlement, TaskId, TaskRecord, Tx } from "../types.ts";
|
||||
import type { SchedulerOutcome } from "./scheduler.ts";
|
||||
|
||||
/** Built-in live conversation state: run control and presentation of the current generation. */
|
||||
export type LiveState = {
|
||||
/** Run control: the task responsible for the run and its placed inputs; present exactly while busy. */
|
||||
run?: { taskId: TaskId; inputs: SubmissionId[] };
|
||||
/** Presentation of the current generation attempt. */
|
||||
generation?: {
|
||||
attempt: number;
|
||||
/** Committed throttled partial of the in-flight response. */
|
||||
message?: JsonRepresentation<AssistantMessage>;
|
||||
/** Durable backoff before the next attempt. */
|
||||
retry?: { at: number; error: string };
|
||||
/** Provider-side deferred response being polled. */
|
||||
deferred?: { pollAt: number };
|
||||
};
|
||||
};
|
||||
|
||||
export const LiveDoc = defineDoc<LiveState>({
|
||||
kind: "pi.live",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({}),
|
||||
// A complete base whenever nothing is in flight, so the delta chain spans at most one generation, including its
|
||||
// retries and deferred polls, or one tool round.
|
||||
checkpointWhen: (value) => value.generation === undefined,
|
||||
});
|
||||
|
||||
/** Built-in task kinds that can own `pi.live.run`. */
|
||||
const RUN_TASK_KINDS: ReadonlySet<string> = new Set(["pi.generation"]);
|
||||
|
||||
/**
|
||||
* End the run owned by `taskId`: settle each of its inputs and remove `run`. Always removes `generation`, whose
|
||||
* presentation belongs to the ending task.
|
||||
*/
|
||||
export function endRun(tx: Tx, live: Draft<LiveState>, taskId: TaskId, settlement: SubmissionSettlement): void {
|
||||
if (live.run?.taskId === taskId) {
|
||||
for (const id of live.run.inputs) tx.settleSubmission(id, settlement);
|
||||
delete live.run;
|
||||
}
|
||||
delete live.generation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Harness cleanup for a terminal outcome the scheduler writes itself (`faulted` or `orphaned`). Ignores non-run task
|
||||
* kinds so it never creates `pi.live` elsewhere. Committed partials are discarded without a transcript entry.
|
||||
*/
|
||||
export async function settleSchedulerOutcome(
|
||||
tx: Transaction,
|
||||
record: TaskRecord<JsonValue, JsonValue, JsonValue>,
|
||||
outcome: SchedulerOutcome,
|
||||
): Promise<void> {
|
||||
if (!RUN_TASK_KINDS.has(record.kind)) return;
|
||||
const live = await tx.doc(LiveDoc, record.conversationId);
|
||||
if (live.run?.taskId !== record.id) return;
|
||||
endRun(
|
||||
tx,
|
||||
live,
|
||||
record.id,
|
||||
outcome.status === "faulted"
|
||||
? { status: "unanswered", reason: "faulted", detail: outcome.error.message }
|
||||
: { status: "unanswered", reason: outcome.reason },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { Message, SystemMessage } from "@earendil-works/pi-ai";
|
||||
import { SystemEntry } from "../entries.ts";
|
||||
import type { ContextEdit, TypedEntryDraft } from "../types.ts";
|
||||
import type { ContextView, PromptInput, PromptSection, ToolRegistration } from "./types.ts";
|
||||
|
||||
/** Sections in effect after replaying system messages in order: set in place, `null` deletes, re-adding appends. */
|
||||
export function replaySections(messages: readonly Message[]): Map<string, string> {
|
||||
const shown = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (message.role !== "system" || message.sections === undefined) continue;
|
||||
for (const [key, value] of Object.entries(message.sections)) {
|
||||
if (value === null) shown.delete(key);
|
||||
else shown.set(key, value);
|
||||
}
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render sections in registry order. `undefined` omits a section; tagged text is wrapped as `<key>\n...\n</key>`. A
|
||||
* section that throws keeps its shown text, if any, and is reported; errors after `context` is aborted propagate.
|
||||
*/
|
||||
export async function renderSections<Tool extends ToolRegistration>(
|
||||
sections: readonly PromptSection<Tool>[],
|
||||
input: PromptInput<Tool>,
|
||||
shown: ReadonlyMap<string, string>,
|
||||
report: (error: unknown) => void,
|
||||
context: Context,
|
||||
): Promise<Map<string, string>> {
|
||||
const desired = new Map<string, string>();
|
||||
for (const section of sections) {
|
||||
let text: string | undefined;
|
||||
try {
|
||||
text = await section.render(input, context);
|
||||
} catch (error) {
|
||||
if (context.abortSignal?.aborted) throw error;
|
||||
report(error);
|
||||
const kept = shown.get(section.key);
|
||||
if (kept !== undefined) desired.set(section.key, kept);
|
||||
continue;
|
||||
}
|
||||
if (text === undefined) continue;
|
||||
desired.set(section.key, section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`);
|
||||
}
|
||||
return desired;
|
||||
}
|
||||
|
||||
type SystemDraft = TypedEntryDraft<never>;
|
||||
|
||||
/**
|
||||
* Plan the `pi.system` entries that make the replayed sections of `view` equal `desired` in values and order.
|
||||
*
|
||||
* - A head marker with no later `pi.system` entry in context: one complete baseline that omits every retained earlier
|
||||
* `pi.system` entry, written even when it restates the replayed values.
|
||||
* - Otherwise, when a minimal patch would leave a different order: remove every shown section, then re-add every
|
||||
* desired section in order.
|
||||
* - Otherwise the minimal patch of changed values and `null` removals, or nothing.
|
||||
*/
|
||||
export function planSystemEntries(
|
||||
view: ContextView,
|
||||
desired: ReadonlyMap<string, string>,
|
||||
timestamp: number,
|
||||
): SystemDraft[] {
|
||||
const head = view.head;
|
||||
if (head !== undefined && !view.entries.some((entry) => SystemEntry.is(entry) && entry.id > head.id)) {
|
||||
const edits: ContextEdit[] = view.entries
|
||||
.filter((entry) => SystemEntry.is(entry))
|
||||
.map((entry) => ({ target: entry.id, action: "omit" }));
|
||||
const baseline = systemEntry(Object.fromEntries(desired), timestamp);
|
||||
return [edits.length === 0 ? baseline : { ...baseline, edits }];
|
||||
}
|
||||
|
||||
const shown = replaySections(view.messages);
|
||||
const patchedOrder = [
|
||||
...[...shown.keys()].filter((key) => desired.has(key)),
|
||||
...[...desired.keys()].filter((key) => !shown.has(key)),
|
||||
];
|
||||
const desiredOrder = [...desired.keys()];
|
||||
if (patchedOrder.some((key, index) => key !== desiredOrder[index])) {
|
||||
return [
|
||||
systemEntry(Object.fromEntries([...shown.keys()].map((key) => [key, null])), timestamp),
|
||||
systemEntry(Object.fromEntries(desired), timestamp),
|
||||
];
|
||||
}
|
||||
|
||||
const patch: Record<string, string | null> = {};
|
||||
for (const [key, value] of shown) {
|
||||
const next = desired.get(key);
|
||||
if (next !== value) patch[key] = next ?? null;
|
||||
}
|
||||
for (const [key, value] of desired) if (!shown.has(key)) patch[key] = value;
|
||||
return Object.keys(patch).length === 0 ? [] : [systemEntry(patch, timestamp)];
|
||||
}
|
||||
|
||||
function systemEntry(sections: Record<string, string | null>, timestamp: number): SystemDraft {
|
||||
const message: SystemMessage = { role: "system", content: "", sections, timestamp };
|
||||
return { model: [message] };
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ConversationRecord, Tx } from "../types.ts";
|
||||
import { ConversationConfig } from "./config.ts";
|
||||
import { GenerationTask } from "./generation.ts";
|
||||
import { LiveDoc } from "./live.ts";
|
||||
import type {
|
||||
AnyTask,
|
||||
ConversationSetup,
|
||||
HookRegistration,
|
||||
HookScope,
|
||||
HooksOf,
|
||||
@@ -15,11 +20,24 @@ import type {
|
||||
|
||||
const SECTION_KEY = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
/** Built-in task definitions every registry starts with; they cannot be disposed or replaced. */
|
||||
export const BUILTIN_TASKS: readonly AnyTask[] = [GenerationTask];
|
||||
|
||||
export const BUILTIN_SETUP_KEY = "pi";
|
||||
|
||||
/** Built-in documents: an empty `pi.live`, and for a new conversation the default configuration with every registered tool active. */
|
||||
async function builtinSetup(tx: Tx, conversation: ConversationRecord, registry: RegistrySnapshot): Promise<void> {
|
||||
await tx.doc(LiveDoc, conversation.id);
|
||||
if (conversation.parent !== undefined) return;
|
||||
(await tx.doc(ConversationConfig, conversation.id)).activeTools = [...registry.toolNames()];
|
||||
}
|
||||
|
||||
type Slot<Tool extends ToolRegistration> =
|
||||
| { readonly kind: "tool"; readonly tool: Tool }
|
||||
| { readonly kind: "toolWrap"; readonly name: string; readonly wrapper: ToolWrapper<Tool> }
|
||||
| { readonly kind: "hook"; readonly taskName: string; readonly hook: StoredHook }
|
||||
| { readonly kind: "task"; readonly task: AnyTask }
|
||||
| { readonly kind: "setup"; readonly key: string; readonly setup: ConversationSetup }
|
||||
| { readonly kind: "section"; readonly section: PromptSection<Tool> }
|
||||
| { readonly kind: "sectionWrap"; readonly key: string; readonly wrapper: PromptSectionWrapper<Tool> };
|
||||
|
||||
@@ -83,6 +101,12 @@ class RegistryState<Tool extends ToolRegistration> implements RegistrySnapshot<T
|
||||
return undefined;
|
||||
}
|
||||
|
||||
conversationSetups(): readonly { readonly key: string; readonly setup: ConversationSetup }[] {
|
||||
const setups: { key: string; setup: ConversationSetup }[] = [];
|
||||
for (const record of this.records) if (record.slot.kind === "setup") setups.push(record.slot);
|
||||
return setups;
|
||||
}
|
||||
|
||||
tasks(): AnyTask[] {
|
||||
const tasks: AnyTask[] = [];
|
||||
for (const record of this.records) if (record.slot.kind === "task") tasks.push(record.slot.task);
|
||||
@@ -164,6 +188,7 @@ class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> {
|
||||
readonly tools: Registry<Tool>["tools"];
|
||||
readonly hooks: Registry<Tool>["hooks"];
|
||||
readonly tasks: Registry<Tool>["tasks"];
|
||||
readonly conversations: Registry<Tool>["conversations"];
|
||||
readonly systemPrompt: Registry<Tool>["systemPrompt"];
|
||||
|
||||
constructor() {
|
||||
@@ -200,6 +225,9 @@ class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> {
|
||||
},
|
||||
list: () => this.#current.tasks(),
|
||||
};
|
||||
this.conversations = {
|
||||
setup: (key, setup) => this.#register({ kind: "setup", key, setup }, `setup\0${key}`, `Setup ${key}`),
|
||||
};
|
||||
this.systemPrompt = {
|
||||
section: (key, render, options) => {
|
||||
requireSectionKey(key);
|
||||
@@ -315,9 +343,13 @@ class RegistryImpl<Tool extends ToolRegistration> implements Registry<Tool> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an empty application-owned registry. */
|
||||
/** Create an application-owned registry holding only the built-ins. */
|
||||
export function createRegistry<Tool extends ToolRegistration = ToolRegistration>(): Registry<Tool> {
|
||||
return new RegistryImpl<Tool>();
|
||||
const registry = new RegistryImpl<Tool>();
|
||||
// Their registrations are dropped, so nothing can dispose them.
|
||||
for (const task of BUILTIN_TASKS) registry.tasks.add(task);
|
||||
registry.conversations.setup(BUILTIN_SETUP_KEY, builtinSetup);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function appendTo<T>(map: Map<string, T[]>, key: string, value: T): void {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ConversationId,
|
||||
DocumentWatch,
|
||||
JsonObject,
|
||||
NextTaskState,
|
||||
RunningTask,
|
||||
Storage,
|
||||
TaskDefinition,
|
||||
@@ -17,9 +16,17 @@ import type {
|
||||
TaskRecord,
|
||||
TaskRuntime,
|
||||
TaskState,
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import type { AnyTask, RegistryReader, RegistrySnapshot, SettledTask } from "./types.ts";
|
||||
import { readContext } from "./context.ts";
|
||||
import type {
|
||||
AnyTask,
|
||||
HarnessInspection,
|
||||
RegistryReader,
|
||||
RegistrySnapshot,
|
||||
SettledTask,
|
||||
TaskInspection,
|
||||
} from "./types.ts";
|
||||
import { closedError, scanAll, Waiters } from "./util.ts";
|
||||
|
||||
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
type LiveTaskRecord = Extract<AnyTaskRecord, { readonly state: { readonly status: "pending" | "running" } }>;
|
||||
@@ -39,6 +46,11 @@ type Resolution =
|
||||
| { readonly kind: "ready"; readonly task: AnyTask; readonly record: LiveTaskRecord }
|
||||
| { readonly kind: "blocked"; readonly reason: BlockedReason };
|
||||
|
||||
/** A definition that can take a record, or why none can; deciding it runs no task code. */
|
||||
type Fit =
|
||||
| { readonly task: AnyTask; readonly migrates: boolean }
|
||||
| { readonly reason: BlockedReason; readonly error?: unknown };
|
||||
|
||||
/** One in-memory execution of a task in run or abort mode. */
|
||||
type Invocation = {
|
||||
readonly taskId: TaskId;
|
||||
@@ -66,7 +78,11 @@ type ReportedTask = { readonly task: AnyTask | undefined } | undefined;
|
||||
/** Outcome of the phase that just returned, judged by the next step. */
|
||||
type PhaseResult = { readonly checkpoint: Checkpoint; readonly failure?: { readonly error: unknown } };
|
||||
|
||||
type Waiter<T> = { readonly resolve: (value: T) => void; readonly reject: (error: unknown) => void };
|
||||
/** Step decision: continue with the next phase, end the invocation, or end it by writing terminal `faulted`. */
|
||||
type Decision = boolean | { readonly fault: unknown };
|
||||
|
||||
/** Terminal outcomes the scheduler writes without running task code. */
|
||||
export type SchedulerOutcome = Extract<TaskOutcome<JsonValue>, { readonly status: "faulted" | "orphaned" }>;
|
||||
|
||||
export type TaskSchedulerOptions = {
|
||||
readonly session: SessionImpl;
|
||||
@@ -75,6 +91,8 @@ export type TaskSchedulerOptions = {
|
||||
readonly models: Models;
|
||||
readonly now: () => number;
|
||||
readonly report: (error: unknown) => void;
|
||||
/** Harness cleanup staged in the same commit as every terminal outcome the scheduler writes itself. */
|
||||
readonly settleOutcome: (tx: Transaction, record: AnyTaskRecord, outcome: SchedulerOutcome) => Promise<void>;
|
||||
/** Context for scheduler commits and invocations; carries no caller cancellation. */
|
||||
readonly context: Context;
|
||||
};
|
||||
@@ -97,14 +115,15 @@ export class TaskScheduler {
|
||||
readonly #models: Models;
|
||||
readonly #now: () => number;
|
||||
readonly #report: (error: unknown) => void;
|
||||
readonly #settleOutcome: TaskSchedulerOptions["settleOutcome"];
|
||||
readonly #context: Context;
|
||||
readonly #live = new Map<TaskId, LiveTaskRecord>();
|
||||
readonly #invocations = new Map<TaskId, Invocation>();
|
||||
readonly #taskWaiters = new Map<TaskId, Set<Waiter<SettledTask<JsonValue>>>>();
|
||||
readonly #taskWaiters = new Waiters<TaskId, SettledTask<JsonValue>>();
|
||||
/** Idle waiters by conversation; `undefined` waits for the whole Harness. */
|
||||
readonly #idleWaiters = new Map<ConversationId | undefined, Set<Waiter<void>>>();
|
||||
readonly #idleWaiters = new Waiters<ConversationId | undefined, void>();
|
||||
/** Definition whose migration failed per task; retried only once the registry resolves another definition. */
|
||||
readonly #failedMigrations = new Map<TaskId, AnyTask>();
|
||||
readonly #failedMigrations = new Map<TaskId, { readonly task: AnyTask; readonly error: unknown }>();
|
||||
#unsubscribeRegistry: () => void = () => {};
|
||||
#enabled = false;
|
||||
#closing = false;
|
||||
@@ -118,6 +137,7 @@ export class TaskScheduler {
|
||||
this.#models = options.models;
|
||||
this.#now = options.now;
|
||||
this.#report = options.report;
|
||||
this.#settleOutcome = options.settleOutcome;
|
||||
this.#context = options.context;
|
||||
}
|
||||
|
||||
@@ -127,23 +147,18 @@ export class TaskScheduler {
|
||||
this.#session.subscribeClose(() => this.#seal());
|
||||
this.#unsubscribeRegistry = this.#registry.subscribe(() => this.#kick());
|
||||
await this.#session.commitWith(async (tx) => {
|
||||
const running: LiveTaskRecord[] = [];
|
||||
for (const status of ["pending", "running"] as const) {
|
||||
let cursor: Parameters<Tx["scanTasks"]>[2];
|
||||
do {
|
||||
const page = await tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor);
|
||||
for (const record of page.items as readonly LiveTaskRecord[]) {
|
||||
this.#live.set(record.id, record);
|
||||
if (status === "running") running.push(record);
|
||||
}
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
}
|
||||
for (const record of running)
|
||||
const scan = (status: "pending" | "running") =>
|
||||
scanAll((cursor) => tx.scanTasks({ status }, SCAN_PAGE_SIZE, cursor)) as Promise<LiveTaskRecord[]>;
|
||||
const pending = await scan("pending");
|
||||
const running = await scan("running");
|
||||
for (const record of [...pending, ...running]) this.#live.set(record.id, record);
|
||||
for (const record of running) {
|
||||
tx.setTask(withState(record, { status: "pending", checkpoint: record.state.checkpoint }));
|
||||
}
|
||||
}, context);
|
||||
}
|
||||
|
||||
/** Enable scheduling. Idempotent; the kick does nothing once closing. */
|
||||
resume(): void {
|
||||
this.#enabled = true;
|
||||
this.#kick();
|
||||
@@ -168,7 +183,7 @@ export class TaskScheduler {
|
||||
if (invocation === undefined) {
|
||||
const resolution = this.#resolve(live, this.#registry.snapshot());
|
||||
if (resolution.kind === "blocked") {
|
||||
tx.setTask(withState(live, terminal({ status: "orphaned", reason: resolution.reason })));
|
||||
await this.#terminate(tx, live, { status: "orphaned", reason: resolution.reason });
|
||||
return { result: "marked" as const };
|
||||
}
|
||||
}
|
||||
@@ -186,7 +201,7 @@ export class TaskScheduler {
|
||||
// Check and register on the line so no terminal publication falls between them.
|
||||
const found = await this.#session.readOnLine(async () => {
|
||||
if (this.#closing) throw closedError();
|
||||
if (this.#live.has(id)) return { promise: addWaiter(this.#taskWaiters, id, context) };
|
||||
if (this.#live.has(id)) return { promise: this.#taskWaiters.add(id, context) };
|
||||
const record = await this.#storage.task(id, context);
|
||||
if (record === undefined) throw new Error(`Task ${id} does not exist`);
|
||||
return { promise: Promise.resolve(record as SettledTask<JsonValue>) };
|
||||
@@ -198,7 +213,7 @@ export class TaskScheduler {
|
||||
waitForIdle(conversationId: ConversationId | undefined, context: Context): Promise<void> {
|
||||
if (this.#closing) return Promise.reject(closedError());
|
||||
if (this.#idle(conversationId)) return Promise.resolve();
|
||||
return addWaiter(this.#idleWaiters, conversationId, context);
|
||||
return this.#idleWaiters.add(conversationId, context);
|
||||
}
|
||||
|
||||
// ─── Scheduling ────────────────────────────────────────────────────────
|
||||
@@ -215,11 +230,11 @@ export class TaskScheduler {
|
||||
}
|
||||
this.#live.delete(record.id);
|
||||
this.#failedMigrations.delete(record.id);
|
||||
settleWaiters(this.#taskWaiters, record.id, (waiter) => waiter.resolve(record as SettledTask<JsonValue>));
|
||||
this.#taskWaiters.resolve(record.id, record as SettledTask<JsonValue>);
|
||||
}
|
||||
if (!changed) return;
|
||||
for (const conversationId of [...this.#idleWaiters.keys()]) {
|
||||
if (this.#idle(conversationId)) settleWaiters(this.#idleWaiters, conversationId, (waiter) => waiter.resolve());
|
||||
for (const conversationId of this.#idleWaiters.keys()) {
|
||||
if (this.#idle(conversationId)) this.#idleWaiters.resolve(conversationId);
|
||||
}
|
||||
this.#kick();
|
||||
}
|
||||
@@ -229,8 +244,8 @@ export class TaskScheduler {
|
||||
this.#closing = true;
|
||||
this.#unsubscribeRegistry();
|
||||
const error = closedError();
|
||||
for (const id of [...this.#taskWaiters.keys()]) settleWaiters(this.#taskWaiters, id, (w) => w.reject(error));
|
||||
for (const id of [...this.#idleWaiters.keys()]) settleWaiters(this.#idleWaiters, id, (w) => w.reject(error));
|
||||
this.#taskWaiters.rejectAll(error);
|
||||
this.#idleWaiters.rejectAll(error);
|
||||
for (const invocation of this.#invocations.values()) invocation.controller.abort();
|
||||
}
|
||||
|
||||
@@ -266,22 +281,13 @@ export class TaskScheduler {
|
||||
// Taken once per pass, and only when some task is a candidate.
|
||||
let snapshot: RegistrySnapshot | undefined;
|
||||
for (const record of [...this.#live.values()]) {
|
||||
if (this.#invocations.has(record.id)) continue;
|
||||
if (this.#invocations.has(record.id) || this.#waitingOn(record).length > 0) continue;
|
||||
const mode = record.abortRequested ? "abort" : "run";
|
||||
// An abort mark bypasses dependencies; a task already running has passed them.
|
||||
if (
|
||||
mode === "run" &&
|
||||
record.state.status === "pending" &&
|
||||
record.after.some((id) => this.#live.has(id))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
snapshot ??= this.#registry.snapshot();
|
||||
const resolution = this.#resolve(record, snapshot);
|
||||
if (resolution.kind === "blocked") {
|
||||
if (mode === "abort") {
|
||||
tx.setTask(withState(record, terminal({ status: "orphaned", reason: resolution.reason })));
|
||||
}
|
||||
if (mode === "abort")
|
||||
await this.#terminate(tx, record, { status: "orphaned", reason: resolution.reason });
|
||||
continue;
|
||||
}
|
||||
if (resolution.record !== record || record.state.status !== "running") {
|
||||
@@ -309,39 +315,65 @@ export class TaskScheduler {
|
||||
|
||||
/** Resolve the record's definition by kind, migrating an older stored version. */
|
||||
#resolve(record: LiveTaskRecord, snapshot: RegistrySnapshot): Resolution {
|
||||
const task = snapshot.task(record.kind);
|
||||
if (task === undefined) return { kind: "blocked", reason: "missing_task" };
|
||||
const definition = erased(task);
|
||||
if (definition.version === record.version) return { kind: "ready", task, record };
|
||||
if (definition.version < record.version) return { kind: "blocked", reason: "task_too_old" };
|
||||
if (this.#failedMigrations.get(record.id) === task) return { kind: "blocked", reason: "migration_failed" };
|
||||
const fit = this.#fit(record, snapshot.task(record.kind));
|
||||
if ("reason" in fit) return { kind: "blocked", reason: fit.reason };
|
||||
if (!fit.migrates) return { kind: "ready", task: fit.task, record };
|
||||
const definition = erased(fit.task);
|
||||
try {
|
||||
if (definition.migrate === undefined) {
|
||||
throw new Error(
|
||||
`Task ${record.kind} version ${definition.version} has no migration from ${record.version}`,
|
||||
);
|
||||
}
|
||||
if (definition.migrate === undefined) throw missingMigration(record, definition);
|
||||
const migrated = definition.migrate(record.input, record.state.checkpoint, record.version);
|
||||
const migratedRecord = {
|
||||
...record,
|
||||
version: definition.version,
|
||||
input: copyJson(migrated.input),
|
||||
state: { status: record.state.status, checkpoint: copyJson(migrated.checkpoint) },
|
||||
} as LiveTaskRecord;
|
||||
return { kind: "ready", task, record: migratedRecord };
|
||||
const state = { status: record.state.status, checkpoint: copyJson(migrated.checkpoint) };
|
||||
const migratedRecord = { ...record, version: definition.version, input: copyJson(migrated.input), state };
|
||||
return { kind: "ready", task: fit.task, record: migratedRecord as LiveTaskRecord };
|
||||
} catch (error) {
|
||||
this.#failedMigrations.set(record.id, task);
|
||||
this.#failedMigrations.set(record.id, { task: fit.task, error });
|
||||
this.#report(error);
|
||||
return { kind: "blocked", reason: "migration_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
#fit(record: LiveTaskRecord, task: AnyTask | undefined): Fit {
|
||||
if (task === undefined) return { reason: "missing_task" };
|
||||
const version = task.definition.version;
|
||||
if (version === record.version) return { task, migrates: false };
|
||||
if (version < record.version) return { reason: "task_too_old" };
|
||||
const failed = this.#failedMigrations.get(record.id);
|
||||
if (failed?.task === task) return { reason: "migration_failed", error: failed.error };
|
||||
return { task, migrates: true };
|
||||
}
|
||||
|
||||
/** Live dependencies a run waits for; an abort mark bypasses them and a running task has passed them. */
|
||||
#waitingOn(record: LiveTaskRecord): TaskId[] {
|
||||
if (record.abortRequested || record.state.status !== "pending") return [];
|
||||
return record.after.filter((id) => this.#live.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduling state and every live task with its derived state, read on the Session line. Runs no task code: a
|
||||
* pending migration shows as `ready` with `migrates`, and only a migration the scheduler already tried, or one that
|
||||
* cannot exist, shows as failed.
|
||||
*/
|
||||
inspect(snapshot: RegistrySnapshot): { scheduling: HarnessInspection["scheduling"]; tasks: TaskInspection[] } {
|
||||
const tasks: TaskInspection[] = [];
|
||||
for (const record of this.#live.values()) tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
||||
return { scheduling, tasks };
|
||||
}
|
||||
|
||||
#inspectTask(record: LiveTaskRecord, snapshot: RegistrySnapshot): TaskInspection["state"] {
|
||||
if (this.#invocations.has(record.id)) return { kind: "running" };
|
||||
const fit = this.#fit(record, snapshot.task(record.kind));
|
||||
if ("reason" in fit) return { kind: "blocked", ...fit };
|
||||
if (fit.migrates && fit.task.definition.migrate === undefined) {
|
||||
return { kind: "blocked", reason: "migration_failed", error: missingMigration(record, erased(fit.task)) };
|
||||
}
|
||||
const on = this.#waitingOn(record);
|
||||
return on.length > 0 ? { kind: "waiting", on } : { kind: "ready", migrates: fit.migrates };
|
||||
}
|
||||
|
||||
#createInvocation(record: LiveTaskRecord, mode: "run" | "abort"): Invocation {
|
||||
const controller = new AbortController();
|
||||
let finish!: () => void;
|
||||
const done = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const { promise: done, resolve: finish } = Promise.withResolvers<void>();
|
||||
const invocation: Invocation = {
|
||||
taskId: record.id,
|
||||
conversationId: record.conversationId,
|
||||
@@ -351,7 +383,7 @@ export class TaskScheduler {
|
||||
watches: new Set(),
|
||||
ended: false,
|
||||
done,
|
||||
finish,
|
||||
finish: () => finish(),
|
||||
};
|
||||
this.#invocations.set(record.id, invocation);
|
||||
return invocation;
|
||||
@@ -402,20 +434,16 @@ export class TaskScheduler {
|
||||
current: ErasedRunningTask,
|
||||
previous: PhaseResult | undefined,
|
||||
state: { task: AnyTask; snapshot: RegistrySnapshot; reported: ReportedTask },
|
||||
): boolean {
|
||||
): Decision {
|
||||
// 3. abort mark: end; the next drain starts a fresh abort invocation.
|
||||
if (current.abortRequested) return false;
|
||||
if (previous === undefined) return true;
|
||||
// 4. uncaught error.
|
||||
if (previous.failure !== undefined) {
|
||||
tx.setTask(faulted(current, previous.failure.error));
|
||||
return false;
|
||||
}
|
||||
if (previous.failure !== undefined) return { fault: previous.failure.error };
|
||||
// 6. no durable progress.
|
||||
if (jsonEqual(current.state.checkpoint, previous.checkpoint)) {
|
||||
const message = `Task ${current.kind} phase ${previous.checkpoint.phase} returned without durable progress`;
|
||||
tx.setTask(faulted(current, new Error(message)));
|
||||
return false;
|
||||
return { fault: new Error(message) };
|
||||
}
|
||||
// 5. progress: refresh the snapshot; hand over to a replacement definition that can take the task.
|
||||
state.snapshot = this.#registry.snapshot();
|
||||
@@ -449,26 +477,29 @@ export class TaskScheduler {
|
||||
failure = { error };
|
||||
}
|
||||
const message = `Abort handler of task ${invocation.taskId} returned without a terminal outcome`;
|
||||
await this.#step(invocation, (tx, current) => {
|
||||
tx.setTask(faulted(current, failure?.error ?? new Error(message)));
|
||||
return false;
|
||||
});
|
||||
await this.#step(invocation, () => ({ fault: failure?.error ?? new Error(message) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* One synchronous decision on the Session line. A terminal task (rule 1) or a closing Harness (rule 2) ends the
|
||||
* invocation without a write; otherwise `decide` may stage a write and returns whether the invocation continues.
|
||||
* Ending happens inside the callback. A rejected step, such as admission after close, also ends the invocation.
|
||||
* Ending happens inside the callback, before a fault's Harness cleanup. A rejected step, such as admission after
|
||||
* close, also ends the invocation.
|
||||
*/
|
||||
async #step(
|
||||
invocation: Invocation,
|
||||
decide: (tx: Transaction, current: ErasedRunningTask) => boolean,
|
||||
decide: (tx: Transaction, current: ErasedRunningTask) => Decision,
|
||||
): Promise<ErasedRunningTask | undefined> {
|
||||
try {
|
||||
return await this.#session.commitWith((tx) => {
|
||||
return await this.#session.commitWith(async (tx) => {
|
||||
const current = this.#live.get(invocation.taskId) as ErasedRunningTask | undefined;
|
||||
if (current !== undefined && !this.#closing && decide(tx, current)) return current;
|
||||
const decision = current !== undefined && !this.#closing ? decide(tx, current) : false;
|
||||
if (decision === true) return current;
|
||||
this.#end(invocation);
|
||||
if (decision !== false) {
|
||||
const message = decision.fault instanceof Error ? decision.fault.message : String(decision.fault);
|
||||
await this.#terminate(tx, current!, { status: "faulted", error: { message } });
|
||||
}
|
||||
return undefined;
|
||||
}, this.#context);
|
||||
} catch (error) {
|
||||
@@ -478,6 +509,12 @@ export class TaskScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a scheduler-decided terminal outcome together with its Harness cleanup. */
|
||||
#terminate(tx: Transaction, record: LiveTaskRecord, outcome: SchedulerOutcome): Promise<void> {
|
||||
tx.setTask(withState(record, { status: "terminal", outcome }));
|
||||
return this.#settleOutcome(tx, record, outcome);
|
||||
}
|
||||
|
||||
/** End an invocation: its runtime operations reject from now on, its watches stop, and its task is free. */
|
||||
#end(invocation: Invocation): void {
|
||||
if (invocation.ended) return;
|
||||
@@ -514,15 +551,45 @@ export class TaskScheduler {
|
||||
},
|
||||
context,
|
||||
),
|
||||
memo: ((name: string, ...rest: readonly unknown[]) =>
|
||||
rest.length === 1
|
||||
? this.#readMemo(invocation, name)
|
||||
: this.#writeMemo(invocation, name, rest[0] as JsonValue, rest[1] as Context)) as ErasedRuntime["memo"],
|
||||
memo: ((name: string, ...rest: readonly unknown[]) => {
|
||||
if (rest.length === 1) {
|
||||
return this.#read(invocation, async () => memoOf(this.#live.get(invocation.taskId), name));
|
||||
}
|
||||
const candidate = rest[0] as JsonValue;
|
||||
return this.#gated(
|
||||
invocation,
|
||||
(tx, current) => {
|
||||
const winner = memoOf(current, name);
|
||||
if (winner !== undefined) return winner;
|
||||
tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } } as AnyTaskRecord);
|
||||
return candidate;
|
||||
},
|
||||
rest[1] as Context,
|
||||
);
|
||||
}) as ErasedRuntime["memo"],
|
||||
sleep: (until, context) => this.#sleep(invocation, until, context),
|
||||
watchDoc: ((...args: readonly unknown[]) => this.#watchDoc(invocation, args)) as ErasedRuntime["watchDoc"],
|
||||
snapshot: ((...args: readonly unknown[]) =>
|
||||
this.#read(invocation, () =>
|
||||
sessionMethod(this.#session, "snapshot")(...args),
|
||||
)) as ErasedRuntime["snapshot"],
|
||||
snapshotAsOf: ((...args: readonly unknown[]) =>
|
||||
this.#read(invocation, () =>
|
||||
sessionMethod(this.#session, "snapshotAsOf")(...args),
|
||||
)) as ErasedRuntime["snapshotAsOf"],
|
||||
context: (conversationId, context, at) =>
|
||||
this.#read(invocation, () => readContext(this.#session, this.#storage, conversationId, context, at)),
|
||||
now: () => this.#now(),
|
||||
report: (error) => this.#report(error),
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a committed-state read unless the invocation has ended. */
|
||||
async #read<T>(invocation: Invocation, read: () => Promise<T>): Promise<T> {
|
||||
if (invocation.ended) throw endedError(invocation);
|
||||
return read();
|
||||
}
|
||||
|
||||
/** Commit after rereading the task on the line and gating the invocation. */
|
||||
#gated<T>(
|
||||
invocation: Invocation,
|
||||
@@ -542,25 +609,7 @@ export class TaskScheduler {
|
||||
return change(tx, current);
|
||||
},
|
||||
context,
|
||||
invocation.conversationId,
|
||||
);
|
||||
}
|
||||
|
||||
#readMemo(invocation: Invocation, name: string): Promise<JsonValue | undefined> {
|
||||
if (invocation.ended) return Promise.reject(endedError(invocation));
|
||||
return Promise.resolve(memoOf(this.#live.get(invocation.taskId), name));
|
||||
}
|
||||
|
||||
#writeMemo(invocation: Invocation, name: string, candidate: JsonValue, context: Context): Promise<JsonValue> {
|
||||
return this.#gated(
|
||||
invocation,
|
||||
(tx, current) => {
|
||||
const winner = memoOf(current, name);
|
||||
if (winner !== undefined) return winner;
|
||||
tx.setTask({ ...current, memos: { ...current.memos, [name]: candidate } } as AnyTaskRecord);
|
||||
return candidate;
|
||||
},
|
||||
context,
|
||||
{ conversationId: invocation.conversationId, taskId: invocation.taskId },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -580,10 +629,7 @@ export class TaskScheduler {
|
||||
|
||||
async #watchDoc(invocation: Invocation, args: readonly unknown[]): Promise<DocumentWatch<JsonObject> | undefined> {
|
||||
if (invocation.ended) throw endedError(invocation);
|
||||
const watchDoc = this.#session.watchDoc.bind(this.#session) as (
|
||||
...args: readonly unknown[]
|
||||
) => Promise<DocumentWatch<JsonObject> | undefined>;
|
||||
const watch = await watchDoc(...args);
|
||||
const watch = (await sessionMethod(this.#session, "watchDoc")(...args)) as DocumentWatch<JsonObject> | undefined;
|
||||
if (watch === undefined) return undefined;
|
||||
if (invocation.ended) {
|
||||
void watch.stop();
|
||||
@@ -601,6 +647,18 @@ function memoOf(record: LiveTaskRecord | undefined, name: string): JsonValue | u
|
||||
return memos !== undefined && Object.hasOwn(memos, name) ? memos[name] : undefined;
|
||||
}
|
||||
|
||||
/** Overloaded Session method bound for forwarding an argument list unchanged. */
|
||||
function sessionMethod(
|
||||
session: SessionImpl,
|
||||
name: "snapshot" | "snapshotAsOf" | "watchDoc",
|
||||
): (...args: readonly unknown[]) => Promise<unknown> {
|
||||
return (session[name] as (...args: readonly unknown[]) => Promise<unknown>).bind(session);
|
||||
}
|
||||
|
||||
function missingMigration(record: LiveTaskRecord, definition: ErasedDefinition): Error {
|
||||
return new Error(`Task ${record.kind} version ${definition.version} has no migration from ${record.version}`);
|
||||
}
|
||||
|
||||
function erased(task: AnyTask): ErasedDefinition {
|
||||
return task.definition as unknown as ErasedDefinition;
|
||||
}
|
||||
@@ -609,19 +667,6 @@ function endedError(invocation: Invocation): Error {
|
||||
return new Error(`Task ${invocation.taskId} invocation has ended`);
|
||||
}
|
||||
|
||||
function closedError(): Error {
|
||||
return new Error("Harness is closed");
|
||||
}
|
||||
|
||||
function terminal(outcome: TaskOutcome<JsonValue>): NextTaskState<JsonValue, JsonValue> {
|
||||
return { status: "terminal", outcome };
|
||||
}
|
||||
|
||||
function faulted(record: LiveTaskRecord, error: unknown): AnyTaskRecord {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return withState(record, terminal({ status: "faulted", error: { message } }));
|
||||
}
|
||||
|
||||
/** Replace a live record's state; memos disappear in the terminal replacement. */
|
||||
function withState(record: LiveTaskRecord, state: TaskState<JsonValue, JsonValue>): AnyTaskRecord {
|
||||
if (state.status !== "terminal") return { ...record, state } as AnyTaskRecord;
|
||||
@@ -637,45 +682,6 @@ function canReserve(task: AnyTask, record: LiveTaskRecord): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait in `waiters[key]` until settled or `context` is cancelled. */
|
||||
function addWaiter<K, T>(waiters: Map<K, Set<Waiter<T>>>, key: K, context: Context): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) return reject(signal.reason);
|
||||
let set = waiters.get(key);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
waiters.set(key, set);
|
||||
}
|
||||
const own = set;
|
||||
const remove = (): void => {
|
||||
own.delete(waiter);
|
||||
if (own.size === 0 && waiters.get(key) === own) waiters.delete(key);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const waiter: Waiter<T> = {
|
||||
resolve: (value) => {
|
||||
remove();
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
remove();
|
||||
reject(error);
|
||||
},
|
||||
};
|
||||
const onAbort = (): void => waiter.reject(signal!.reason);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
own.add(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
function settleWaiters<K, T>(waiters: Map<K, Set<Waiter<T>>>, key: K, settle: (waiter: Waiter<T>) => void): void {
|
||||
const set = waiters.get(key);
|
||||
if (set === undefined) return;
|
||||
waiters.delete(key);
|
||||
for (const waiter of [...set]) settle(waiter);
|
||||
}
|
||||
|
||||
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
@@ -690,6 +696,7 @@ function delay(ms: number, signal: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Structural equality of two JSON values; object key order is ignored. */
|
||||
function jsonEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
|
||||
if (left === right) return true;
|
||||
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { UserEntry } from "../entries.ts";
|
||||
import { ConversationBusy } from "../errors.ts";
|
||||
import type { SessionImpl } from "../session/session.ts";
|
||||
import type { CommitPublication, ConversationId, Storage, SubmissionId, SubmissionRecord } from "../types.ts";
|
||||
import { GenerationTask } from "./generation.ts";
|
||||
import { LiveDoc } from "./live.ts";
|
||||
import type { SettledSubmissionRecord, Submission, SubmissionDraft } from "./types.ts";
|
||||
import { closedError, Waiters } from "./util.ts";
|
||||
|
||||
type AbortResult = "aborted" | "already_placed" | "settled";
|
||||
|
||||
/** Admission, waits, and withdrawal of the durable submissions of one Harness. */
|
||||
export class Submissions {
|
||||
readonly #session: SessionImpl;
|
||||
readonly #storage: Storage;
|
||||
readonly #now: () => number;
|
||||
/** Enable task scheduling; submitting or waiting asks for progress. */
|
||||
readonly #resume: () => void;
|
||||
readonly #waiters = new Waiters<SubmissionId, SettledSubmissionRecord>();
|
||||
#closed = false;
|
||||
|
||||
constructor(session: SessionImpl, storage: Storage, now: () => number, resume: () => void) {
|
||||
this.#session = session;
|
||||
this.#storage = storage;
|
||||
this.#now = now;
|
||||
this.#resume = resume;
|
||||
session.subscribeCommits((publication) => this.#observe(publication));
|
||||
session.subscribeClose(() => {
|
||||
this.#closed = true;
|
||||
this.#waiters.rejectAll(closedError());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit a submission in one commit. A known request ID returns its existing submission without writing. Until the
|
||||
* inbox exists, a busy conversation rejects with `ConversationBusy` and writes nothing. Idle input places a user
|
||||
* entry and starts a generation run; an idle write appends its entry and settles `done`.
|
||||
*/
|
||||
async submit(conversationId: ConversationId, draft: SubmissionDraft, context: Context): Promise<Submission> {
|
||||
this.#resume();
|
||||
const id = await this.#session.commitWith(async (tx) => {
|
||||
if (draft.requestId !== undefined) {
|
||||
const existing = await tx.submissionByRequest(conversationId, draft.requestId);
|
||||
if (existing !== undefined) {
|
||||
if (existing.type !== draft.type) {
|
||||
throw new Error(
|
||||
`Request ${draft.requestId} already identifies a submission of type ${existing.type}`,
|
||||
);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
if (live.run !== undefined) throw new ConversationBusy(conversationId);
|
||||
const requestId = draft.requestId === undefined ? {} : { requestId: draft.requestId };
|
||||
if (draft.type === "write") {
|
||||
const entry = await tx.appendEntry(conversationId, draft.entry);
|
||||
const write = { conversationId, ...requestId, type: "write", status: "done", entry: entry.id } as const;
|
||||
return (await tx.createSubmission(write)).id;
|
||||
}
|
||||
const message = { role: "user", content: draft.content, timestamp: this.#now() } as const;
|
||||
const entry = await tx.appendEntry(UserEntry, conversationId, { model: [message] });
|
||||
const input = { conversationId, ...requestId, type: "input", status: "placed", entry: entry.id } as const;
|
||||
const { id } = await tx.createSubmission(input);
|
||||
live.run = { taskId: await tx.createTask(GenerationTask, {}, { conversationId }), inputs: [id] };
|
||||
return id;
|
||||
}, context);
|
||||
return new SubmissionHandle(id, this);
|
||||
}
|
||||
|
||||
/** Handle for an existing submission, or `undefined`. */
|
||||
async get(id: SubmissionId, context: Context): Promise<Submission | undefined> {
|
||||
const record = await this.#session.readOnLine(() => this.#storage.submission(id, context));
|
||||
return record === undefined ? undefined : new SubmissionHandle(record.id, this);
|
||||
}
|
||||
|
||||
async status(id: SubmissionId, context: Context): Promise<SubmissionRecord> {
|
||||
const record = await this.#session.readOnLine(() => this.#storage.submission(id, context));
|
||||
if (record === undefined) throw new Error(`Submission ${id} does not exist`);
|
||||
return record;
|
||||
}
|
||||
|
||||
async wait(id: SubmissionId, context: Context): Promise<SettledSubmissionRecord> {
|
||||
this.#resume();
|
||||
// Check and register on the line so no settling publication falls between them.
|
||||
const found = await this.#session.readOnLine(async () => {
|
||||
const record = await this.#storage.submission(id, context);
|
||||
if (record === undefined) throw new Error(`Submission ${id} does not exist`);
|
||||
if (isSettled(record)) return { promise: Promise.resolve(record) };
|
||||
// Close rejects registered waiters synchronously and may begin during the read.
|
||||
if (this.#closed) throw closedError();
|
||||
return { promise: this.#waiters.add(id, context) };
|
||||
});
|
||||
return found.promise;
|
||||
}
|
||||
|
||||
/** Withdraw a queued submission; placed inputs and settled submissions are reported, not changed. */
|
||||
abort(id: SubmissionId, context: Context, conversationId?: ConversationId): Promise<AbortResult | "not_found"> {
|
||||
return this.#session.commitWith(async (tx) => {
|
||||
const record = await tx.submission(id);
|
||||
if (record === undefined || (conversationId !== undefined && record.conversationId !== conversationId)) {
|
||||
return "not_found";
|
||||
}
|
||||
if (record.status === "queued") {
|
||||
tx.settleSubmission(id, { status: "unanswered", reason: "aborted" });
|
||||
return "aborted";
|
||||
}
|
||||
return record.status === "placed" ? "already_placed" : "settled";
|
||||
}, context);
|
||||
}
|
||||
|
||||
#observe(publication: CommitPublication): void {
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "submission" || !isSettled(change.value)) continue;
|
||||
this.#waiters.resolve(change.value.id, change.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SubmissionHandle implements Submission {
|
||||
readonly id: SubmissionId;
|
||||
readonly #submissions: Submissions;
|
||||
|
||||
constructor(id: SubmissionId, submissions: Submissions) {
|
||||
this.id = id;
|
||||
this.#submissions = submissions;
|
||||
}
|
||||
|
||||
status(context: Context): Promise<SubmissionRecord> {
|
||||
return this.#submissions.status(this.id, context);
|
||||
}
|
||||
|
||||
wait(context: Context): Promise<SettledSubmissionRecord> {
|
||||
return this.#submissions.wait(this.id, context);
|
||||
}
|
||||
|
||||
async abort(context: Context): Promise<AbortResult> {
|
||||
const result = await this.#submissions.abort(this.id, context);
|
||||
if (result === "not_found") throw new Error(`Submission ${this.id} does not exist`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function isSettled(record: SubmissionRecord): record is SettledSubmissionRecord {
|
||||
return record.status === "done" || record.status === "unanswered";
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import type { Context, JsonValue } from "@earendil-works/chord";
|
||||
import type { Message, Models, ModelThinkingLevel, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
CacheRetention,
|
||||
Message,
|
||||
Models,
|
||||
ModelThinkingLevel,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
Transport,
|
||||
UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
ConversationId,
|
||||
ConversationOwnership,
|
||||
ConversationRecord,
|
||||
Cursor,
|
||||
DocumentObserver,
|
||||
DocumentReader,
|
||||
EntryDraft,
|
||||
EntryId,
|
||||
EntryQuery,
|
||||
EntryRecord,
|
||||
JsonObject,
|
||||
Page,
|
||||
Session,
|
||||
SubmissionId,
|
||||
@@ -29,7 +41,7 @@ export type ModelRef = {
|
||||
|
||||
export type UserInput = UserMessage["content"];
|
||||
|
||||
/** Host submission: user input that may start a turn, or a passive entry write. */
|
||||
/** Host submission: user input that may start a run, or a passive entry write. */
|
||||
export type SubmissionDraft = {
|
||||
readonly requestId?: string;
|
||||
} & (
|
||||
@@ -81,9 +93,6 @@ export type AnyTask = {
|
||||
/** Hook handler map declared by a task definition. */
|
||||
export type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H> ? H : never;
|
||||
|
||||
/** Committed document reads. */
|
||||
export type DocumentReader = Pick<Session, "snapshot" | "snapshotAsOf">;
|
||||
|
||||
/** Invocation-bound conversation operations available to tools. */
|
||||
export interface ConversationHandle {
|
||||
readonly id: ConversationId;
|
||||
@@ -209,6 +218,8 @@ export interface RegistrySnapshot<Tool extends ToolRegistration = ToolRegistrati
|
||||
sections(): readonly PromptSection<Tool>[];
|
||||
/** Wrapper failures of this state. */
|
||||
failures(): readonly RegistryFailure[];
|
||||
/** Conversation setups in registry order, the built-in `pi` setup first. */
|
||||
conversationSetups(): readonly { readonly key: string; readonly setup: ConversationSetup }[];
|
||||
}
|
||||
|
||||
/** Read side of a registry consumed by a Harness. */
|
||||
@@ -239,6 +250,10 @@ export interface Registry<Tool extends ToolRegistration = ToolRegistration> exte
|
||||
add(task: AnyTask): Registration;
|
||||
list(): readonly AnyTask[];
|
||||
};
|
||||
readonly conversations: {
|
||||
/** `key` orders setups and keeps its position on re-registration. */
|
||||
setup(key: string, setup: ConversationSetup): Registration;
|
||||
};
|
||||
readonly systemPrompt: {
|
||||
section(key: string, render: PromptSection<Tool>["render"], options?: { readonly tag?: boolean }): Registration;
|
||||
wrap(key: string, wrapperKey: string, wrapper: PromptSectionWrapper<Tool>): Registration;
|
||||
@@ -249,6 +264,18 @@ export interface Registry<Tool extends ToolRegistration = ToolRegistration> exte
|
||||
batch(register: () => void): Registration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stages the documents every new conversation gets. Runs inside every Harness commit that creates or forks a
|
||||
* conversation, including raw `Tx` creation, after fork copies and before host `init`. Table reads throw, since the
|
||||
* conversation write came first; a fork (`conversation.parent`) already holds its copied documents. A throw fails the
|
||||
* creating commit.
|
||||
*/
|
||||
export type ConversationSetup = (
|
||||
tx: Tx,
|
||||
conversation: ConversationRecord,
|
||||
registry: RegistrySnapshot,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Runs inside the creating commit, after the conversation and its configuration exist. The conversation creation is
|
||||
* already a table write, so table reads here throw `ReadAfterWrite`; document access remains available.
|
||||
@@ -260,6 +287,27 @@ export type ConversationCreateOptions = {
|
||||
readonly init?: ConversationInit;
|
||||
};
|
||||
|
||||
/** Curated pi-ai request options; absent fields use pi-ai defaults. */
|
||||
export type ConversationStreamOptions = {
|
||||
transport?: Transport;
|
||||
timeoutMs?: number;
|
||||
/** Provider/SDK retries inside one request attempt. */
|
||||
maxRetries?: number;
|
||||
maxRetryDelayMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
metadata?: JsonObject;
|
||||
cacheRetention?: CacheRetention;
|
||||
deferred?: boolean | { window?: "15m" | "1h" | "24h" };
|
||||
};
|
||||
|
||||
/** Durable generation attempt retries; the JSON shape of pi-ai `RetryPolicy`. */
|
||||
export type ConversationRetryPolicy = {
|
||||
enabled: boolean;
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxAgentDelayMs?: number;
|
||||
};
|
||||
|
||||
export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
|
||||
/** pi-ai model access used by generation. */
|
||||
readonly models: Models;
|
||||
@@ -269,11 +317,32 @@ export type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
|
||||
readonly onReport?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Typed entry kind with a narrowing guard. */
|
||||
export interface Entry<E extends EntryRecord = EntryRecord> {
|
||||
readonly kind: string;
|
||||
is(entry: EntryRecord | undefined): entry is E;
|
||||
}
|
||||
/** Live task and what the scheduler would do with it under the current registry. */
|
||||
export type TaskInspection = {
|
||||
readonly record: TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
readonly state: /** An invocation is active. */
|
||||
| { readonly kind: "running" }
|
||||
/** The next scheduling pass reserves it; `migrates` when its definition is newer and has `migrate`. */
|
||||
| { readonly kind: "ready"; readonly migrates: boolean }
|
||||
/** Pending until these tasks are terminal. */
|
||||
| { readonly kind: "waiting"; readonly on: readonly TaskId[] }
|
||||
/** No registered definition can take it; aborting it settles it as `orphaned`. */
|
||||
| {
|
||||
readonly kind: "blocked";
|
||||
readonly reason: "missing_task" | "task_too_old" | "migration_failed";
|
||||
readonly error?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/** Point-in-time view of live work: unfinished tasks and submissions, read on the Session line. */
|
||||
export type HarnessInspection = {
|
||||
readonly scheduling: "paused" | "running" | "closing";
|
||||
readonly tasks: readonly TaskInspection[];
|
||||
/** Queued and placed submissions, in ID order. */
|
||||
readonly submissions: readonly SubmissionRecord[];
|
||||
/** Wrapper failures of the current registry snapshot. */
|
||||
readonly registry: readonly RegistryFailure[];
|
||||
};
|
||||
|
||||
/** Raw active transcript and derived model context. */
|
||||
export type ContextView = {
|
||||
@@ -295,6 +364,19 @@ export interface Conversation {
|
||||
setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise<void>;
|
||||
getActiveTools(context: Context): Promise<readonly string[]>;
|
||||
setActiveTools(names: readonly string[], context: Context): Promise<void>;
|
||||
/** `{}` when unset. */
|
||||
getStreamOptions(context: Context): Promise<ConversationStreamOptions>;
|
||||
setStreamOptions(options: ConversationStreamOptions, context: Context): Promise<void>;
|
||||
/** The default policy when unset. */
|
||||
getRetryPolicy(context: Context): Promise<ConversationRetryPolicy>;
|
||||
/** `undefined` removes the configured policy. */
|
||||
setRetryPolicy(policy: ConversationRetryPolicy | undefined, context: Context): Promise<void>;
|
||||
|
||||
/**
|
||||
* Durably admit user input or a passive entry write. Until the inbox exists, a busy conversation rejects every
|
||||
* submission with `ConversationBusy`.
|
||||
*/
|
||||
submit(submission: SubmissionDraft, context: Context): Promise<Submission>;
|
||||
|
||||
/** Session commit whose `tx.createTask()` defaults to this conversation. */
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
|
||||
@@ -315,16 +397,29 @@ export interface Conversation {
|
||||
// and must not throw or call Session APIs, and Harness close will also join task invocations.
|
||||
/** Durable agent harness over one Session. */
|
||||
export interface Harness extends Session {
|
||||
/** Enable task scheduling. Idempotent; throws after close. */
|
||||
/**
|
||||
* Enable task scheduling. Idempotent; throws after close. Calls that wait for progress (`Conversation.submit()`,
|
||||
* `Submission.wait()`, `waitForTask()`, `waitForIdle()`) enable it too.
|
||||
*/
|
||||
resume(): void;
|
||||
|
||||
/** Return the reserved root conversation, creating it with `init` in one commit when absent. */
|
||||
root(context: Context, options?: { readonly init?: ConversationInit }): Promise<Conversation>;
|
||||
// Conversation activity (active/idle notifications) is specified with turn control in Package 17.
|
||||
// Conversation activity (active/idle notifications) is specified with run control in Package 17.
|
||||
conversation(id: ConversationId, context: Context): Promise<Conversation | undefined>;
|
||||
createConversation(options: ConversationCreateOptions, context: Context): Promise<Conversation>;
|
||||
|
||||
getTask<R>(id: TaskId<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
|
||||
/** Live tasks, unsettled submissions, and registry failures. Writes nothing and runs no task code. */
|
||||
inspect(context: Context): Promise<HarnessInspection>;
|
||||
/** Reacquire a submission, for example after reopen. */
|
||||
submission(id: SubmissionId, context: Context): Promise<Submission | undefined>;
|
||||
/** `not_found` for an unknown submission or one of another conversation than `conversationId`. */
|
||||
abortSubmission(
|
||||
id: SubmissionId,
|
||||
context: Context,
|
||||
conversationId?: ConversationId,
|
||||
): Promise<"aborted" | "already_placed" | "settled" | "not_found">;
|
||||
/**
|
||||
* Commit the abort mark, signal and join an active run invocation, and schedule the abort invocation. A task whose
|
||||
* definition cannot take it settles as `orphaned` instead.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import type { Cursor, Page } from "../types.ts";
|
||||
|
||||
/** Pending waits by key. Each settles once: through `resolve`, `rejectAll`, or cancellation of its context. */
|
||||
export class Waiters<K, T> {
|
||||
readonly #sets = new Map<K, Set<PromiseWithResolvers<T>>>();
|
||||
|
||||
add(key: K, context: Context): Promise<T> {
|
||||
const signal = context.abortSignal;
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
const waiter = Promise.withResolvers<T>();
|
||||
const set = this.#sets.get(key) ?? new Set();
|
||||
this.#sets.set(key, set.add(waiter));
|
||||
if (signal !== undefined) {
|
||||
const onAbort = (): void => {
|
||||
set.delete(waiter);
|
||||
if (set.size === 0 && this.#sets.get(key) === set) this.#sets.delete(key);
|
||||
waiter.reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
const detach = (): void => signal.removeEventListener("abort", onAbort);
|
||||
waiter.promise.then(detach, detach);
|
||||
}
|
||||
return waiter.promise;
|
||||
}
|
||||
|
||||
keys(): K[] {
|
||||
return [...this.#sets.keys()];
|
||||
}
|
||||
|
||||
resolve(key: K, value: T): void {
|
||||
const set = this.#sets.get(key);
|
||||
this.#sets.delete(key);
|
||||
for (const waiter of set ?? []) waiter.resolve(value);
|
||||
}
|
||||
|
||||
rejectAll(error: unknown): void {
|
||||
const sets = [...this.#sets.values()];
|
||||
this.#sets.clear();
|
||||
for (const set of sets) for (const waiter of set) waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every item of a paginated scan, in page order. */
|
||||
export async function scanAll<T>(scan: (cursor: Cursor | undefined) => Promise<Page<T, Cursor>>): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let cursor: Cursor | undefined;
|
||||
do {
|
||||
const page = await scan(cursor);
|
||||
items.push(...page.items);
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function closedError(): Error {
|
||||
return new Error("Harness is closed");
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
export { defineDoc, defineDocFamily } from "./documents.ts";
|
||||
export { defineEntry } from "./entries.ts";
|
||||
export { ReadAfterWrite, StorageRejected } from "./errors.ts";
|
||||
export { AssistantEntry, defineEntry, SystemEntry, ToolResultEntry, UserEntry } from "./entries.ts";
|
||||
export { ConversationBusy, ReadAfterWrite, StorageRejected } from "./errors.ts";
|
||||
export { ConversationConfig, type ConversationConfigState } from "./harness/config.ts";
|
||||
export {
|
||||
type GenerationCheckpoint,
|
||||
type GenerationInput,
|
||||
type GenerationResult,
|
||||
GenerationTask,
|
||||
} from "./harness/generation.ts";
|
||||
export { Harness } from "./harness/harness.ts";
|
||||
export { LiveDoc, type LiveState } from "./harness/live.ts";
|
||||
export { createRegistry } from "./harness/registry.ts";
|
||||
export type {
|
||||
AnyTask,
|
||||
@@ -11,8 +18,10 @@ export type {
|
||||
ConversationCreateOptions,
|
||||
ConversationHandle,
|
||||
ConversationInit,
|
||||
DocumentReader,
|
||||
Entry,
|
||||
ConversationRetryPolicy,
|
||||
ConversationSetup,
|
||||
ConversationStreamOptions,
|
||||
HarnessInspection,
|
||||
HarnessOptions,
|
||||
HookRegistration,
|
||||
HookScope,
|
||||
@@ -31,6 +40,7 @@ export type {
|
||||
SettledTask,
|
||||
Submission,
|
||||
SubmissionDraft,
|
||||
TaskInspection,
|
||||
ToolControl,
|
||||
ToolExecutionApi,
|
||||
ToolExecutionResult,
|
||||
@@ -67,10 +77,12 @@ export type {
|
||||
DocumentObserver,
|
||||
DocumentPoint,
|
||||
DocumentQuery,
|
||||
DocumentReader,
|
||||
DocumentRecord,
|
||||
DocumentSemantics,
|
||||
DocumentState,
|
||||
DocumentWatch,
|
||||
Entry,
|
||||
EntryDraft,
|
||||
EntryId,
|
||||
EntryQuery,
|
||||
@@ -94,7 +106,9 @@ export type {
|
||||
StoredDocument,
|
||||
SubmissionCreate,
|
||||
SubmissionId,
|
||||
SubmissionQuery,
|
||||
SubmissionRecord,
|
||||
SubmissionSettlement,
|
||||
TableCommitChange,
|
||||
Task,
|
||||
TaskDefinition,
|
||||
@@ -109,6 +123,8 @@ export type {
|
||||
TaskRuntime,
|
||||
TaskState,
|
||||
Tx,
|
||||
TypedEntry,
|
||||
TypedEntryDraft,
|
||||
WatchEnd,
|
||||
WatchHandle,
|
||||
} from "./types.ts";
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ConversationDocFamilyToken,
|
||||
ConversationDocToken,
|
||||
ConversationId,
|
||||
ConversationRecord,
|
||||
DocumentAddress,
|
||||
DocumentCommitChange,
|
||||
DocumentState,
|
||||
@@ -36,7 +37,7 @@ import type {
|
||||
Tx,
|
||||
} from "../types.ts";
|
||||
import { RETIREMENT_OPERATIONS, SessionDocumentSource, SessionDocumentWatch } from "./observation.ts";
|
||||
import { type LoadedDocument, Transaction, type TransactionHost } from "./transaction.ts";
|
||||
import { type LoadedDocument, Transaction, type TransactionHost, type TransactionScope } from "./transaction.ts";
|
||||
|
||||
/** Open a Session kernel over one storage backend. */
|
||||
export function createSession(storage: Storage): Session {
|
||||
@@ -71,33 +72,26 @@ export class SessionImpl implements Session {
|
||||
evict: (id, recordId) => {
|
||||
if (this.#documents.get(id)?.record.id === recordId) this.#documents.delete(id);
|
||||
},
|
||||
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
||||
};
|
||||
}
|
||||
|
||||
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T> {
|
||||
try {
|
||||
this.#assertUsable();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return this.#enqueue(() => this.#runCommit(change, context));
|
||||
return this.commitWith(change, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal commit exposing the concrete transaction and its internal operations, such as the reserved-ID root
|
||||
* bootstrap and task replacement. `tx.createTask()` defaults to `defaultConversationId`.
|
||||
* bootstrap and task replacement. `scope` sets the default `tx.createTask()` conversation and the task attributed to
|
||||
* appended entries.
|
||||
*/
|
||||
commitWith<T>(
|
||||
change: (tx: Transaction) => T | Promise<T>,
|
||||
context: Context,
|
||||
defaultConversationId?: ConversationId,
|
||||
): Promise<T> {
|
||||
commitWith<T>(change: (tx: Transaction) => T | Promise<T>, context: Context, scope?: TransactionScope): Promise<T> {
|
||||
try {
|
||||
this.#assertUsable();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return this.#enqueue(() => this.#runCommit(change, context, defaultConversationId));
|
||||
return this.#enqueue(() => this.#runCommit(change, context, scope));
|
||||
}
|
||||
|
||||
/** Internal: run a read-only job on the mutation line so multi-read derivations observe one committed state. */
|
||||
@@ -201,27 +195,15 @@ export class SessionImpl implements Session {
|
||||
this.#assertHealthy();
|
||||
const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
|
||||
if (loaded === undefined) return undefined;
|
||||
checkRecordScope(definition, loaded.record);
|
||||
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
||||
let unsubscribeCommit = (): void => {};
|
||||
let unsubscribeClose = (): void => {};
|
||||
const source = new SessionDocumentSource(loaded.tracker.value, () => {
|
||||
unsubscribeCommit();
|
||||
unsubscribeClose();
|
||||
});
|
||||
const observed = { version: loaded.valueVersion };
|
||||
unsubscribeCommit = this.subscribeCommits((publication, commitContext) => {
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "document" || change.record.id !== loaded.record.id) continue;
|
||||
source.advance(change.value, observedOperations(observed, change), withoutAbortSignal(commitContext));
|
||||
}
|
||||
});
|
||||
unsubscribeClose = this.subscribeClose(() => source.closeSession());
|
||||
const { observer: source, detach } = this.#attachDocument(
|
||||
definition,
|
||||
loaded,
|
||||
(value, release) => new SessionDocumentSource(value, release),
|
||||
);
|
||||
try {
|
||||
return replicatedState(source) as DocumentState<JsonObject>;
|
||||
} catch (error) {
|
||||
unsubscribeCommit();
|
||||
unsubscribeClose();
|
||||
detach();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -276,23 +258,11 @@ export class SessionImpl implements Session {
|
||||
const loaded = await this.#loadDocument(definition, resolved.id, resolved.address, context);
|
||||
if (cancelled) throw cancellationError(signal!);
|
||||
if (loaded === undefined) return undefined;
|
||||
checkRecordScope(definition, loaded.record);
|
||||
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
||||
let unsubscribeCommit = (): void => {};
|
||||
let unsubscribeClose = (): void => {};
|
||||
const watch = new SessionDocumentWatch(loaded.tracker.value, () => {
|
||||
unsubscribeCommit();
|
||||
unsubscribeClose();
|
||||
});
|
||||
const observed = { version: loaded.valueVersion };
|
||||
unsubscribeCommit = this.subscribeCommits((publication, commitContext) => {
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "document" || change.record.id !== loaded.record.id) continue;
|
||||
watch.advance(change.value, observedOperations(observed, change), commitContext);
|
||||
}
|
||||
});
|
||||
unsubscribeClose = this.subscribeClose(() => watch.closeSession());
|
||||
return watch;
|
||||
return this.#attachDocument(
|
||||
definition,
|
||||
loaded,
|
||||
(value, release) => new SessionDocumentWatch(value, release),
|
||||
).observer;
|
||||
});
|
||||
if (watch === undefined) return undefined;
|
||||
if (cancelled) {
|
||||
@@ -373,6 +343,14 @@ export class SessionImpl implements Session {
|
||||
return awaitWithContext(this.#closing, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
||||
* plain Session stages nothing; a Harness stages its built-in documents.
|
||||
*/
|
||||
protected conversationCreated(_tx: Transaction, _record: ConversationRecord): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** Runs after close seals admission and before the line closes Storage; must not reject. */
|
||||
protected beforeClose(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
@@ -402,11 +380,11 @@ export class SessionImpl implements Session {
|
||||
async #runCommit<T>(
|
||||
change: (tx: Transaction) => T | Promise<T>,
|
||||
context: Context,
|
||||
defaultConversationId?: ConversationId,
|
||||
scope?: TransactionScope,
|
||||
): Promise<T> {
|
||||
this.#assertHealthy();
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const tx = new Transaction(this.#host, context, defaultConversationId);
|
||||
const tx = new Transaction(this.#host, context, scope);
|
||||
let result: T;
|
||||
try {
|
||||
result = await change(tx);
|
||||
@@ -463,6 +441,37 @@ export class SessionImpl implements Session {
|
||||
for (const listener of [...this.#commitListeners]) listener(publication, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an observer to one committed incarnation: check the definition, then forward this incarnation's committed
|
||||
* changes and close. `detach` removes both subscriptions.
|
||||
*/
|
||||
#attachDocument<O extends SessionDocumentSource | SessionDocumentWatch>(
|
||||
definition: AnyDocToken["definition"],
|
||||
loaded: LoadedDocument,
|
||||
create: (value: JsonObject, detach: () => void) => O,
|
||||
): { observer: O; detach: () => void } {
|
||||
checkRecordScope(definition, loaded.record);
|
||||
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
|
||||
let unsubscribeCommit = (): void => {};
|
||||
let unsubscribeClose = (): void => {};
|
||||
const detach = (): void => {
|
||||
unsubscribeCommit();
|
||||
unsubscribeClose();
|
||||
};
|
||||
const observer = create(loaded.tracker.value, detach);
|
||||
const observed = { version: loaded.valueVersion };
|
||||
unsubscribeCommit = this.subscribeCommits((publication, context) => {
|
||||
for (const change of publication.changes) {
|
||||
if (change.type !== "document" || change.record.id !== loaded.record.id) continue;
|
||||
// A document state's frames carry no caller cancellation; a watch observes its own cancellation.
|
||||
const frameContext = observer instanceof SessionDocumentSource ? withoutAbortSignal(context) : context;
|
||||
observer.advance(change.value, observedOperations(observed, change), frameContext);
|
||||
}
|
||||
});
|
||||
unsubscribeClose = this.subscribeClose(() => observer.closeSession());
|
||||
return { observer, detach };
|
||||
}
|
||||
|
||||
async #loadDocument(
|
||||
definition: AnyDocToken["definition"],
|
||||
addressId: string,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
DocumentCreate,
|
||||
DocumentId,
|
||||
DocumentRecord,
|
||||
Entry,
|
||||
EntryDraft,
|
||||
EntryId,
|
||||
EntryQuery,
|
||||
@@ -36,6 +37,10 @@ import type {
|
||||
SessionDocToken,
|
||||
Storage,
|
||||
StorageWrite,
|
||||
SubmissionCreate,
|
||||
SubmissionId,
|
||||
SubmissionRecord,
|
||||
SubmissionSettlement,
|
||||
Task,
|
||||
TaskDocFamilyToken,
|
||||
TaskDocToken,
|
||||
@@ -44,12 +49,24 @@ import type {
|
||||
TaskQuery,
|
||||
TaskRecord,
|
||||
Tx,
|
||||
TypedEntry,
|
||||
TypedEntryDraft,
|
||||
} from "../types.ts";
|
||||
import { ROOT_CONVERSATION_ID } from "../types.ts";
|
||||
import { prepareForkDocumentCopies } from "./forks.ts";
|
||||
|
||||
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
|
||||
/** Complete record after applying one settlement; only a placed input can be answered. A settled record stays. */
|
||||
function settledSubmission(current: SubmissionRecord, settlement: SubmissionSettlement): SubmissionRecord {
|
||||
if (current.status === "done" || current.status === "unanswered") return current;
|
||||
if (settlement.status === "done" && current.status !== "placed") {
|
||||
throw new Error(`Submission ${current.id} is not a placed input`);
|
||||
}
|
||||
// Queued and placed records carry no answer, reason, or detail; an unanswered input keeps its entry.
|
||||
return { ...current, ...settlement } as SubmissionRecord;
|
||||
}
|
||||
|
||||
const INTERNAL_SCAN_PAGE_SIZE = 256;
|
||||
const EMPTY_OPERATIONS: readonly Op[] = [];
|
||||
const TABLE_JSON_COPY_OPTIONS = { omitUndefinedProperties: true } as const;
|
||||
@@ -83,6 +100,8 @@ export interface TransactionHost {
|
||||
install(document: LoadedDocument): void;
|
||||
/** Remove a retired incarnation if it is still the cached occupant of its address. */
|
||||
evict(addressId: string, recordId: DocumentId): void;
|
||||
/** Stage writes that belong to every newly created or forked conversation, in its creating transaction. */
|
||||
conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void>;
|
||||
}
|
||||
|
||||
/** Committed and candidate state for one task touched by this transaction. */
|
||||
@@ -92,6 +111,13 @@ type TransactionTask = {
|
||||
publicationConversationId?: ConversationId;
|
||||
};
|
||||
|
||||
/** Defaults a commit binds to: `tx.createTask()` conversation and the task attributed to appended entries. */
|
||||
export type TransactionScope = {
|
||||
readonly conversationId?: ConversationId;
|
||||
/** Task whose runtime commit this is; stamped as `byTaskId` on appended entries. */
|
||||
readonly taskId?: TaskId;
|
||||
};
|
||||
|
||||
/** Storage/cache provenance of one staged document incarnation. */
|
||||
type DocumentTarget =
|
||||
| { readonly kind: "loaded"; readonly document: LoadedDocument }
|
||||
@@ -121,8 +147,27 @@ type DocumentEntry = {
|
||||
change?: Change<JsonObject>;
|
||||
prepared?: Prepared<JsonObject>;
|
||||
retireOnCommit: boolean;
|
||||
/** Content kind selected for a loaded incarnation's write; absent when no content is written. */
|
||||
writtenContent?: "base" | "delta";
|
||||
};
|
||||
|
||||
/**
|
||||
* What one staged incarnation writes and publishes, decided once before Storage admission so adoption only applies it.
|
||||
* `record` is a `DocumentRecord` for an incarnation that is already committed and a `DocumentCreate` for a new one.
|
||||
*/
|
||||
type DocumentPlan = {
|
||||
readonly addressId: string;
|
||||
readonly record: DocumentCreate | DocumentRecord;
|
||||
retire: boolean;
|
||||
/** Creation, copy, or change content; absent when only retirement is written. */
|
||||
content?: Extract<StorageWrite, { readonly type: "document.create" | "document.copy" | "document.change" }>;
|
||||
/** Prepared change of a tracked incarnation; absent for fork copies and retirement-only entries. */
|
||||
readonly change?: {
|
||||
readonly tracker: Tracker<JsonObject>;
|
||||
readonly prepared: Prepared<JsonObject>;
|
||||
readonly version: number;
|
||||
/** The cached incarnation this change updates; absent when it creates one. */
|
||||
readonly loaded?: LoadedDocument;
|
||||
readonly definition?: AnyDocDefinition;
|
||||
};
|
||||
/** Resolved before Storage admission so adoption performs no reads. */
|
||||
conversationId?: ConversationId;
|
||||
};
|
||||
@@ -136,7 +181,7 @@ type DocumentEntry = {
|
||||
export class Transaction implements Tx {
|
||||
readonly #host: TransactionHost;
|
||||
readonly #context: Context;
|
||||
readonly #defaultConversationId: ConversationId | undefined;
|
||||
readonly #scope: TransactionScope;
|
||||
readonly #pendingOperations = new Set<Promise<unknown>>();
|
||||
#sealed = false;
|
||||
#hasTableWrite = false;
|
||||
@@ -148,16 +193,22 @@ export class Transaction implements Tx {
|
||||
readonly #forkSourceDocumentIds = new Set<DocumentId>();
|
||||
/** One entry per task touched by a public read, candidate write, or document-owner lookup. */
|
||||
readonly #tasksById = new Map<TaskId, TransactionTask>();
|
||||
/** Submissions created by this transaction, by ID. */
|
||||
readonly #submissions = new Map<SubmissionId, SubmissionRecord>();
|
||||
/** Settlements in staging order; resolved against the latest candidate record during assembly. */
|
||||
readonly #settlements: { readonly id: SubmissionId; readonly settlement: SubmissionSettlement }[] = [];
|
||||
|
||||
/** Write and publication plans of every staged incarnation, built during assembly. */
|
||||
readonly #plans: DocumentPlan[] = [];
|
||||
/** Every document acquisition or retirement marker in staging order. */
|
||||
readonly #documents: DocumentEntry[] = [];
|
||||
/** Latest transaction-local incarnation or retirement marker at each logical address. */
|
||||
readonly #latestDocumentByAddress = new Map<string, DocumentEntry>();
|
||||
|
||||
constructor(host: TransactionHost, context: Context, defaultConversationId?: ConversationId) {
|
||||
constructor(host: TransactionHost, context: Context, scope: TransactionScope = {}) {
|
||||
this.#host = host;
|
||||
this.#context = context;
|
||||
this.#defaultConversationId = defaultConversationId;
|
||||
this.#scope = scope;
|
||||
}
|
||||
|
||||
// ─── Table reads ────────────────────────────────────────────────────────
|
||||
@@ -166,8 +217,15 @@ export class Transaction implements Tx {
|
||||
return this.#read("conversation", () => this.#host.storage.conversation(id, this.#context));
|
||||
}
|
||||
|
||||
entry(id: EntryId): Promise<EntryRecord | undefined> {
|
||||
return this.#read("entry", async () => (await this.#host.storage.entry(id, this.#context))?.entry);
|
||||
entry(id: EntryId): Promise<EntryRecord | undefined>;
|
||||
entry<D extends JsonValue>(token: Entry<D>, id: EntryId): Promise<TypedEntry<D> | undefined>;
|
||||
entry(first: EntryId | { readonly kind: string }, second?: EntryId): Promise<EntryRecord | undefined> {
|
||||
const kind = typeof first === "number" ? undefined : first.kind;
|
||||
const id = typeof first === "number" ? first : second!;
|
||||
return this.#read("entry", async () => {
|
||||
const entry = (await this.#host.storage.entry(id, this.#context))?.entry;
|
||||
return kind === undefined || entry?.kind === kind ? entry : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
task(id: TaskId): Promise<AnyTaskRecord | undefined> {
|
||||
@@ -188,6 +246,18 @@ export class Transaction implements Tx {
|
||||
return this.#read("scanTasks", () => this.#host.storage.scanTasks(query, limit, cursor, this.#context));
|
||||
}
|
||||
|
||||
/** Internal: committed submission record. */
|
||||
submission(id: SubmissionId): Promise<SubmissionRecord | undefined> {
|
||||
return this.#read("submission", () => this.#host.storage.submission(id, this.#context));
|
||||
}
|
||||
|
||||
/** Internal: committed submission with a conversation-scoped request ID. */
|
||||
submissionByRequest(conversationId: ConversationId, requestId: string): Promise<SubmissionRecord | undefined> {
|
||||
return this.#read("submissionByRequest", () =>
|
||||
this.#host.storage.submissionByRequest(conversationId, requestId, this.#context),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table writes ───────────────────────────────────────────────────────
|
||||
|
||||
createConversation(options: { readonly ownership: ConversationOwnership }): Promise<ConversationRecord> {
|
||||
@@ -196,13 +266,7 @@ export class Transaction implements Tx {
|
||||
|
||||
/** Internal final-form bootstrap path for the reserved root identity. */
|
||||
createRootConversation(): Promise<ConversationRecord> {
|
||||
return this.#write(async () => {
|
||||
if ((await this.#host.storage.conversation(ROOT_CONVERSATION_ID, this.#context)) !== undefined) {
|
||||
throw new Error(`Root conversation ${ROOT_CONVERSATION_ID} already exists`);
|
||||
}
|
||||
this.#assertOpen();
|
||||
return this.#stageConversation(undefined, { kind: "ownerless" }, ROOT_CONVERSATION_ID);
|
||||
});
|
||||
return this.#write(() => this.#stageConversation(undefined, { kind: "ownerless" }, ROOT_CONVERSATION_ID));
|
||||
}
|
||||
|
||||
forkConversation(
|
||||
@@ -254,20 +318,42 @@ export class Transaction implements Tx {
|
||||
if (parent !== undefined) this.#forkSourceConversationIds.add(parent.conversationId);
|
||||
this.#createdConversationIds.add(id);
|
||||
this.#writes.push({ type: "conversation", value: record });
|
||||
await this.#host.conversationCreated(this, record);
|
||||
this.#assertOpen();
|
||||
return record;
|
||||
}
|
||||
|
||||
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> {
|
||||
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
|
||||
appendEntry<D extends JsonValue>(
|
||||
token: Entry<D>,
|
||||
conversationId: ConversationId,
|
||||
value: TypedEntryDraft<NoInfer<D>>,
|
||||
): Promise<TypedEntry<D>>;
|
||||
appendEntry(
|
||||
first: ConversationId | { readonly kind: string },
|
||||
second: ConversationId | EntryDraft,
|
||||
third?: object,
|
||||
): Promise<EntryRecord> {
|
||||
if (typeof first === "number") return this.#appendEntry(first, second as EntryDraft);
|
||||
return this.#appendEntry(second as ConversationId, { ...third, kind: first.kind } as EntryDraft);
|
||||
}
|
||||
|
||||
#appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord> {
|
||||
return this.#write(async () => {
|
||||
await this.#requireConversation(conversationId);
|
||||
this.#assertOpen();
|
||||
const id = await this.#host.storage.mintId<EntryId>();
|
||||
this.#assertOpen();
|
||||
const { head, ...rest } = value;
|
||||
// Undefined `head` and `byTaskId` are omitted by the copy.
|
||||
const record = copyJson(
|
||||
head === undefined
|
||||
? { ...rest, id, conversationId }
|
||||
: { ...rest, id, conversationId, head: head === "self" ? id : head },
|
||||
{
|
||||
...rest,
|
||||
id,
|
||||
conversationId,
|
||||
head: head === "self" ? id : head,
|
||||
byTaskId: this.#scope.taskId,
|
||||
},
|
||||
TABLE_JSON_COPY_OPTIONS,
|
||||
) as unknown as EntryRecord;
|
||||
this.#writes.push({ type: "entry", value: record });
|
||||
@@ -281,7 +367,7 @@ export class Transaction implements Tx {
|
||||
options?: TaskOptions,
|
||||
): Promise<TaskId<R>> {
|
||||
return this.#write(async () => {
|
||||
const conversationId = options?.conversationId ?? this.#defaultConversationId;
|
||||
const conversationId = options?.conversationId ?? this.#scope.conversationId;
|
||||
if (conversationId === undefined) throw new TypeError("Tx.createTask() requires options.conversationId");
|
||||
await this.#requireConversation(conversationId);
|
||||
this.#assertOpen();
|
||||
@@ -308,6 +394,29 @@ export class Transaction implements Tx {
|
||||
});
|
||||
}
|
||||
|
||||
/** Internal: create a submission record with a fresh ID. */
|
||||
createSubmission(create: SubmissionCreate): Promise<SubmissionRecord> {
|
||||
return this.#write(async () => {
|
||||
await this.#requireConversation(create.conversationId);
|
||||
this.#assertOpen();
|
||||
const id = await this.#host.storage.mintId<SubmissionId>();
|
||||
this.#assertOpen();
|
||||
const record = copyJson({ ...create, id }, TABLE_JSON_COPY_OPTIONS) as unknown as SubmissionRecord;
|
||||
this.#submissions.set(id, record);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a submission. Resolved during assembly against the transaction's latest candidate record, falling back to
|
||||
* committed state, so it is not a caller table read and works after the first table write.
|
||||
*/
|
||||
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void {
|
||||
this.#assertOpen();
|
||||
this.#hasTableWrite = true;
|
||||
this.#settlements.push({ id, settlement: copyJson(settlement) as SubmissionSettlement });
|
||||
}
|
||||
|
||||
/** Internal: replace one task record completely. Tasks change their own state through their runtime. */
|
||||
setTask(value: AnyTaskRecord): void {
|
||||
this.#assertOpen();
|
||||
@@ -503,7 +612,7 @@ export class Transaction implements Tx {
|
||||
async settleFailure(): Promise<void> {
|
||||
this.#sealed = true;
|
||||
this.#abortChanges();
|
||||
await this.#drain();
|
||||
await Promise.allSettled(this.#pendingOperations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,7 +623,7 @@ export class Transaction implements Tx {
|
||||
this.#sealed = true;
|
||||
if (this.#pendingOperations.size > 0) {
|
||||
this.#abortChanges();
|
||||
await this.#drain();
|
||||
await Promise.allSettled(this.#pendingOperations);
|
||||
throw new Error("Session commit callback settled before its pending Tx operations");
|
||||
}
|
||||
try {
|
||||
@@ -537,97 +646,55 @@ export class Transaction implements Tx {
|
||||
/** Adopt every prepared change by pointer swap after Storage success and describe the publication. */
|
||||
adopt(seq: Seq): DocumentCommitChange[] {
|
||||
const publications: DocumentCommitChange[] = [];
|
||||
for (const document of this.#documents) {
|
||||
const target = document.target;
|
||||
if (target === undefined) continue;
|
||||
switch (target.kind) {
|
||||
case "created": {
|
||||
const prepared = document.prepared!;
|
||||
const record: DocumentRecord = document.retireOnCommit
|
||||
? { ...target.record, createdAt: seq, retiredAt: seq }
|
||||
: { ...target.record, createdAt: seq };
|
||||
if (document.retireOnCommit) prepared.abort();
|
||||
else {
|
||||
target.tracker.adopt(prepared);
|
||||
this.#host.install({
|
||||
addressId: document.addressId,
|
||||
record,
|
||||
storedVersion: target.version,
|
||||
valueVersion: target.version,
|
||||
deltasSinceBase: 0,
|
||||
tracker: target.tracker,
|
||||
});
|
||||
for (const plan of this.#plans) {
|
||||
const committed = "createdAt" in plan.record;
|
||||
let record: DocumentRecord = committed ? (plan.record as DocumentRecord) : { ...plan.record, createdAt: seq };
|
||||
if (plan.retire) record = { ...record, retiredAt: seq };
|
||||
const change = plan.change;
|
||||
if (change !== undefined) {
|
||||
const { tracker, prepared, loaded, version } = change;
|
||||
// A new incarnation is adopted unless it retires in the same commit; a loaded one only when it changed.
|
||||
if (loaded === undefined ? !plan.retire : prepared.ops.length > 0) tracker.adopt(prepared);
|
||||
else prepared.abort();
|
||||
if (loaded !== undefined) {
|
||||
if (loaded.storedVersion < version) loaded.storedVersion = version;
|
||||
if (plan.content?.type === "document.change") {
|
||||
if (plan.content.content.kind === "base") loaded.deltasSinceBase = 0;
|
||||
else loaded.deltasSinceBase++;
|
||||
}
|
||||
publications.push({
|
||||
type: "document",
|
||||
} else if (!plan.retire) {
|
||||
this.#host.install({
|
||||
addressId: plan.addressId,
|
||||
record,
|
||||
conversationId: document.conversationId,
|
||||
version: document.retireOnCommit ? undefined : target.version,
|
||||
value: document.retireOnCommit ? null : prepared.value,
|
||||
ops: EMPTY_OPERATIONS,
|
||||
storedVersion: version,
|
||||
valueVersion: version,
|
||||
deltasSinceBase: 0,
|
||||
tracker,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "fork-copy": {
|
||||
const record: DocumentRecord = document.retireOnCommit
|
||||
? { ...target.record, createdAt: seq, retiredAt: seq }
|
||||
: { ...target.record, createdAt: seq };
|
||||
if (document.retireOnCommit) {
|
||||
publications.push({
|
||||
type: "document",
|
||||
record,
|
||||
conversationId: document.conversationId,
|
||||
version: undefined,
|
||||
value: null,
|
||||
ops: EMPTY_OPERATIONS,
|
||||
});
|
||||
} else {
|
||||
publications.push({
|
||||
type: "document.copy",
|
||||
record,
|
||||
conversationId: document.conversationId!,
|
||||
source: target.source,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "loaded": {
|
||||
const prepared = document.prepared!;
|
||||
const changed = prepared.ops.length > 0;
|
||||
if (changed) target.document.tracker.adopt(prepared);
|
||||
else prepared.abort();
|
||||
if (target.document.storedVersion < document.definition!.version) {
|
||||
target.document.storedVersion = document.definition!.version;
|
||||
}
|
||||
if (document.writtenContent === "base") target.document.deltasSinceBase = 0;
|
||||
else if (document.writtenContent === "delta") target.document.deltasSinceBase++;
|
||||
if (!document.retireOnCommit && !changed) break;
|
||||
if (document.retireOnCommit) {
|
||||
this.#host.evict(target.document.addressId, target.document.record.id);
|
||||
}
|
||||
publications.push({
|
||||
type: "document",
|
||||
record: document.retireOnCommit
|
||||
? { ...target.document.record, retiredAt: seq }
|
||||
: target.document.record,
|
||||
conversationId: document.conversationId,
|
||||
version: document.retireOnCommit ? undefined : document.definition!.version,
|
||||
value: document.retireOnCommit ? null : prepared.value,
|
||||
ops: document.retireOnCommit ? EMPTY_OPERATIONS : prepared.ops,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "retire-only":
|
||||
this.#host.evict(document.addressId, target.record.id);
|
||||
publications.push({
|
||||
type: "document",
|
||||
record: { ...target.record, retiredAt: seq },
|
||||
conversationId: document.conversationId,
|
||||
version: undefined,
|
||||
value: null,
|
||||
ops: EMPTY_OPERATIONS,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const conversationId = plan.conversationId;
|
||||
if (plan.retire) {
|
||||
if (committed) this.#host.evict(plan.addressId, record.id);
|
||||
const retired = { version: undefined, value: null, ops: EMPTY_OPERATIONS };
|
||||
publications.push({ type: "document", record, conversationId, ...retired });
|
||||
} else if (plan.content?.type === "document.copy") {
|
||||
publications.push({
|
||||
type: "document.copy",
|
||||
record,
|
||||
conversationId: conversationId!,
|
||||
source: plan.content.source,
|
||||
});
|
||||
} else if (change !== undefined && publishes(plan)) {
|
||||
const ops = change.loaded === undefined ? EMPTY_OPERATIONS : change.prepared.ops;
|
||||
publications.push({
|
||||
type: "document",
|
||||
record,
|
||||
conversationId,
|
||||
version: change.version,
|
||||
value: change.prepared.value,
|
||||
ops,
|
||||
});
|
||||
}
|
||||
}
|
||||
return publications;
|
||||
@@ -635,7 +702,12 @@ export class Transaction implements Tx {
|
||||
|
||||
async #assemble(): Promise<StorageWrite[]> {
|
||||
const storage = this.#host.storage;
|
||||
this.#rejectForkSourceWrites();
|
||||
const plans = this.#plans;
|
||||
for (const document of this.#documents) {
|
||||
const plan = planDocument(document);
|
||||
if (plan !== undefined) plans.push(plan);
|
||||
}
|
||||
this.#rejectForkSourceWrites(plans);
|
||||
await this.#validateConversationOwners();
|
||||
for (const [id, task] of this.#tasksById) {
|
||||
if (task.write?.kind !== "replace") continue;
|
||||
@@ -648,22 +720,17 @@ export class Transaction implements Tx {
|
||||
}
|
||||
|
||||
// Terminal settlement retires every task document, including ones created by this transaction.
|
||||
let terminalTaskIds: Set<TaskId> | undefined;
|
||||
const terminalTaskIds = new Set<TaskId>();
|
||||
for (const task of this.#tasksById.values()) {
|
||||
const candidate = task.write?.record;
|
||||
if (candidate?.state.status !== "terminal") continue;
|
||||
terminalTaskIds ??= new Set();
|
||||
terminalTaskIds.add(candidate.id);
|
||||
if (task.write?.record.state.status === "terminal") terminalTaskIds.add(task.write.record.id);
|
||||
}
|
||||
if (terminalTaskIds !== undefined) {
|
||||
const targetedDocumentIds = new Set<DocumentId>();
|
||||
for (const document of this.#documents) {
|
||||
const scope = document.address.scope;
|
||||
if (terminalTaskIds.size > 0) {
|
||||
const retiring = new Set<DocumentId>();
|
||||
for (const plan of plans) {
|
||||
const scope = plan.record.scope;
|
||||
if (scope.kind !== "task" || !terminalTaskIds.has(scope.taskId)) continue;
|
||||
document.retireOnCommit = true;
|
||||
const target = document.target;
|
||||
if (target?.kind === "loaded") targetedDocumentIds.add(target.document.record.id);
|
||||
if (target?.kind === "created" || target?.kind === "retire-only") targetedDocumentIds.add(target.record.id);
|
||||
plan.retire = true;
|
||||
retiring.add(plan.record.id);
|
||||
}
|
||||
for (const taskId of terminalTaskIds) {
|
||||
if (this.#tasksById.get(taskId)?.write?.kind === "create") continue;
|
||||
@@ -676,89 +743,53 @@ export class Transaction implements Tx {
|
||||
this.#context,
|
||||
);
|
||||
for (const record of page.items) {
|
||||
if (targetedDocumentIds.has(record.id)) continue;
|
||||
this.#documents.push({
|
||||
addressId: addressId(record),
|
||||
address: record,
|
||||
target: { kind: "retire-only", record },
|
||||
retireOnCommit: true,
|
||||
});
|
||||
targetedDocumentIds.add(record.id);
|
||||
if (retiring.has(record.id)) continue;
|
||||
plans.push({ addressId: addressId(record), record, retire: true });
|
||||
retiring.add(record.id);
|
||||
}
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve task-document publication ownership before Storage admission so adoption remains synchronous.
|
||||
for (const document of this.#documents) {
|
||||
const target = document.target;
|
||||
if (target === undefined) continue;
|
||||
if (target.kind === "loaded" && !document.retireOnCommit && document.prepared!.ops.length === 0) continue;
|
||||
const scope = document.address.scope;
|
||||
if (scope.kind === "conversation") document.conversationId = scope.conversationId;
|
||||
// Resolve publication ownership before Storage admission so adoption remains synchronous.
|
||||
for (const plan of plans) {
|
||||
if (!publishes(plan)) continue;
|
||||
const scope = plan.record.scope;
|
||||
if (scope.kind === "conversation") plan.conversationId = scope.conversationId;
|
||||
if (scope.kind !== "task") continue;
|
||||
const task = this.#taskEntry(scope.taskId);
|
||||
if (task.publicationConversationId === undefined) {
|
||||
const current = await this.#currentTask(scope.taskId);
|
||||
if (current !== undefined) task.publicationConversationId = current.conversationId;
|
||||
}
|
||||
document.conversationId = task.publicationConversationId;
|
||||
plan.conversationId = task.publicationConversationId;
|
||||
}
|
||||
|
||||
for (const { id, settlement } of this.#settlements) {
|
||||
const current = this.#submissions.get(id) ?? (await storage.submission(id, this.#context));
|
||||
if (current === undefined) throw new Error(`Submission ${id} does not exist`);
|
||||
const settled = settledSubmission(current, settlement);
|
||||
if (settled !== current) this.#submissions.set(id, settled);
|
||||
}
|
||||
|
||||
const writes = this.#writes;
|
||||
for (const value of this.#submissions.values()) writes.push({ type: "submission", value });
|
||||
for (const task of this.#tasksById.values()) {
|
||||
if (task.write !== undefined) writes.push({ type: "task", value: task.write.record });
|
||||
}
|
||||
for (const document of this.#documents) {
|
||||
const target = document.target;
|
||||
if (target === undefined) continue;
|
||||
switch (target.kind) {
|
||||
case "created":
|
||||
writes.push({
|
||||
type: "document.create",
|
||||
record: target.record,
|
||||
content: { version: target.version, kind: "base", value: document.prepared!.value },
|
||||
});
|
||||
if (document.retireOnCommit) writes.push({ type: "document.retire", id: target.record.id });
|
||||
break;
|
||||
case "fork-copy":
|
||||
writes.push({ type: "document.copy", record: target.record, source: target.source });
|
||||
if (document.retireOnCommit) writes.push({ type: "document.retire", id: target.record.id });
|
||||
break;
|
||||
case "loaded": {
|
||||
const definition = document.definition!;
|
||||
const prepared = document.prepared!;
|
||||
if (target.document.storedVersion < definition.version) {
|
||||
document.writtenContent = "base";
|
||||
writes.push({
|
||||
type: "document.change",
|
||||
id: target.document.record.id,
|
||||
content: { version: definition.version, kind: "base", value: prepared.value },
|
||||
});
|
||||
} else if (prepared.ops.length > 0) {
|
||||
const useBase =
|
||||
definition.checkpointWhen?.(prepared.value, prepared.ops, {
|
||||
deltasSinceBase: target.document.deltasSinceBase,
|
||||
}) ?? false;
|
||||
document.writtenContent = useBase ? "base" : "delta";
|
||||
writes.push({
|
||||
type: "document.change",
|
||||
id: target.document.record.id,
|
||||
content: useBase
|
||||
? { version: definition.version, kind: "base", value: prepared.value }
|
||||
: { version: definition.version, kind: "delta", ops: prepared.ops },
|
||||
});
|
||||
}
|
||||
if (document.retireOnCommit) {
|
||||
writes.push({ type: "document.retire", id: target.document.record.id });
|
||||
}
|
||||
break;
|
||||
for (const plan of plans) {
|
||||
const change = plan.change;
|
||||
// Checkpoint predicates run last, after every validation.
|
||||
if (plan.content?.type === "document.change" && plan.content.content.kind === "delta" && change?.loaded) {
|
||||
const { prepared, version } = change;
|
||||
const info = { deltasSinceBase: change.loaded.deltasSinceBase };
|
||||
if (change.definition?.checkpointWhen?.(prepared.value, prepared.ops, info)) {
|
||||
plan.content = { ...plan.content, content: { version, kind: "base", value: prepared.value } };
|
||||
}
|
||||
case "retire-only":
|
||||
writes.push({ type: "document.retire", id: target.record.id });
|
||||
break;
|
||||
}
|
||||
if (plan.content !== undefined) writes.push(plan.content);
|
||||
if (plan.retire) writes.push({ type: "document.retire", id: plan.record.id });
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
@@ -783,21 +814,14 @@ export class Transaction implements Tx {
|
||||
}
|
||||
}
|
||||
|
||||
#rejectForkSourceWrites(): void {
|
||||
for (const document of this.#documents) {
|
||||
const target = document.target;
|
||||
if (target === undefined) continue;
|
||||
const writes =
|
||||
target.kind !== "loaded" ||
|
||||
document.retireOnCommit ||
|
||||
target.document.storedVersion < document.definition!.version ||
|
||||
document.prepared!.ops.length > 0;
|
||||
if (!writes) continue;
|
||||
const record = target.kind === "loaded" ? target.document.record : target.record;
|
||||
#rejectForkSourceWrites(plans: readonly DocumentPlan[]): void {
|
||||
for (const plan of plans) {
|
||||
if (plan.content === undefined && !plan.retire) continue;
|
||||
const record = plan.record;
|
||||
if (this.#forkSourceDocumentIds.has(record.id)) {
|
||||
throw new Error(`Cannot change fork source document ${record.id} in the fork transaction`);
|
||||
}
|
||||
const scope = document.address.scope;
|
||||
const scope = plan.record.scope;
|
||||
if (
|
||||
scope.kind === "conversation" &&
|
||||
this.#forkSourceConversationIds.has(scope.conversationId) &&
|
||||
@@ -815,10 +839,6 @@ export class Transaction implements Tx {
|
||||
for (const document of this.#documents) document.change?.abort();
|
||||
}
|
||||
|
||||
async #drain(): Promise<void> {
|
||||
await Promise.allSettled(this.#pendingOperations);
|
||||
}
|
||||
|
||||
#assertOpen(): void {
|
||||
if (this.#sealed) throw new Error("Transaction has settled");
|
||||
}
|
||||
@@ -887,3 +907,58 @@ export class Transaction implements Tx {
|
||||
return task.committedRead;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plan of one staged document: its record, content write, and prepared change. Retirement is decided later. */
|
||||
function planDocument(document: DocumentEntry): DocumentPlan | undefined {
|
||||
const target = document.target;
|
||||
if (target === undefined) return undefined;
|
||||
const plan = { addressId: document.addressId, retire: document.retireOnCommit };
|
||||
switch (target.kind) {
|
||||
case "created": {
|
||||
const prepared = document.prepared!;
|
||||
const { record, version, tracker } = target;
|
||||
const content = { version, kind: "base", value: prepared.value } as const;
|
||||
return {
|
||||
...plan,
|
||||
record,
|
||||
content: { type: "document.create", record, content },
|
||||
change: { tracker, prepared, version },
|
||||
};
|
||||
}
|
||||
case "fork-copy":
|
||||
return {
|
||||
...plan,
|
||||
record: target.record,
|
||||
content: { type: "document.copy", record: target.record, source: target.source },
|
||||
};
|
||||
case "retire-only":
|
||||
return { ...plan, record: target.record };
|
||||
case "loaded": {
|
||||
const loaded = target.document;
|
||||
const definition = document.definition!;
|
||||
const prepared = document.prepared!;
|
||||
const version = definition.version;
|
||||
const id = loaded.record.id;
|
||||
// A version change stores a base even without operations; otherwise only a change stores a delta.
|
||||
const content =
|
||||
loaded.storedVersion < version
|
||||
? ({ type: "document.change", id, content: { version, kind: "base", value: prepared.value } } as const)
|
||||
: prepared.ops.length > 0
|
||||
? ({ type: "document.change", id, content: { version, kind: "delta", ops: prepared.ops } } as const)
|
||||
: undefined;
|
||||
const change = {
|
||||
tracker: loaded.tracker,
|
||||
prepared,
|
||||
version,
|
||||
loaded,
|
||||
definition,
|
||||
};
|
||||
return { ...plan, record: loaded.record, change, ...(content === undefined ? {} : { content }) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether adoption publishes the plan: every creation, copy, and retirement, and a loaded incarnation that changed. */
|
||||
function publishes(plan: DocumentPlan): boolean {
|
||||
return plan.retire || plan.change?.loaded === undefined || plan.change.prepared.ops.length > 0;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
Storage,
|
||||
StorageWrite,
|
||||
SubmissionId,
|
||||
SubmissionQuery,
|
||||
TaskId,
|
||||
TaskQuery,
|
||||
TaskRecord,
|
||||
@@ -353,6 +354,10 @@ export class JsonlStorage implements Storage {
|
||||
return this.store.submission(id, context);
|
||||
}
|
||||
|
||||
async scanSubmissions(query: SubmissionQuery, limit: number, cursor: Cursor | undefined, context: Context) {
|
||||
return this.store.scanSubmissions(query, limit, cursor, context);
|
||||
}
|
||||
|
||||
async submissionByRequest(conversationId: ConversationId, requestId: string, context: Context) {
|
||||
return this.store.submissionByRequest(conversationId, requestId, context);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
StorageWrite,
|
||||
StoredDocument,
|
||||
SubmissionId,
|
||||
SubmissionQuery,
|
||||
SubmissionRecord,
|
||||
TaskId,
|
||||
TaskQuery,
|
||||
@@ -33,6 +34,7 @@ import type {
|
||||
|
||||
type StoredTask = TaskRecord<JsonValue, JsonValue, JsonValue>;
|
||||
type TaskStatus = StoredTask["state"]["status"];
|
||||
type SubmissionStatus = SubmissionRecord["status"];
|
||||
type TableName = "conversation" | "entry" | "task" | "submission" | "document";
|
||||
type DocumentRevision = DocumentContent & { readonly seq: Seq };
|
||||
type StoredDocumentState = {
|
||||
@@ -79,6 +81,8 @@ type State = {
|
||||
taskIds: TaskId[];
|
||||
taskIdsByStatus: Record<TaskStatus, TaskId[]>;
|
||||
submissions: Map<SubmissionId, SubmissionRecord>;
|
||||
submissionIds: SubmissionId[];
|
||||
submissionIdsByStatus: Record<SubmissionStatus, SubmissionId[]>;
|
||||
submissionIdsByRequest: Map<ConversationId, Map<string, SubmissionId>>;
|
||||
documents: Map<DocumentId, StoredDocumentState>;
|
||||
documentAddresses: Map<string, DocumentAddressIndex>;
|
||||
@@ -227,6 +231,8 @@ export class MemoryStorage implements Storage {
|
||||
taskIds: [],
|
||||
taskIdsByStatus: { pending: [], running: [], terminal: [] },
|
||||
submissions: new Map(),
|
||||
submissionIds: [],
|
||||
submissionIdsByStatus: { queued: [], placed: [], done: [], unanswered: [] },
|
||||
submissionIdsByRequest: new Map(),
|
||||
documents: new Map(),
|
||||
documentAddresses: new Map(),
|
||||
@@ -361,6 +367,13 @@ export class MemoryStorage implements Storage {
|
||||
case "submission": {
|
||||
this.state.recordTypes.set(write.value.id, "submission");
|
||||
const previous = this.state.submissions.get(write.value.id);
|
||||
if (previous === undefined) {
|
||||
insertSorted(this.state.submissionIds, write.value.id);
|
||||
insertSorted(this.state.submissionIdsByStatus[write.value.status], write.value.id);
|
||||
} else if (previous.status !== write.value.status) {
|
||||
removeSorted(this.state.submissionIdsByStatus[previous.status], write.value.id);
|
||||
insertSorted(this.state.submissionIdsByStatus[write.value.status], write.value.id);
|
||||
}
|
||||
if (previous?.requestId !== undefined) {
|
||||
const previousRequests = this.state.submissionIdsByRequest.get(previous.conversationId);
|
||||
if (previousRequests?.get(previous.requestId) === write.value.id) {
|
||||
@@ -535,6 +548,26 @@ export class MemoryStorage implements Storage {
|
||||
return value === undefined ? undefined : clone(value);
|
||||
}
|
||||
|
||||
async scanSubmissions(
|
||||
query: SubmissionQuery,
|
||||
limit: number,
|
||||
cursor: Cursor | undefined,
|
||||
_context: Context,
|
||||
): Promise<Page<SubmissionRecord, Cursor>> {
|
||||
this.assertOpen();
|
||||
const after = cursorId(cursor);
|
||||
const ids =
|
||||
query.status === undefined ? this.state.submissionIds : this.state.submissionIdsByStatus[query.status];
|
||||
const start = after === undefined ? 0 : upperBound(ids, after);
|
||||
const values: SubmissionRecord[] = [];
|
||||
for (let index = start; index < ids.length && values.length <= limit; index++) {
|
||||
const value = this.state.submissions.get(ids[index])!;
|
||||
if (query.conversationId !== undefined && value.conversationId !== query.conversationId) continue;
|
||||
values.push(value);
|
||||
}
|
||||
return page(values, limit);
|
||||
}
|
||||
|
||||
async submissionByRequest(
|
||||
conversationId: ConversationId,
|
||||
requestId: string,
|
||||
|
||||
@@ -52,9 +52,12 @@ const INITIAL_SCHEMA: readonly string[] = [
|
||||
id INTEGER PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
request_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'placed', 'done', 'unanswered')),
|
||||
record TEXT NOT NULL CHECK (json_valid(record))
|
||||
) STRICT`,
|
||||
"CREATE INDEX submissions_by_request ON submissions (conversation_id, request_id)",
|
||||
"CREATE INDEX submissions_by_conversation ON submissions (conversation_id, id)",
|
||||
"CREATE INDEX submissions_by_status ON submissions (status, id)",
|
||||
`CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
StorageWrite,
|
||||
StoredDocument,
|
||||
SubmissionId,
|
||||
SubmissionQuery,
|
||||
SubmissionRecord,
|
||||
TaskId,
|
||||
TaskQuery,
|
||||
@@ -417,6 +418,34 @@ export class SqliteStorage implements Storage {
|
||||
return row === undefined ? undefined : parseJson<SubmissionRecord>(row.record);
|
||||
}
|
||||
|
||||
async scanSubmissions(
|
||||
query: SubmissionQuery,
|
||||
limit: number,
|
||||
cursor: Cursor | undefined,
|
||||
_context: Context,
|
||||
): Promise<Page<SubmissionRecord, Cursor>> {
|
||||
this.assertOpen();
|
||||
const clauses = ["id > ?"];
|
||||
const params: SqliteValue[] = [cursorId(cursor) ?? -1];
|
||||
if (query.conversationId !== undefined) {
|
||||
clauses.push("conversation_id = ?");
|
||||
params.push(query.conversationId);
|
||||
}
|
||||
if (query.status !== undefined) {
|
||||
clauses.push("status = ?");
|
||||
params.push(query.status);
|
||||
}
|
||||
params.push(limit + 1);
|
||||
const rows = allRows<JsonRow>(
|
||||
this.db.prepare(`SELECT record FROM submissions WHERE ${clauses.join(" AND ")} ORDER BY id LIMIT ?`),
|
||||
...params,
|
||||
);
|
||||
return page(
|
||||
rows.map((row) => parseJson<SubmissionRecord>(row.record)),
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
async submissionByRequest(
|
||||
conversationId: ConversationId,
|
||||
requestId: string,
|
||||
@@ -704,13 +733,14 @@ export class SqliteStorage implements Storage {
|
||||
case "submission":
|
||||
this.claimId(write.value.id, "submission");
|
||||
this.db
|
||||
.prepare(`INSERT INTO submissions (id, conversation_id, request_id, record) VALUES (?, ?, ?, ?)
|
||||
.prepare(`INSERT INTO submissions (id, conversation_id, request_id, status, record) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET conversation_id = excluded.conversation_id,
|
||||
request_id = excluded.request_id, record = excluded.record`)
|
||||
request_id = excluded.request_id, status = excluded.status, record = excluded.record`)
|
||||
.run(
|
||||
write.value.id,
|
||||
write.value.conversationId,
|
||||
write.value.requestId === undefined ? null : encodeIndexedString(write.value.requestId),
|
||||
write.value.status,
|
||||
encodeJson(write.value),
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Op } from "@earendil-works/chord/delta";
|
||||
import { idFromNumber } from "../ids.ts";
|
||||
import {
|
||||
type ConversationId,
|
||||
type Cursor,
|
||||
type DocumentCreate,
|
||||
type DocumentId,
|
||||
type EntryId,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
type Storage,
|
||||
type StorageWrite,
|
||||
type SubmissionId,
|
||||
type SubmissionQuery,
|
||||
type SubmissionRecord,
|
||||
type TaskId,
|
||||
type TaskRecord,
|
||||
@@ -600,6 +602,29 @@ export function createStorageConformance(options: StorageConformanceOptions): re
|
||||
await storage.commit([{ type: "submission", value: placedSecond }], context);
|
||||
expect(await storage.submission(secondId, context)).toEqual(placedSecond);
|
||||
expect(await storage.submissionByRequest(rootId, "other", context)).toEqual(placedSecond);
|
||||
|
||||
const ids = async (query: SubmissionQuery) => {
|
||||
const found: SubmissionId[] = [];
|
||||
let cursor: Cursor | undefined;
|
||||
do {
|
||||
const page = await storage.scanSubmissions(query, 1, cursor, context);
|
||||
found.push(...page.items.map(({ id }) => id));
|
||||
cursor = page.next;
|
||||
} while (cursor !== undefined);
|
||||
return found;
|
||||
};
|
||||
expect(await ids({})).toEqual([firstId, secondId, otherConversationId]);
|
||||
expect(await ids({ conversationId: rootId })).toEqual([firstId, secondId]);
|
||||
// A status change moves the record between status scans.
|
||||
expect(await ids({ status: "queued" })).toEqual([firstId, otherConversationId]);
|
||||
expect(await ids({ status: "placed" })).toEqual([secondId]);
|
||||
expect(await ids({ conversationId: secondConversationId, status: "queued" })).toEqual([
|
||||
otherConversationId,
|
||||
]);
|
||||
expect(await ids({ conversationId: secondConversationId, status: "placed" })).toEqual([]);
|
||||
expect((await storage.scanSubmissions({ status: "placed" }, 10, undefined, context)).items).toEqual([
|
||||
placedSecond,
|
||||
]);
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AttachedReplicatedState, Context, Draft, JsonValue } from "@earendil-works/chord";
|
||||
import type { Op } from "@earendil-works/chord/delta";
|
||||
import type { Message, Models } from "@earendil-works/pi-ai";
|
||||
import type { RegistrySnapshot } from "./harness/types.ts";
|
||||
import type { ContextView, RegistrySnapshot } from "./harness/types.ts";
|
||||
|
||||
/** JSON object used as the root of every durable document. */
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
@@ -147,7 +147,7 @@ export type PhaseHandler<I, P, S, R, H extends object> = (
|
||||
* Operations of one task invocation. Every operation rejects after the invocation ends; watches acquired through it
|
||||
* stop at invocation end. `_H` is the task's hook map, consumed once the runtime gains its hook runner.
|
||||
*/
|
||||
export interface TaskRuntime<I, S, R, _H extends object> extends DocumentObserver {
|
||||
export interface TaskRuntime<I, S, R, _H extends object> extends DocumentObserver, DocumentReader {
|
||||
readonly taskId: TaskId<R>;
|
||||
readonly conversationId: ConversationId;
|
||||
/** Aborted when the run is signalled by `abortTask()` or the Harness closes. */
|
||||
@@ -173,6 +173,12 @@ export interface TaskRuntime<I, S, R, _H extends object> extends DocumentObserve
|
||||
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
|
||||
/** Store `candidate` unless a memo already exists; return the durable winner. */
|
||||
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
|
||||
/** Committed raw active transcript and model context, optionally cut off at the visible entry `at`. */
|
||||
context(conversationId: ConversationId, context: Context, at?: EntryId): Promise<ContextView>;
|
||||
/** The Harness clock. */
|
||||
now(): number;
|
||||
/** Forward a non-fatal failure to `HarnessOptions.onReport`. */
|
||||
report(error: unknown): void;
|
||||
/** Resolve once the Harness clock reaches `until`; rejects when the invocation or `context` is cancelled. */
|
||||
sleep(until: number, context: Context): Promise<void>;
|
||||
}
|
||||
@@ -276,6 +282,20 @@ export type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId"
|
||||
readonly head?: EntryId | "self";
|
||||
};
|
||||
|
||||
/** Entry whose `data` has type `D`; `never` means the kind carries no data. */
|
||||
export type TypedEntry<D extends JsonValue> = Omit<EntryRecord, "data"> &
|
||||
([D] extends [never] ? { readonly data?: never } : { readonly data: D });
|
||||
|
||||
/** Entry content of a typed kind; the token supplies `kind`. */
|
||||
export type TypedEntryDraft<D extends JsonValue> = Omit<EntryDraft, "kind" | "data"> &
|
||||
([D] extends [never] ? { readonly data?: never } : { readonly data: D });
|
||||
|
||||
/** Typed entry kind with a narrowing guard. */
|
||||
export interface Entry<D extends JsonValue = never> {
|
||||
readonly kind: string;
|
||||
is(entry: EntryRecord | undefined): entry is TypedEntry<D>;
|
||||
}
|
||||
|
||||
/** Identity fields shared by every durable submission state. */
|
||||
type SubmissionRecordBase = {
|
||||
readonly id: SubmissionId;
|
||||
@@ -298,7 +318,7 @@ export type SubmissionRecord =
|
||||
readonly detail?: never;
|
||||
}
|
||||
| {
|
||||
/** Added to the transcript and owned by an active turn. */
|
||||
/** Added to the transcript and owned by an active run. */
|
||||
readonly status: "placed";
|
||||
readonly entry: EntryId;
|
||||
readonly answer?: never;
|
||||
@@ -351,6 +371,11 @@ export type SubmissionRecord =
|
||||
}
|
||||
));
|
||||
|
||||
/** Terminal status staged for a submission; identity, type, and entry come from its current record. */
|
||||
export type SubmissionSettlement =
|
||||
| { readonly status: "done"; readonly answer: EntryId }
|
||||
| { readonly status: "unanswered"; readonly reason: string; readonly detail?: JsonValue };
|
||||
|
||||
/** Submission fields supplied before the Session assigns an ID. */
|
||||
export type SubmissionCreate = SubmissionRecord extends infer Record
|
||||
? Record extends SubmissionRecord
|
||||
@@ -536,6 +561,12 @@ export type TaskQuery = {
|
||||
readonly background?: boolean;
|
||||
};
|
||||
|
||||
/** Optional filters for an ordered scan of submission records. */
|
||||
export type SubmissionQuery = {
|
||||
readonly conversationId?: ConversationId;
|
||||
readonly status?: SubmissionRecord["status"];
|
||||
};
|
||||
|
||||
/** Current state or one historical commit sequence used for document membership and content reads. */
|
||||
export type DocumentPoint = Seq | "current";
|
||||
|
||||
@@ -644,6 +675,8 @@ export type CommitPublication = {
|
||||
export interface Tx {
|
||||
conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
|
||||
entry(id: EntryId): Promise<EntryRecord | undefined>;
|
||||
/** Undefined when the entry is absent or has another kind. */
|
||||
entry<D extends JsonValue>(token: Entry<D>, id: EntryId): Promise<TypedEntry<D> | undefined>;
|
||||
task(id: TaskId): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
|
||||
scanConversations(
|
||||
query: ConversationQuery,
|
||||
@@ -667,11 +700,23 @@ export interface Tx {
|
||||
): Promise<ConversationRecord>;
|
||||
/** Returned records are Session-owned immutable values and may be shared with commit listeners. */
|
||||
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
|
||||
/** The token supplies `kind` and types `data`. */
|
||||
appendEntry<D extends JsonValue>(
|
||||
token: Entry<D>,
|
||||
conversationId: ConversationId,
|
||||
value: TypedEntryDraft<NoInfer<D>>,
|
||||
): Promise<TypedEntry<D>>;
|
||||
createTask<I, S extends { phase: string }, R, H extends object>(
|
||||
task: Task<I, S, R, H>,
|
||||
input: I,
|
||||
options?: TaskOptions,
|
||||
): Promise<TaskId<R>>;
|
||||
/**
|
||||
* Settle a queued or placed submission; only a placed input can be answered, and a settled submission stays
|
||||
* unchanged. Resolved against this transaction's latest record of the submission, so it works after table writes.
|
||||
* Run tasks settle the inputs they answer.
|
||||
*/
|
||||
settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
|
||||
|
||||
doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
|
||||
doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
|
||||
@@ -732,6 +777,9 @@ export interface WatchHandle<T> {
|
||||
|
||||
export type DocumentWatch<T extends JsonObject> = WatchHandle<Readonly<T> | null>;
|
||||
|
||||
/** Committed document reads. */
|
||||
export type DocumentReader = Pick<Session, "snapshot" | "snapshotAsOf">;
|
||||
|
||||
/** Non-creating document watch acquisition shared by Session and later invocation APIs. */
|
||||
export interface DocumentObserver {
|
||||
watchDoc<T extends JsonObject>(token: SessionDocToken<T>, context: Context): Promise<DocumentWatch<T> | undefined>;
|
||||
@@ -920,6 +968,14 @@ export interface Storage {
|
||||
/** Look up the latest complete record for one admitted submission. */
|
||||
submission(id: SubmissionId, context: Context): Promise<SubmissionRecord | undefined>;
|
||||
|
||||
/** Scan submissions matching every supplied filter in ascending ID order. */
|
||||
scanSubmissions(
|
||||
query: SubmissionQuery,
|
||||
limit: number,
|
||||
cursor: Cursor | undefined,
|
||||
context: Context,
|
||||
): Promise<Page<SubmissionRecord, Cursor>>;
|
||||
|
||||
/** Find a submission by its conversation-scoped host deduplication key. */
|
||||
submissionByRequest(
|
||||
conversationId: ConversationId,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import {
|
||||
createModels,
|
||||
type FauxProviderHandle,
|
||||
type FauxResponseStep,
|
||||
fauxProvider,
|
||||
type Message,
|
||||
type Models,
|
||||
type RegisterFauxProviderOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type Conversation,
|
||||
ConversationConfig,
|
||||
createRegistry,
|
||||
type EntryRecord,
|
||||
Harness,
|
||||
type Registry,
|
||||
type Storage,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { context } from "./session-support.ts";
|
||||
|
||||
/** Models and registry that survive a close/reopen, like a host process's own objects. */
|
||||
export type ChatSetup = {
|
||||
readonly faux: FauxProviderHandle;
|
||||
readonly models: Models;
|
||||
readonly registry: Registry;
|
||||
readonly reports: unknown[];
|
||||
now: () => number;
|
||||
};
|
||||
|
||||
export function chatSetup(options: RegisterFauxProviderOptions = {}): ChatSetup {
|
||||
const faux = fauxProvider(options);
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
return { faux, models, registry: createRegistry(), reports: [], now: () => Date.now() };
|
||||
}
|
||||
|
||||
/** Open a Harness over `storage` and return its root, configured with the faux model on first creation. */
|
||||
export async function openChat(
|
||||
storage: Storage,
|
||||
setup: ChatSetup,
|
||||
): Promise<{ readonly harness: Harness; readonly root: Conversation }> {
|
||||
const harness = await Harness.open(
|
||||
storage,
|
||||
{
|
||||
models: setup.models,
|
||||
registry: setup.registry,
|
||||
now: () => setup.now(),
|
||||
onReport: (error) => setup.reports.push(error),
|
||||
},
|
||||
context,
|
||||
);
|
||||
const root = await harness.root(context, {
|
||||
init: async (tx, id) => {
|
||||
(await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" };
|
||||
},
|
||||
});
|
||||
return { harness, root };
|
||||
}
|
||||
|
||||
/** Raw entries of a conversation, oldest first. */
|
||||
export async function allEntries(conversation: Conversation, callContext: Context = context): Promise<EntryRecord[]> {
|
||||
const page = await conversation.entries({}, 1000, undefined, callContext);
|
||||
return [...page.items].reverse();
|
||||
}
|
||||
|
||||
/** Text of the first text content of a message. */
|
||||
export function textOf(message: Message | undefined): string | undefined {
|
||||
if (message === undefined) return undefined;
|
||||
if (message.role === "system") return undefined;
|
||||
if (typeof message.content === "string") return message.content;
|
||||
const text = message.content.find((content) => content.type === "text");
|
||||
return text?.type === "text" ? text.text : undefined;
|
||||
}
|
||||
|
||||
/** Poll `check` in real time until it holds; for waits that span throttle windows and timers. */
|
||||
export async function waitFor(check: () => boolean | Promise<boolean>, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!(await check())) {
|
||||
if (Date.now() > deadline) throw new Error("Condition was not reached");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Faux response that never answers; the run stays busy until its generation is cancelled. `reached` resolves once the
|
||||
* request was sent, after the generation's preparation and request commits.
|
||||
*/
|
||||
export function unanswered(): { readonly step: FauxResponseStep; readonly reached: Promise<void> } {
|
||||
let reach!: () => void;
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
reach = resolve;
|
||||
});
|
||||
const step: FauxResponseStep = (_context, options) =>
|
||||
new Promise((_, reject) => {
|
||||
reach();
|
||||
const signal = options!.signal!;
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
});
|
||||
return { step, reached };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A Session stores conversations, transcript entries, tasks, and documents.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/00-conversation.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createSession, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
// MemoryStorage keeps everything in memory; other storage backends keep it on disk.
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
// Every Session call takes a context, which is used for cancellation.
|
||||
// BACKGROUND_CONTEXT means "never cancel".
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// All writes happen inside session.commit(). The callback receives a
|
||||
// transaction `tx`; everything it writes is saved together when the callback
|
||||
// returns, or discarded if it throws.
|
||||
// "ownerless" means no task created this conversation.
|
||||
const standalone = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
console.log("standalone conversation:", standalone);
|
||||
|
||||
await session.close(context);
|
||||
@@ -0,0 +1,45 @@
|
||||
// Store document state next to transcript entries.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/01-documents.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
// A document is a JSON object attached to something; here, one per conversation.
|
||||
// "rewindable" keeps old values readable, so you can ask what the document
|
||||
// looked like when a particular entry was written.
|
||||
// `fork` says what a forked copy of the conversation starts with (see 02-forks.ts).
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "example.notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf", // a fork starts with the value these notes had at the fork entry
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
|
||||
const chat = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
|
||||
// tx.doc() returns an editable copy of the document (created on first use).
|
||||
// Plain assignments to it are saved when the commit finishes.
|
||||
const firstEntry = await session.commit(async (tx) => {
|
||||
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "hello" });
|
||||
(await tx.doc(Notes, chat.id)).text = "after hello";
|
||||
return entry;
|
||||
}, context);
|
||||
|
||||
const secondEntry = await session.commit(async (tx) => {
|
||||
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "goodbye" });
|
||||
(await tx.doc(Notes, chat.id)).text = "after goodbye";
|
||||
return entry;
|
||||
}, context);
|
||||
|
||||
// snapshot() reads the latest value. snapshotAsOf() reads the value that was
|
||||
// saved in the same commit as the given entry.
|
||||
console.log("latest notes:", await session.snapshot(Notes, chat.id, context));
|
||||
console.log("notes at first entry:", await session.snapshotAsOf(Notes, chat.id, firstEntry.id, context));
|
||||
console.log("notes at second entry:", await session.snapshotAsOf(Notes, chat.id, secondEntry.id, context));
|
||||
|
||||
await session.close(context);
|
||||
@@ -0,0 +1,55 @@
|
||||
// Fork a conversation.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/02-forks.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "example.notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
|
||||
const chat = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
const firstEntry = await session.commit(async (tx) => {
|
||||
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "hello" });
|
||||
(await tx.doc(Notes, chat.id)).text = "after hello";
|
||||
return entry;
|
||||
}, context);
|
||||
await session.commit(async (tx) => {
|
||||
await tx.appendEntry(chat.id, { kind: "note", data: "goodbye" });
|
||||
(await tx.doc(Notes, chat.id)).text = "after goodbye";
|
||||
}, context);
|
||||
|
||||
// A fork is a new conversation that continues from one entry of another. It
|
||||
// sees the parent's transcript up to that entry, and each document follows its
|
||||
// own `fork` setting. Notes uses "asOf", so the fork starts with the notes
|
||||
// value from the fork entry.
|
||||
const branch = await session.commit(
|
||||
(tx) => tx.forkConversation(chat.id, firstEntry.id, { ownership: { kind: "ownerless" } }),
|
||||
context,
|
||||
);
|
||||
|
||||
// scanEntries() pages through visible entries, newest first. The fork sees
|
||||
// "hello" (inherited from the parent) but not "goodbye", which came later.
|
||||
const branchEntries = await session.commit((tx) => tx.scanEntries({ conversationId: branch.id }, 10), context);
|
||||
console.log(
|
||||
"fork transcript:",
|
||||
branchEntries.items.map((entry) => entry.data),
|
||||
);
|
||||
console.log("fork notes:", await session.snapshot(Notes, branch.id, context));
|
||||
|
||||
// The fork's copy is independent: editing it leaves the parent unchanged.
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Notes, branch.id)).text = "changed only in the fork";
|
||||
}, context);
|
||||
console.log("fork notes after edit:", await session.snapshot(Notes, branch.id, context));
|
||||
console.log("parent notes after edit:", await session.snapshot(Notes, chat.id, context));
|
||||
|
||||
await session.close(context);
|
||||
@@ -0,0 +1,65 @@
|
||||
// A background task that owns a child conversation.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/03-owned-conversations.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { type ConversationId, createSession, defineDoc, defineTask, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
// A typical agent setup: a background task supervises a helper conversation,
|
||||
// and the main conversation keeps a registry that maps agent names to their
|
||||
// conversations. All three are created in one commit, so after a crash either
|
||||
// all of them exist or none do.
|
||||
|
||||
// A task definition needs a name, a version, the task's starting state, a
|
||||
// handler for every phase, and an abort handler (12-tasks.ts runs a task).
|
||||
// This example only creates the task record; a plain Session never runs it.
|
||||
const Supervisor = defineTask<null, { phase: "ready" }, null>({
|
||||
name: "example.supervisor",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "ready" }),
|
||||
phases: { ready: async () => {} },
|
||||
abort: async () => {},
|
||||
});
|
||||
|
||||
// "latest" keeps only the current value. "initial" means forks of this
|
||||
// conversation start without a registry, so a child doesn't inherit its
|
||||
// parent's list of agents.
|
||||
const AgentRegistry = defineDoc<{
|
||||
agents: Record<string, { conversationId: ConversationId; requestId: string }>;
|
||||
}>({
|
||||
kind: "example.agent-registry",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ agents: {} }),
|
||||
});
|
||||
|
||||
const main = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
|
||||
const setup = await session.commit(async (tx) => {
|
||||
// `background: true` means the task is side work: waiting for the main
|
||||
// conversation to finish does not wait for it.
|
||||
const supervisorId = await tx.createTask(Supervisor, null, { conversationId: main.id, background: true });
|
||||
|
||||
// The child records that it belongs to the supervisor task. The task was
|
||||
// created a few lines above in this same commit, which is allowed.
|
||||
const child = await tx.createConversation({ ownership: { kind: "task", taskId: supervisorId } });
|
||||
|
||||
// requestId is a fixed name for the child's first message. Later code sends
|
||||
// that message using this requestId, so a retry after a crash cannot
|
||||
// deliver it twice.
|
||||
(await tx.doc(AgentRegistry, main.id)).agents.researcher = {
|
||||
conversationId: child.id,
|
||||
requestId: `researcher:first-message:${supervisorId}`,
|
||||
};
|
||||
return { supervisorId, child };
|
||||
}, context);
|
||||
|
||||
console.log("supervisor task:", setup.supervisorId);
|
||||
console.log("child conversation:", setup.child);
|
||||
console.log("registry:", await session.snapshot(AgentRegistry, main.id, context));
|
||||
|
||||
await session.close(context);
|
||||
@@ -0,0 +1,37 @@
|
||||
// Expose a document through Chord.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/04-chord-state.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "example.notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
const chat = await session.commit(async (tx) => {
|
||||
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
||||
(await tx.doc(Notes, conversation.id)).text = "first";
|
||||
return conversation;
|
||||
}, context);
|
||||
|
||||
// documentState() never creates a document. It returns a hydrated read-only
|
||||
// Chord state bound to the current concrete incarnation.
|
||||
const notesState = await session.documentState(Notes, chat.id, context);
|
||||
if (notesState === undefined) throw new Error("notes are absent");
|
||||
const stopNotes = notesState.subscribe((value, _deliveryContext, delivery) => {
|
||||
console.log("Chord notes:", delivery.kind, delivery.sequence, value);
|
||||
});
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Notes, chat.id)).text = "published through Chord";
|
||||
}, context);
|
||||
stopNotes();
|
||||
notesState.dispose();
|
||||
|
||||
await session.close(context);
|
||||
@@ -0,0 +1,41 @@
|
||||
// Serialize asynchronous document work with a watch.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/05-watches.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "example.notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
const chat = await session.commit(async (tx) => {
|
||||
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
||||
(await tx.doc(Notes, conversation.id)).text = "first";
|
||||
return conversation;
|
||||
}, context);
|
||||
|
||||
// A watch starts from one stable acquisition revision. Slow callbacks never
|
||||
// overlap; exact committed frames buffer, with a full-value reset after 100.
|
||||
const notesWatch = await session.watchDoc(Notes, chat.id, context);
|
||||
if (notesWatch === undefined) throw new Error("notes are absent");
|
||||
console.log("watch baseline:", notesWatch.value);
|
||||
const delivered = new Promise<void>((resolve) => {
|
||||
notesWatch.start(async (value, _ops, _deliveryContext) => {
|
||||
console.log("watch update:", value);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Notes, chat.id)).text = "observed asynchronously";
|
||||
}, context);
|
||||
await delivered;
|
||||
await notesWatch.stop();
|
||||
|
||||
await session.close(context);
|
||||
@@ -0,0 +1,54 @@
|
||||
// Open a Harness with a registry.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/06-harness.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels, Type } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
ConversationConfig,
|
||||
createRegistry,
|
||||
defineDoc,
|
||||
Harness,
|
||||
MemoryStorage,
|
||||
type ToolRegistration,
|
||||
} from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// A Harness is a Session plus conversation handles and durable tasks. Extension
|
||||
// code (tools, hooks, tasks, system prompt sections, conversation setups) lives
|
||||
// in a registry the application owns. Nothing in the registry is saved; it is
|
||||
// this process's code.
|
||||
const registry = createRegistry();
|
||||
const read: ToolRegistration = {
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
parameters: Type.Object({ path: Type.String() }),
|
||||
execute: async (args) => ({ content: [{ type: "text", text: `read ${JSON.stringify(args)}` }] }),
|
||||
};
|
||||
registry.tools.add(read);
|
||||
|
||||
// `models` is pi-ai's model access; generation calls models through it.
|
||||
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
|
||||
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "example.notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
|
||||
// The root conversation always has ID 1. The first root() call creates it,
|
||||
// its built-in documents, and whatever `init` writes, all in one commit. Later
|
||||
// calls, including after a restart, return it and ignore `init`.
|
||||
const root = await harness.root(context, {
|
||||
init: async (tx, rootId) => {
|
||||
(await tx.doc(Notes, rootId)).text = "root notes";
|
||||
(await tx.doc(ConversationConfig, rootId)).thinkingLevel = "low";
|
||||
},
|
||||
});
|
||||
console.log("root:", root.id, await harness.snapshot(Notes, root.id, context));
|
||||
console.log("root config:", await harness.snapshot(ConversationConfig, root.id, context));
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,59 @@
|
||||
// Conversation configuration: model, thinking level, and active tools.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/07-configuration.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels, Type } from "@earendil-works/pi-ai";
|
||||
import { createRegistry, Harness, MemoryStorage, type ToolRegistration } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// Apps may attach their own metadata to tools, such as a prompt snippet.
|
||||
type AppTool = ToolRegistration & { readonly snippet?: string };
|
||||
|
||||
function exampleTool(name: string, description: string): AppTool {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parameters: Type.Object({ path: Type.String() }),
|
||||
snippet: `Use ${name} for files.`,
|
||||
execute: async (args) => ({ content: [{ type: "text", text: `${name} ${JSON.stringify(args)}` }] }),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = createRegistry<AppTool>();
|
||||
registry.tools.add(exampleTool("read", "Read a file"));
|
||||
const writeRegistration = registry.tools.add(exampleTool("write", "Write a file"));
|
||||
registry.tools.add(exampleTool("grep", "Search files"));
|
||||
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
|
||||
const root = await harness.root(context);
|
||||
|
||||
// Model, thinking level, and active tool names live in the built-in
|
||||
// ConversationConfig document. New conversations start with every registered
|
||||
// tool active. Each setter is one commit.
|
||||
console.log("active tools:", await root.getActiveTools(context));
|
||||
await root.setModel({ provider: "anthropic", modelId: "claude-sonnet-4-5" }, context);
|
||||
await root.setThinkingLevel("high", context);
|
||||
await root.setActiveTools(["write", "read"], context);
|
||||
console.log("snippet kept on the app tool:", registry.tools.list()[0]!.snippet);
|
||||
console.log("model:", await root.getModel(context), "thinking:", await root.getThinkingLevel(context));
|
||||
|
||||
// Adding a name that is not registered is rejected, and nothing is written.
|
||||
await root.setActiveTools(["read", "find"], context).catch((error: Error) => console.log("rejected:", error.message));
|
||||
|
||||
// Names that were already active are never rechecked. After "write" is
|
||||
// unregistered it stays in the configuration; requests just stop offering it
|
||||
// until it is registered again.
|
||||
writeRegistration.dispose();
|
||||
await root.setActiveTools(["write", "read", "grep"], context);
|
||||
console.log("active tools without a registered write:", await root.getActiveTools(context));
|
||||
console.log(
|
||||
"registered tools:",
|
||||
registry.tools.list().map((entry) => entry.name),
|
||||
);
|
||||
|
||||
// Request options and the durable retry policy are configuration too.
|
||||
await root.setStreamOptions({ timeoutMs: 60_000 }, context);
|
||||
console.log("stream options:", await root.getStreamOptions(context));
|
||||
console.log("retry policy:", await root.getRetryPolicy(context));
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Conversation handles, typed entries, created conversations, and forks.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/08-harness-conversations.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import { ConversationConfig, createRegistry, defineEntry, Harness, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const harness = await Harness.open(
|
||||
new MemoryStorage(),
|
||||
{ models: createModels(), registry: createRegistry() },
|
||||
context,
|
||||
);
|
||||
const root = await harness.root(context);
|
||||
await root.setThinkingLevel("high", context);
|
||||
|
||||
// Conversation handles are stateless; compare them by id. They bind commits
|
||||
// to their conversation. An entry token types an entry kind's `data`.
|
||||
const Message = defineEntry<{ from: string }>("message");
|
||||
const hello = await root.commit(
|
||||
(tx) =>
|
||||
tx.appendEntry(Message, root.id, {
|
||||
data: { from: "example" },
|
||||
model: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
}),
|
||||
context,
|
||||
);
|
||||
console.log("typed entry:", Message.is(hello), hello.data.from);
|
||||
|
||||
// createConversation() and fork() run `init` in the creating commit. A fork
|
||||
// starts with the configuration the parent had at the fork entry.
|
||||
const helper = await harness.createConversation(
|
||||
{
|
||||
ownership: { kind: "ownerless" },
|
||||
init: async (tx, id) => {
|
||||
(await tx.doc(ConversationConfig, id)).thinkingLevel = "minimal";
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
const retry = await root.fork(hello.id, { ownership: { kind: "ownerless" } }, context);
|
||||
console.log("helper thinking:", await helper.getThinkingLevel(context));
|
||||
console.log("fork thinking:", await retry.getThinkingLevel(context));
|
||||
console.log("lookup:", (await harness.conversation(retry.id, context))?.id === retry.id);
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,141 @@
|
||||
// Transcript history and model context.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/09-context.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createModels,
|
||||
type Message,
|
||||
type StopReason,
|
||||
type ToolResultMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const harness = await Harness.open(
|
||||
new MemoryStorage(),
|
||||
{ models: createModels(), registry: createRegistry() },
|
||||
context,
|
||||
);
|
||||
|
||||
// Entries are immutable. `model` holds the messages an entry contributes to
|
||||
// the next model request; `data` is for the app only. context() turns the
|
||||
// stored transcript into those request messages:
|
||||
// - an entry with `head` starts a new context; older entries stay stored,
|
||||
// - `edits` replace or omit what an earlier entry contributes,
|
||||
// - aborted, error, and deferred assistant messages are not sent,
|
||||
// - tool results are sent right after their call, in call order,
|
||||
// - a call without a result gets a synthesized error result.
|
||||
function assistantMessage(text: string, calls: readonly string[] = [], stopReason?: StopReason): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text },
|
||||
...calls.map((id) => ({ type: "toolCall" as const, id, name: "read", arguments: {} })),
|
||||
],
|
||||
api: "example",
|
||||
provider: "example",
|
||||
model: "example",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: stopReason ?? (calls.length > 0 ? "toolUse" : "stop"),
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessage(id: string): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: id,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: `file ${id}` }],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function show(message: Message): string {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return `user: ${message.content as string}`;
|
||||
case "system":
|
||||
return `system: ${JSON.stringify(message.sections)}`;
|
||||
case "assistant":
|
||||
return `assistant: ${message.content
|
||||
.map((part) => (part.type === "text" ? part.text : part.type === "toolCall" ? `call(${part.id})` : ""))
|
||||
.join(" ")}`;
|
||||
case "toolResult":
|
||||
return `result(${message.toolCallId})${message.isError ? " error" : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
const transcript = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
||||
const say = (kind: string, ...model: Message[]) =>
|
||||
transcript.commit((tx) => tx.appendEntry(transcript.id, { kind, model }), context);
|
||||
|
||||
const question = await say("message", { role: "user", content: "read a and b", timestamp: 1 });
|
||||
await say("message", assistantMessage("I crashed", [], "aborted")); // stored, never sent
|
||||
const calls = await say("message", assistantMessage("reading", ["a", "b"]));
|
||||
await say("message", toolResultMessage("b")); // results finish out of order
|
||||
await say("pi.system", { role: "system", content: "", sections: { cwd: "<cwd>/repo</cwd>" }, timestamp: 4 });
|
||||
await say("message", toolResultMessage("a"));
|
||||
await say("message", assistantMessage("a and b look fine"));
|
||||
await transcript.commit(
|
||||
(tx) =>
|
||||
tx.appendEntry(transcript.id, {
|
||||
kind: "edit",
|
||||
data: "user fixed a typo",
|
||||
edits: [
|
||||
{
|
||||
target: question.id,
|
||||
action: "replace",
|
||||
messages: [{ role: "user", content: "read files a and b", timestamp: 1 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
context,
|
||||
);
|
||||
await transcript.commit((tx) => tx.appendEntry(transcript.id, { kind: "note", data: "display only" }), context);
|
||||
|
||||
let view = await transcript.context(context);
|
||||
console.log(
|
||||
"raw active entries:",
|
||||
view.entries.map((entry) => entry.kind),
|
||||
);
|
||||
console.log("request messages:", view.messages.map(show));
|
||||
|
||||
// A fork at the tool call has no results yet; context() fills them in.
|
||||
const cut = await transcript.fork(calls.id, { ownership: { kind: "ownerless" } }, context);
|
||||
console.log("fork messages:", (await cut.context(context)).messages.map(show));
|
||||
|
||||
// A headed summary replaces everything before the entry it points at.
|
||||
// "self" points the head at the summary entry itself.
|
||||
await transcript.commit(
|
||||
(tx) =>
|
||||
tx.appendEntry(transcript.id, {
|
||||
kind: "summary",
|
||||
head: "self",
|
||||
model: [{ role: "user", content: "Summary: a and b are fine.", timestamp: 5 }],
|
||||
}),
|
||||
context,
|
||||
);
|
||||
view = await transcript.context(context);
|
||||
console.log("after summary:", view.head?.kind, view.messages.map(show));
|
||||
|
||||
// entries() pages the stored transcript, newest first, including inherited
|
||||
// parent entries. Nothing is ever deleted by heads or edits.
|
||||
const history = await transcript.entries({}, 3, undefined, context);
|
||||
console.log(
|
||||
"newest stored entries:",
|
||||
history.items.map((entry) => entry.kind),
|
||||
"more:",
|
||||
history.next !== undefined,
|
||||
);
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Reload extension code through the registry.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/10-registry-reload.ts
|
||||
import { Type } from "@earendil-works/pi-ai";
|
||||
import { createRegistry, type ToolRegistration } from "../../src/index.ts";
|
||||
|
||||
function exampleTool(name: string, description: string): ToolRegistration {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parameters: Type.Object({ path: Type.String() }),
|
||||
execute: async (args) => ({ content: [{ type: "text", text: `${name} ${JSON.stringify(args)}` }] }),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = createRegistry();
|
||||
registry.tools.add(exampleTool("read", "Read a file"));
|
||||
const grep = registry.tools.add(exampleTool("grep", "Search files"));
|
||||
|
||||
// batch() publishes a replacement at once, so no snapshot ever sees the tool
|
||||
// missing. Work that already started keeps using the snapshot it took.
|
||||
registry.batch(() => {
|
||||
grep.dispose();
|
||||
registry.tools.add(exampleTool("grep", "Search files, faster"));
|
||||
});
|
||||
console.log(
|
||||
"tools after reload:",
|
||||
registry.tools.list().map((entry) => `${entry.name}: ${entry.description}`),
|
||||
);
|
||||
|
||||
// Wrappers decorate a tool without replacing it, and survive its reload.
|
||||
registry.tools.wrap("read", "audit", (tool) => ({ ...tool, description: `${tool.description} (audited)` }));
|
||||
console.log("wrapped read:", registry.snapshot().tool("read")?.description);
|
||||
@@ -0,0 +1,47 @@
|
||||
// Give every new conversation the application's own documents.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/11-conversation-setup.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import { createRegistry, defineDoc, Harness, LiveDoc, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// A coding agent keeps a profile per conversation. Readers and watchers want it
|
||||
// to exist from the first moment, like the built-in documents.
|
||||
const AgentProfile = defineDoc<{ role: "main" | "subagent"; cwd: string }>({
|
||||
kind: "example.agent-profile",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ role: "main", cwd: process.cwd() }),
|
||||
});
|
||||
|
||||
// A conversation setup runs in every commit that creates or forks a
|
||||
// conversation, including raw tx.createConversation() in a tool, after the
|
||||
// built-in `pi` setup and before the host's `init`. A fork already has its
|
||||
// copied documents, so this setup leaves them alone.
|
||||
const registry = createRegistry();
|
||||
registry.conversations.setup("agent", async (tx, conversation) => {
|
||||
if (conversation.parent === undefined) await tx.doc(AgentProfile, conversation.id);
|
||||
});
|
||||
|
||||
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
|
||||
const root = await harness.root(context);
|
||||
const subagent = await harness.createConversation(
|
||||
{
|
||||
ownership: { kind: "ownerless" },
|
||||
// `init` runs after every setup, so the profile already exists here.
|
||||
init: async (tx, id) => {
|
||||
(await tx.doc(AgentProfile, id)).role = "subagent";
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
console.log("root profile:", await harness.snapshot(AgentProfile, root.id, context));
|
||||
console.log("subagent profile:", await harness.snapshot(AgentProfile, subagent.id, context));
|
||||
// The built-in setup created pi.live, so a UI can watch it before the first turn.
|
||||
console.log("root live state:", await harness.snapshot(LiveDoc, root.id, context));
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,58 @@
|
||||
// Run a durable task.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/12-tasks.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import { createRegistry, defineTask, Harness, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// A task is a small state machine. Its state, the checkpoint, is saved after
|
||||
// every step, so after a crash the next open continues from the last saved
|
||||
// step. The usual pattern: save what you are about to do, do it, then save
|
||||
// the result. A crash between doing and saving reruns that step, so the step
|
||||
// must be safe to repeat; here the fake payment service ignores a repeated key.
|
||||
const payments = new Map<string, number>();
|
||||
type PaymentState = { phase: "prepare" } | { phase: "charge"; key: string };
|
||||
const Payment = defineTask<{ amount: number }, PaymentState, { receipt: number }>({
|
||||
name: "example.payment",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "prepare" }),
|
||||
// One handler per phase. Each must save progress through runtime.commit():
|
||||
// its callback returns the next checkpoint or the final outcome, and that
|
||||
// state is saved in the same commit as everything else the callback wrote.
|
||||
phases: {
|
||||
prepare: async (task, runtime, taskContext) => {
|
||||
await runtime.commit(
|
||||
() => ({ status: "running", checkpoint: { phase: "charge", key: `payment-${task.id}` } }),
|
||||
taskContext,
|
||||
);
|
||||
},
|
||||
charge: async (task, runtime, taskContext) => {
|
||||
const key = task.state.checkpoint.key;
|
||||
if (!payments.has(key)) payments.set(key, task.input.amount * 100);
|
||||
const receipt = payments.get(key)!;
|
||||
await runtime.commit(
|
||||
() => ({ status: "terminal", outcome: { status: "completed", result: { receipt } } }),
|
||||
taskContext,
|
||||
);
|
||||
},
|
||||
},
|
||||
// Runs instead of the phases after harness.abortTask(); it decides the outcome.
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
|
||||
},
|
||||
});
|
||||
|
||||
// The Harness finds task code by name in the registry. Nothing runs until
|
||||
// resume() or a call that waits for progress, such as waitForTask().
|
||||
const registry = createRegistry();
|
||||
registry.tasks.add(Payment);
|
||||
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
|
||||
const root = await harness.root(context);
|
||||
const paymentId = await root.commit((tx) => tx.createTask(Payment, { amount: 5 }), context);
|
||||
// The finished task record is the durable receipt; waitForTask() knows its result type.
|
||||
const paid = await harness.waitForTask(paymentId, context);
|
||||
console.log("payment outcome:", paid.state.outcome);
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,87 @@
|
||||
// Close, reopen, and continue where a task stopped.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/13-recovery.ts
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import { createRegistry, defineTask, Harness } from "../../src/index.ts";
|
||||
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// Everything a task needs to continue is in storage, so a new Harness over the
|
||||
// same storage picks up where the last one stopped. This example keeps its
|
||||
// storage in a SQLite file so it survives closing.
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-durable-example-"));
|
||||
const databasePath = join(directory, "session.sqlite");
|
||||
|
||||
let reachedTick = (_n: number): void => {};
|
||||
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({
|
||||
name: "example.ticker",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "tick", n: 1 }),
|
||||
phases: {
|
||||
tick: async (task, runtime, taskContext) => {
|
||||
const n = task.state.checkpoint.n;
|
||||
// Save the intent before the effect. A memo keeps the first value
|
||||
// written under its name, so if the process dies after printing but
|
||||
// before the next checkpoint is saved, the rerun sees the memo and
|
||||
// does not print the same tick twice.
|
||||
if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) {
|
||||
await runtime.memo(`printed-${n}`, true, taskContext);
|
||||
console.log(`tick ${n}`);
|
||||
}
|
||||
reachedTick(n);
|
||||
// Save the outcome: the next tick, or the final result.
|
||||
await runtime.commit(
|
||||
() =>
|
||||
n === task.input.to
|
||||
? { status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }
|
||||
: { status: "running", checkpoint: { phase: "tick", n: n + 1 } },
|
||||
taskContext,
|
||||
);
|
||||
// Wait a little between ticks. Closing the Harness cancels this wait;
|
||||
// the checkpoint saved above is where the next Harness continues.
|
||||
await runtime.sleep(Date.now() + 50, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
|
||||
},
|
||||
});
|
||||
const registry = createRegistry();
|
||||
registry.tasks.add(Ticker);
|
||||
const open = async () =>
|
||||
Harness.open(await openNodeSqliteStorage(databasePath), { models: createModels(), registry }, context);
|
||||
|
||||
// First run: start counting to 5, and close the Harness right after tick 2 is
|
||||
// printed, before its next checkpoint is saved. That is the same situation as
|
||||
// a crash between the effect and saving its outcome.
|
||||
const firstRun = await open();
|
||||
const tickerId = await (await firstRun.root(context)).commit((tx) => tx.createTask(Ticker, { to: 5 }), context);
|
||||
const tickTwo = new Promise<void>((resolve) => {
|
||||
reachedTick = (n) => {
|
||||
if (n === 2) resolve();
|
||||
};
|
||||
});
|
||||
firstRun.resume();
|
||||
await tickTwo;
|
||||
await firstRun.close(context);
|
||||
reachedTick = () => {};
|
||||
|
||||
// Read the saved record through a Harness that never resumes, so nothing runs.
|
||||
const reader = await open();
|
||||
const saved = (await reader.getTask(tickerId, context))!;
|
||||
await reader.close(context);
|
||||
console.log("closed; saved checkpoint:", saved.state, "memos:", saved.memos);
|
||||
|
||||
// Second run: nothing to restart by hand. Waiting for the unfinished task
|
||||
// enables scheduling and continues it. Tick 2 runs again because its outcome
|
||||
// was never saved, but its memo says it was already printed.
|
||||
const secondRun = await open();
|
||||
const counted = await secondRun.waitForTask(tickerId, context);
|
||||
console.log("after reopen:", counted.state.outcome);
|
||||
await secondRun.close(context);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
@@ -0,0 +1,43 @@
|
||||
// A chat turn.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/14-chat.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { AssistantEntry, ConversationConfig, createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// The faux provider stands in for a real model (see 16-real-model.ts).
|
||||
const faux = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses([fauxAssistantMessage("Paris.")]);
|
||||
|
||||
const registry = createRegistry();
|
||||
registry.systemPrompt.section("preamble", () => "You answer in one word.", { tag: false });
|
||||
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
|
||||
const root = await harness.root(context, {
|
||||
init: async (tx, id) => {
|
||||
(await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" };
|
||||
},
|
||||
});
|
||||
|
||||
// submit() durably admits user input and returns a Submission. The built-in
|
||||
// pi.generation task prepares the system prompt from the registry's sections,
|
||||
// calls the model, and appends the answer; wait() resolves once the input is
|
||||
// answered or has failed.
|
||||
const capital = await root.submit({ type: "input", content: "Capital of France?" }, context);
|
||||
const answered = await capital.wait(context);
|
||||
if (answered.status === "done" && answered.type === "input") {
|
||||
const entry = await root.commit((tx) => tx.entry(AssistantEntry, answered.answer), context);
|
||||
const reply = entry?.model?.[0];
|
||||
console.log("answer:", reply?.role === "assistant" ? reply.content : reply);
|
||||
}
|
||||
|
||||
// The transcript holds the user input, the positional system prompt, and the answer.
|
||||
const transcript = await root.entries({}, 10, undefined, context);
|
||||
console.log(
|
||||
"transcript:",
|
||||
[...transcript.items].reverse().map((entry) => entry.kind),
|
||||
);
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,88 @@
|
||||
// System prompt sections.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/examples/15-system-prompt.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type Conversation,
|
||||
ConversationConfig,
|
||||
type ConversationInit,
|
||||
createRegistry,
|
||||
defineDoc,
|
||||
Harness,
|
||||
MemoryStorage,
|
||||
SystemEntry,
|
||||
} from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
const faux = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses([fauxAssistantMessage("Done."), fauxAssistantMessage("Done."), fauxAssistantMessage("Done.")]);
|
||||
|
||||
// Pico stores no prompt state. Before each model request, the registry's
|
||||
// sections render the desired prompt, and only the difference to what the
|
||||
// model already saw is appended to the transcript as a `pi.system` entry.
|
||||
// Sections read per-conversation data through `input.read`; here a coding
|
||||
// agent keeps its own profile document.
|
||||
const AgentProfile = defineDoc<{ role: "main" | "subagent"; cwd: string }>({
|
||||
kind: "example.agent-profile",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ role: "main", cwd: "/repo" }),
|
||||
});
|
||||
|
||||
const registry = createRegistry();
|
||||
// `tag: false` sends the text as is; by default it is wrapped in <key>...</key>.
|
||||
registry.systemPrompt.section("preamble", () => "You are a coding agent.", { tag: false });
|
||||
registry.systemPrompt.section("cwd", async (input, renderContext) => {
|
||||
return (await input.read.snapshot(AgentProfile, input.conversationId, renderContext))?.cwd;
|
||||
});
|
||||
// Returning undefined omits the section, here for subagents.
|
||||
registry.systemPrompt.section("agents_md", async (input, renderContext) => {
|
||||
const profile = await input.read.snapshot(AgentProfile, input.conversationId, renderContext);
|
||||
return profile?.role === "subagent" ? undefined : "Run npm run check after changes.";
|
||||
});
|
||||
// Another extension decorates a section without replacing it.
|
||||
registry.systemPrompt.wrap("preamble", "tone", (section) => ({
|
||||
...section,
|
||||
render: async (input, renderContext) => `${await section.render(input, renderContext)} Be terse.`,
|
||||
}));
|
||||
|
||||
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
|
||||
const withModel: ConversationInit = async (tx, id) => {
|
||||
(await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" };
|
||||
await tx.doc(AgentProfile, id);
|
||||
};
|
||||
const root = await harness.root(context, { init: withModel });
|
||||
const subagent = await harness.createConversation(
|
||||
{
|
||||
ownership: { kind: "ownerless" },
|
||||
init: async (tx, id) => {
|
||||
await withModel(tx, id);
|
||||
(await tx.doc(AgentProfile, id)).role = "subagent";
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
|
||||
async function systemEntries(conversation: Conversation) {
|
||||
const page = await conversation.entries({}, 20, undefined, context);
|
||||
return [...page.items].reverse().flatMap((entry) => (SystemEntry.is(entry) ? (entry.model ?? []) : []));
|
||||
}
|
||||
|
||||
await (await root.submit({ type: "input", content: "Fix the build." }, context)).wait(context);
|
||||
await (await subagent.submit({ type: "input", content: "Read the logs." }, context)).wait(context);
|
||||
console.log("root system prompt:", await systemEntries(root));
|
||||
console.log("subagent system prompt:", await systemEntries(subagent));
|
||||
|
||||
// When a section's output changes, the next request appends only the change.
|
||||
await root.commit(async (tx) => {
|
||||
(await tx.doc(AgentProfile, root.id)).cwd = "/repo/packages";
|
||||
}, context);
|
||||
await (await root.submit({ type: "input", content: "Now the package." }, context)).wait(context);
|
||||
console.log("root system entries after cwd change:", await systemEntries(root));
|
||||
|
||||
await harness.close(context);
|
||||
@@ -0,0 +1,56 @@
|
||||
// A real model: stream an answer from OpenAI.
|
||||
// Run from packages/durable (needs OPENAI_API_KEY):
|
||||
// node --conditions=source --experimental-strip-types test/examples/16-real-model.ts
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
||||
import { AssistantEntry, createRegistry, Harness, LiveDoc, MemoryStorage } from "../../src/index.ts";
|
||||
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// Production code passes a Models collection with real providers; the Harness
|
||||
// never talks to a provider any other way. openaiProvider() reads
|
||||
// OPENAI_API_KEY from the environment. While the answer streams, generation
|
||||
// commits throttled partials to the conversation's pi.live document. Watching
|
||||
// that document streams the answer; the watch sees only committed values.
|
||||
if (process.env.OPENAI_API_KEY === undefined) {
|
||||
console.log("skipped: OPENAI_API_KEY is not set");
|
||||
} else {
|
||||
const models = createModels();
|
||||
models.setProvider(openaiProvider());
|
||||
const registry = createRegistry();
|
||||
registry.systemPrompt.section("preamble", () => "You are a concise assistant.", { tag: false });
|
||||
const harness = await Harness.open(new MemoryStorage(), { models: models, registry: registry }, context);
|
||||
const root = await harness.root(context);
|
||||
await root.setModel({ provider: "openai", modelId: "gpt-6-sol" }, context);
|
||||
await root.setThinkingLevel("high", context);
|
||||
const liveWatch = (await harness.watchDoc(LiveDoc, root.id, context))!;
|
||||
// Print only what each committed partial adds to the text printed so far.
|
||||
let printed = "";
|
||||
const printText = (text: string): void => {
|
||||
if (text.length <= printed.length || !text.startsWith(printed)) return;
|
||||
process.stdout.write(text.slice(printed.length));
|
||||
printed = text;
|
||||
};
|
||||
liveWatch.start(async (value) => {
|
||||
const block = value?.generation?.message?.content.find((content) => content.type === "text");
|
||||
if (block?.type === "text") printText(block.text);
|
||||
});
|
||||
harness.resume();
|
||||
process.stdout.write("answer: ");
|
||||
const poem = await root.submit({ type: "input", content: "Write a long poem" }, context);
|
||||
const settledPoem = await poem.wait(context);
|
||||
await liveWatch.stop();
|
||||
if (settledPoem.status === "done" && settledPoem.type === "input") {
|
||||
// The last throttle window may not have been committed as a partial; the answer entry has the rest.
|
||||
const entry = await root.commit((tx) => tx.entry(AssistantEntry, settledPoem.answer), context);
|
||||
const message = entry?.model?.[0];
|
||||
const block =
|
||||
message?.role === "assistant" ? message.content.find((content) => content.type === "text") : undefined;
|
||||
if (block?.type === "text") printText(block.text);
|
||||
process.stdout.write("\n");
|
||||
} else {
|
||||
console.log("unanswered:", settledPoem.reason, settledPoem.detail);
|
||||
}
|
||||
await harness.close(context);
|
||||
}
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
type Conversation,
|
||||
ConversationConfig,
|
||||
type ConversationId,
|
||||
createRegistry,
|
||||
createSession,
|
||||
defineDoc,
|
||||
defineEntry,
|
||||
defineTask,
|
||||
type EntryRecord,
|
||||
LiveDoc,
|
||||
MemoryStorage,
|
||||
ROOT_CONVERSATION_ID,
|
||||
type TaskId,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
|
||||
@@ -39,7 +43,7 @@ const NoteDoc = defineDoc<{ text: string }>({
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
|
||||
const Message = defineEntry<EntryRecord & { readonly kind: "message" }>("message");
|
||||
const Message = defineEntry("message");
|
||||
|
||||
async function append(conversation: Conversation, text: string): Promise<EntryRecord> {
|
||||
return conversation.commit(
|
||||
@@ -72,11 +76,14 @@ describe("Harness root and conversations", () => {
|
||||
});
|
||||
expect(root.id).toBe(ROOT_CONVERSATION_ID);
|
||||
expect(storage.commits).toHaveLength(1);
|
||||
// Conversation, pi.live, configuration, and the init note.
|
||||
expect(storage.commits[0]!.map((write) => write.type)).toEqual([
|
||||
"conversation",
|
||||
"document.create",
|
||||
"document.create",
|
||||
"document.create",
|
||||
]);
|
||||
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
|
||||
expect(await root.getActiveTools(context)).toEqual(["read", "bash"]);
|
||||
expect(await root.getThinkingLevel(context)).toBe("high");
|
||||
expect(await harness.snapshot(NoteDoc, root.id, context)).toEqual({ text: "root note" });
|
||||
@@ -342,14 +349,101 @@ describe("Harness configuration", () => {
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reads initial configuration for conversations created without one", async () => {
|
||||
const storage = new ControlledStorage();
|
||||
const { harness } = await openHarness(storage, ["read"]);
|
||||
it("stages the built-in documents for conversations created or forked through Tx", async () => {
|
||||
const { harness } = await openHarness(new MemoryStorage(), ["read"]);
|
||||
const id = await harness.commit(
|
||||
async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id,
|
||||
context,
|
||||
);
|
||||
const created = (await harness.conversation(id, context))!;
|
||||
expect(await created.getActiveTools(context)).toEqual(["read"]);
|
||||
expect(await harness.snapshot(LiveDoc, id, context)).toEqual({});
|
||||
|
||||
await created.setActiveTools([], context);
|
||||
const at = await created.commit(
|
||||
async (tx) => (await tx.appendEntry(id, { kind: "message", model: [user("again")] })).id,
|
||||
context,
|
||||
);
|
||||
await created.commit(async (tx) => {
|
||||
(await tx.doc(LiveDoc, id)).run = { taskId: 99 as TaskId, inputs: [] };
|
||||
}, context);
|
||||
const forkId = await harness.commit(
|
||||
async (tx) => (await tx.forkConversation(id, at, { ownership: { kind: "ownerless" } })).id,
|
||||
context,
|
||||
);
|
||||
const fork = (await harness.conversation(forkId, context))!;
|
||||
// A fork copies the configuration at its fork entry, not the registry default, and starts with an empty pi.live.
|
||||
expect(await fork.getActiveTools(context)).toEqual([]);
|
||||
expect(await harness.snapshot(LiveDoc, forkId, context)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("runs registered conversation setups after the built-in one on every creation path", async () => {
|
||||
const Agent = defineDoc<{ kind: string; forks: number }>({
|
||||
kind: "test.agent",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "current",
|
||||
initial: () => ({ kind: "main", forks: 0 }),
|
||||
});
|
||||
const registry = createRegistry();
|
||||
const order: string[] = [];
|
||||
registry.conversations.setup("agent", async (tx, conversation) => {
|
||||
order.push(`agent:${conversation.id}`);
|
||||
const agent = await tx.doc(Agent, conversation.id);
|
||||
// A fork keeps its copied document; the setup only records the fork.
|
||||
if (conversation.parent !== undefined) agent.forks++;
|
||||
});
|
||||
expect(
|
||||
registry
|
||||
.snapshot()
|
||||
.conversationSetups()
|
||||
.map(({ key }) => key),
|
||||
).toEqual(["pi", "agent"]);
|
||||
expect(() => registry.conversations.setup("agent", () => {})).toThrow("Setup agent is already registered");
|
||||
const { harness } = await openHarness(new MemoryStorage(), [], { registry });
|
||||
const root = await harness.root(context, {
|
||||
init: async (tx, id) => {
|
||||
// Host init runs after every setup, so the agent document already exists.
|
||||
expect(order).toEqual([`agent:${id}`]);
|
||||
(await tx.doc(Agent, id)).kind = "root";
|
||||
},
|
||||
});
|
||||
const raw = await root.commit(
|
||||
async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id,
|
||||
context,
|
||||
);
|
||||
const at = await root.commit(async (tx) => (await tx.appendEntry(root.id, { kind: "note" })).id, context);
|
||||
const fork = await root.fork(at, { ownership: { kind: "ownerless" } }, context);
|
||||
expect(await harness.snapshot(Agent, root.id, context)).toEqual({ kind: "root", forks: 0 });
|
||||
expect(await harness.snapshot(Agent, raw, context)).toEqual({ kind: "main", forks: 0 });
|
||||
expect(await harness.snapshot(Agent, fork.id, context)).toEqual({ kind: "root", forks: 1 });
|
||||
expect(await harness.snapshot(LiveDoc, raw, context)).toEqual({});
|
||||
expect(order).toEqual([`agent:${root.id}`, `agent:${raw}`, `agent:${fork.id}`]);
|
||||
|
||||
registry.conversations.setup("broken", () => {
|
||||
throw new Error("setup failed");
|
||||
});
|
||||
const count = async () => (await harness.commit((tx) => tx.scanConversations({}, 100), context)).items.length;
|
||||
const before = await count();
|
||||
await expect(harness.createConversation({ ownership: { kind: "ownerless" } }, context)).rejects.toThrow(
|
||||
"setup failed",
|
||||
);
|
||||
// The whole creating commit rolled back.
|
||||
expect(await count()).toBe(before);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reads initial configuration for conversations a plain Session created", async () => {
|
||||
const storage = new ControlledStorage();
|
||||
const id = await createSession(storage).commit(
|
||||
async (tx) => (await tx.createConversation({ ownership: { kind: "ownerless" } })).id,
|
||||
context,
|
||||
);
|
||||
const { harness } = await openHarness(storage, ["read"]);
|
||||
const raw = await harness.conversation(id, context);
|
||||
expect(await harness.snapshot(LiveDoc, id, context)).toBeUndefined();
|
||||
expect(await raw!.getThinkingLevel(context)).toBe("off");
|
||||
expect(await raw!.getActiveTools(context)).toEqual([]);
|
||||
const commits = storage.commits.length;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type AssistantMessage, createModels, fauxAssistantMessage, type Message } from "@earendil-works/pi-ai";
|
||||
import { AssistantEntry, type Harness, LiveDoc, type TaskId } from "@earendil-works/pi-durable";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
|
||||
import { allEntries, type ChatSetup, chatSetup, openChat, textOf, unanswered, waitFor } from "./chat-support.ts";
|
||||
import { context } from "./session-support.ts";
|
||||
import { aborted, deferred } from "./task-support.ts";
|
||||
|
||||
const directories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of directories) await rm(directory, { recursive: true, force: true });
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
async function sqlitePath(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-durable-generation-"));
|
||||
directories.add(directory);
|
||||
return join(directory, "session.sqlite");
|
||||
}
|
||||
|
||||
async function open(path: string, setup: ChatSetup) {
|
||||
return openChat(await openNodeSqliteStorage(path), setup);
|
||||
}
|
||||
|
||||
async function runTaskId(harness: Harness): Promise<TaskId> {
|
||||
const live = await harness.snapshot(LiveDoc, 1 as never, context);
|
||||
return live!.run!.taskId;
|
||||
}
|
||||
|
||||
async function checkpoint(harness: Harness, id: TaskId) {
|
||||
const record = await harness.getTask(id, context);
|
||||
return record?.state.status === "terminal" ? undefined : record?.state.checkpoint;
|
||||
}
|
||||
|
||||
describe("generation recovery", () => {
|
||||
it("reruns preparation interrupted before its commit", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup();
|
||||
const reached = deferred();
|
||||
let block = true;
|
||||
setup.registry.systemPrompt.section("preamble", async (_input, ctx) => {
|
||||
if (block) {
|
||||
block = false;
|
||||
reached.resolve();
|
||||
await aborted(ctx.abortSignal!);
|
||||
}
|
||||
return "p";
|
||||
});
|
||||
setup.faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
let opened = await open(path, setup);
|
||||
opened.harness.resume();
|
||||
const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id;
|
||||
await reached.promise;
|
||||
const taskId = await runTaskId(opened.harness);
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, setup);
|
||||
expect(await checkpoint(opened.harness, taskId)).toEqual({ phase: "prepare", attempt: 1 });
|
||||
opened.harness.resume();
|
||||
expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");
|
||||
expect((await allEntries(opened.root)).map((entry) => entry.kind)).toEqual([
|
||||
"pi.user",
|
||||
"pi.system",
|
||||
"pi.assistant",
|
||||
]);
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("resends a request interrupted before any partial without repeating preparation", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup();
|
||||
setup.registry.systemPrompt.section("preamble", () => "p", { tag: false });
|
||||
const reached = deferred();
|
||||
const sent: string[][] = [];
|
||||
const timeouts: (number | undefined)[] = [];
|
||||
setup.faux.setResponses([
|
||||
async (_context, options) => {
|
||||
reached.resolve();
|
||||
return aborted(options!.signal!);
|
||||
},
|
||||
(request, options) => {
|
||||
sent.push(request.messages.map((message) => message.role));
|
||||
timeouts.push(options?.timeoutMs);
|
||||
return fauxAssistantMessage("answer");
|
||||
},
|
||||
]);
|
||||
let opened = await open(path, setup);
|
||||
await opened.root.setStreamOptions({ timeoutMs: 1234 }, context);
|
||||
opened.harness.resume();
|
||||
const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id;
|
||||
await reached.promise;
|
||||
const taskId = await runTaskId(opened.harness);
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, setup);
|
||||
expect(await checkpoint(opened.harness, taskId)).toMatchObject({
|
||||
phase: "request",
|
||||
attempt: 1,
|
||||
thinkingLevel: "off",
|
||||
streamOptions: { timeoutMs: 1234 },
|
||||
});
|
||||
// The resend uses the pinned request, not options changed after preparation.
|
||||
await opened.root.setStreamOptions({ timeoutMs: 999 }, context);
|
||||
expect(await opened.harness.snapshot(LiveDoc, opened.root.id, context)).toMatchObject({
|
||||
generation: { attempt: 1 },
|
||||
});
|
||||
opened.harness.resume();
|
||||
expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");
|
||||
expect(sent).toEqual([["user", "system"]]);
|
||||
expect(timeouts).toEqual([1234]);
|
||||
expect((await allEntries(opened.root)).map((entry) => entry.kind)).toEqual([
|
||||
"pi.user",
|
||||
"pi.system",
|
||||
"pi.assistant",
|
||||
]);
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("converts a committed partial into an aborted entry and resends the same messages", async () => {
|
||||
const path = await sqlitePath();
|
||||
const slow = chatSetup({ tokensPerSecond: 20, tokenSize: { min: 1, max: 1 } });
|
||||
slow.faux.setResponses([fauxAssistantMessage("z".repeat(400))]);
|
||||
let opened = await open(path, slow);
|
||||
opened.harness.resume();
|
||||
const watch = (await opened.harness.watchDoc(LiveDoc, opened.root.id, context))!;
|
||||
let watched: string | undefined;
|
||||
watch.start(async (value) => {
|
||||
watched = textOf(value?.generation?.message as Message) ?? watched;
|
||||
});
|
||||
const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id;
|
||||
await waitFor(() => watched !== undefined);
|
||||
await opened.harness.close(context);
|
||||
|
||||
const setup = chatSetup();
|
||||
const sent: string[][] = [];
|
||||
setup.faux.setResponses([
|
||||
(request) => {
|
||||
sent.push(request.messages.map((message) => message.role));
|
||||
return fauxAssistantMessage("answer");
|
||||
},
|
||||
]);
|
||||
opened = await open(path, setup);
|
||||
const stored = await opened.harness.snapshot(LiveDoc, opened.root.id, context);
|
||||
const partial = textOf(stored?.generation?.message as Message)!;
|
||||
// Everything observers saw before the crash is durable.
|
||||
expect(partial.startsWith(watched!)).toBe(true);
|
||||
opened.harness.resume();
|
||||
expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");
|
||||
expect(sent).toEqual([["user"]]);
|
||||
const entries = await allEntries(opened.root);
|
||||
expect(entries.map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant", "pi.assistant"]);
|
||||
const converted = entries[1]!.model![0] as AssistantMessage;
|
||||
expect(converted.stopReason).toBe("aborted");
|
||||
expect(textOf(converted)).toBe(partial);
|
||||
expect(await opened.harness.snapshot(LiveDoc, opened.root.id, context)).toEqual({});
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("resumes a retry backoff after reopen", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup();
|
||||
let now = 1_000;
|
||||
setup.now = () => now;
|
||||
setup.faux.setResponses([
|
||||
fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
]);
|
||||
let opened = await open(path, setup);
|
||||
opened.harness.resume();
|
||||
await opened.root.setRetryPolicy({ enabled: true, maxRetries: 2, baseDelayMs: 60_000 }, context);
|
||||
const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id;
|
||||
await waitFor(async () => {
|
||||
const live = await opened.harness.snapshot(LiveDoc, opened.root.id, context);
|
||||
return live?.generation?.retry !== undefined;
|
||||
});
|
||||
const taskId = await runTaskId(opened.harness);
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, setup);
|
||||
expect(await checkpoint(opened.harness, taskId)).toEqual({ phase: "retry", attempt: 1, until: 61_000 });
|
||||
expect(await opened.harness.snapshot(LiveDoc, opened.root.id, context)).toEqual({
|
||||
run: { taskId, inputs: [id] },
|
||||
generation: { attempt: 1, retry: { at: 61_000, error: "503 Service Unavailable" } },
|
||||
});
|
||||
now = 61_000;
|
||||
opened.harness.resume();
|
||||
expect((await (await opened.harness.submission(id, context))!.wait(context)).status).toBe("done");
|
||||
expect((await allEntries(opened.root)).map((entry) => entry.kind)).toEqual([
|
||||
"pi.user",
|
||||
"pi.assistant",
|
||||
"pi.assistant",
|
||||
]);
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("resumes polling a deferred response after reopen", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup({ deferred: { pollAfterMs: 60_000 } });
|
||||
let now = 1_000;
|
||||
setup.now = () => now;
|
||||
setup.faux.setResponses([fauxAssistantMessage("deferred answer")]);
|
||||
let opened = await open(path, setup);
|
||||
opened.harness.resume();
|
||||
await opened.root.setStreamOptions({ deferred: true }, context);
|
||||
const id = (await opened.root.submit({ type: "input", content: "hi" }, context)).id;
|
||||
await waitFor(async () => {
|
||||
const live = await opened.harness.snapshot(LiveDoc, opened.root.id, context);
|
||||
return live?.generation?.deferred !== undefined;
|
||||
});
|
||||
const taskId = await runTaskId(opened.harness);
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, setup);
|
||||
expect(await checkpoint(opened.harness, taskId)).toMatchObject({ phase: "poll", attempt: 1, pollAt: 61_000 });
|
||||
now = 61_000;
|
||||
opened.harness.resume();
|
||||
const settled = await (await opened.harness.submission(id, context))!.wait(context);
|
||||
if (settled.status !== "done" || settled.type !== "input") throw new Error(`Unexpected ${settled.status}`);
|
||||
const answer = await opened.root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
|
||||
expect(textOf(answer?.model?.[0])).toBe("deferred answer");
|
||||
expect(setup.faux.state.deferredFetchCount).toBe(1);
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("fails no_model when the pinned model is gone after reopen, in request and in poll", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup({ deferred: { pollAfterMs: 60_000 } });
|
||||
const busy = unanswered();
|
||||
setup.faux.setResponses([busy.step, fauxAssistantMessage("deferred")]);
|
||||
let opened = await open(path, setup);
|
||||
opened.harness.resume();
|
||||
const requesting = (await opened.root.submit({ type: "input", content: "one" }, context)).id;
|
||||
await busy.reached;
|
||||
await opened.harness.close(context);
|
||||
|
||||
// Reopened without the faux provider: the request's pinned model is unknown.
|
||||
const empty: ChatSetup = { ...setup, models: createModels() };
|
||||
opened = await open(path, empty);
|
||||
opened.harness.resume();
|
||||
expect(await (await opened.harness.submission(requesting, context))!.wait(context)).toMatchObject({
|
||||
status: "unanswered",
|
||||
reason: "no_model",
|
||||
});
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, setup);
|
||||
await opened.root.setStreamOptions({ deferred: true }, context);
|
||||
opened.harness.resume();
|
||||
const polling = (await opened.root.submit({ type: "input", content: "two" }, context)).id;
|
||||
await waitFor(async () => {
|
||||
const live = await opened.harness.snapshot(LiveDoc, opened.root.id, context);
|
||||
return live?.generation?.deferred !== undefined;
|
||||
});
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, empty);
|
||||
opened.harness.resume();
|
||||
expect(await (await opened.harness.submission(polling, context))!.wait(context)).toMatchObject({
|
||||
status: "unanswered",
|
||||
reason: "no_model",
|
||||
});
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("runs a print-style turn and reads the durable answer after reopen", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup();
|
||||
setup.registry.systemPrompt.section("preamble", () => "You are terse.", { tag: false });
|
||||
setup.faux.setResponses([fauxAssistantMessage("42")]);
|
||||
let opened = await open(path, setup);
|
||||
opened.harness.resume();
|
||||
const submission = await opened.root.submit({ type: "input", content: "answer?" }, context);
|
||||
const settled = await submission.wait(context);
|
||||
if (settled.status !== "done" || settled.type !== "input") throw new Error(`Unexpected ${settled.status}`);
|
||||
const answer = await opened.root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
|
||||
expect(textOf(answer?.model?.[0])).toBe("42");
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await open(path, setup);
|
||||
expect(await (await opened.harness.submission(submission.id, context))!.status(context)).toEqual(settled);
|
||||
expect(await opened.root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context)).toEqual(answer);
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
type AssistantMessage,
|
||||
fauxAssistantMessage,
|
||||
fauxToolCall,
|
||||
type Message,
|
||||
type Models,
|
||||
type SimpleStreamOptions,
|
||||
type SystemMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import {
|
||||
AssistantEntry,
|
||||
type CommitPublication,
|
||||
type Conversation,
|
||||
ConversationConfig,
|
||||
createRegistry,
|
||||
defineDoc,
|
||||
GenerationTask,
|
||||
Harness,
|
||||
LiveDoc,
|
||||
type LiveState,
|
||||
MemoryStorage,
|
||||
type RegistrySnapshot,
|
||||
type TaskId,
|
||||
UserEntry,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionImpl } from "../src/session/session.ts";
|
||||
import { allEntries, type ChatSetup, chatSetup, openChat, textOf, unanswered, waitFor } from "./chat-support.ts";
|
||||
import { ControlledStorage, context } from "./session-support.ts";
|
||||
|
||||
const ERROR_503 = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" });
|
||||
|
||||
async function live(harness: Harness, conversation: Conversation): Promise<Readonly<LiveState> | undefined> {
|
||||
return harness.snapshot(LiveDoc, conversation.id, context);
|
||||
}
|
||||
|
||||
async function runTask(harness: Harness, conversation: Conversation): Promise<TaskId> {
|
||||
let taskId: TaskId | undefined;
|
||||
await waitFor(async () => {
|
||||
taskId = (await live(harness, conversation))?.run?.taskId;
|
||||
return taskId !== undefined;
|
||||
});
|
||||
return taskId!;
|
||||
}
|
||||
|
||||
/** `models` with `streamSimple` replaced. */
|
||||
function withStream(models: Models, streamSimple: Models["streamSimple"]): Models {
|
||||
return new Proxy(models, {
|
||||
get(target, property) {
|
||||
if (property === "streamSimple") return streamSimple;
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A stream that commits one partial and then ends with a final message that is not strict JSON, so the classification
|
||||
* commit throws and the scheduler faults the task.
|
||||
*/
|
||||
function invalidFinalStream(): ReturnType<Models["streamSimple"]> {
|
||||
const events = async function* () {
|
||||
yield { type: "start", partial: fauxAssistantMessage("partial", { stopReason: "pending" }) };
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
};
|
||||
const final = { ...fauxAssistantMessage("final"), invalid: () => {} };
|
||||
return { [Symbol.asyncIterator]: events, result: async () => final } as unknown as ReturnType<
|
||||
Models["streamSimple"]
|
||||
>;
|
||||
}
|
||||
|
||||
function livePublications(harness: Harness): LiveState[] {
|
||||
const values: LiveState[] = [];
|
||||
harness.subscribeCommits((publication: CommitPublication) => {
|
||||
for (const change of publication.changes) {
|
||||
if (change.type === "document" && change.record.kind === "pi.live" && change.value !== null) {
|
||||
values.push(change.value as LiveState);
|
||||
}
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
describe("generation", () => {
|
||||
it("answers an input and settles its submission", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false });
|
||||
setup.faux.setResponses([fauxAssistantMessage("Hello there")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
harness.resume();
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
const settled = await submission.wait(context);
|
||||
if (settled.status !== "done" || settled.type !== "input") throw new Error(`Unexpected ${settled.status}`);
|
||||
|
||||
const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
|
||||
expect(textOf(answer?.model?.[0])).toBe("Hello there");
|
||||
const entries = await allEntries(root);
|
||||
expect(entries.map((entry) => entry.kind)).toEqual(["pi.user", "pi.system", "pi.assistant"]);
|
||||
expect(entries[0]!.id).toBe(settled.entry);
|
||||
expect(entries[1]!.model).toEqual([
|
||||
{ role: "system", content: "", sections: { preamble: "You are helpful." }, timestamp: expect.any(Number) },
|
||||
]);
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
const task = (await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 10), context)).items[0];
|
||||
expect(task?.kind).toBe("pi.generation");
|
||||
// Entries written by the generation are attributed to it; the admitted user entry is not task work.
|
||||
expect(entries.map((entry) => entry.byTaskId)).toEqual([undefined, task!.id, task!.id]);
|
||||
expect(task?.state).toEqual({
|
||||
status: "terminal",
|
||||
outcome: { status: "completed", result: { entryId: settled.answer } },
|
||||
});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("stores partials as deltas and a complete base once nothing is in flight", async () => {
|
||||
const setup = chatSetup({ tokensPerSecond: 200, tokenSize: { min: 1, max: 1 } });
|
||||
setup.faux.setResponses([fauxAssistantMessage("w".repeat(200))]);
|
||||
const storage = new ControlledStorage();
|
||||
const { harness, root } = await openChat(storage, setup);
|
||||
const record = await storage.findDocument(
|
||||
{ kind: "pi.live", scope: { kind: "conversation", conversationId: root.id } },
|
||||
"current",
|
||||
context,
|
||||
);
|
||||
harness.resume();
|
||||
await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
const contents = storage.commits.flatMap((writes) =>
|
||||
writes.flatMap((write) =>
|
||||
write.type === "document.change" && write.id === record!.id ? [write.content.kind] : [],
|
||||
),
|
||||
);
|
||||
// Streaming writes deltas; the commit that settles the answer clears generation and writes a base.
|
||||
expect(contents).toContain("delta");
|
||||
expect(contents.at(-1)).toBe("base");
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("still ends a run whose input something else already settled", async () => {
|
||||
const setup = chatSetup();
|
||||
const busy = unanswered();
|
||||
setup.faux.setResponses([busy.step]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
await busy.reached;
|
||||
await root.commit(
|
||||
(tx) => tx.settleSubmission(submission.id, { status: "unanswered", reason: "withdrawn" }),
|
||||
context,
|
||||
);
|
||||
const taskId = await runTask(harness, root);
|
||||
await harness.abortTask(taskId, context);
|
||||
expect((await harness.waitForTask(taskId, context)).state.outcome).toEqual({ status: "aborted" });
|
||||
// The earlier settlement stays; the run's own settlement leaves it unchanged.
|
||||
expect(await submission.status(context)).toMatchObject({ status: "unanswered", reason: "withdrawn" });
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("settles a tool call as the answer until the tool chain exists", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([fauxAssistantMessage([fauxToolCall("read", { path: "a" })], { stopReason: "toolUse" })]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
harness.resume();
|
||||
const settled = await (await root.submit({ type: "input", content: "read a" }, context)).wait(context);
|
||||
expect(settled.status).toBe("done");
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("fails with no_model when no model is configured or the model is unknown", async () => {
|
||||
const setup = chatSetup();
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
const plain = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
||||
harness.resume();
|
||||
const unset = await (await plain.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
expect(unset).toMatchObject({ status: "unanswered", reason: "no_model" });
|
||||
|
||||
await root.setModel({ provider: "faux", modelId: "missing" }, context);
|
||||
const unknown = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
expect(unknown).toMatchObject({ status: "unanswered", reason: "no_model", entry: expect.any(Number) });
|
||||
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user"]);
|
||||
const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 10), context);
|
||||
expect(tasks.items[0]!.state).toEqual({
|
||||
status: "terminal",
|
||||
outcome: {
|
||||
status: "failed",
|
||||
error: { message: "Model faux/missing is not available", detail: { reason: "no_model" } },
|
||||
},
|
||||
});
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
expect(await live(harness, plain)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("retries a retryable error after a durable backoff and then answers", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.registry.systemPrompt.section("preamble", () => "p", { tag: false });
|
||||
setup.faux.setResponses([ERROR_503, fauxAssistantMessage("recovered")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setRetryPolicy({ enabled: true, maxRetries: 3, baseDelayMs: 1 }, context);
|
||||
const values = livePublications(harness);
|
||||
harness.resume();
|
||||
const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
expect(settled.status).toBe("done");
|
||||
const entries = await allEntries(root);
|
||||
expect(entries.map((entry) => entry.kind)).toEqual(["pi.user", "pi.system", "pi.assistant", "pi.assistant"]);
|
||||
expect((entries[2]!.model![0] as AssistantMessage).stopReason).toBe("error");
|
||||
expect(values.some((value) => value.generation?.retry?.error === "503 Service Unavailable")).toBe(true);
|
||||
expect(values.some((value) => value.generation?.attempt === 2)).toBe(true);
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("fails with model_error once retries are exhausted", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([ERROR_503, ERROR_503, fauxAssistantMessage("never")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setRetryPolicy({ enabled: true, maxRetries: 1, baseDelayMs: 1 }, context);
|
||||
harness.resume();
|
||||
const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
expect(settled).toMatchObject({ status: "unanswered", reason: "model_error", detail: "503 Service Unavailable" });
|
||||
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant", "pi.assistant"]);
|
||||
expect(setup.faux.getPendingResponseCount()).toBe(1);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("fails a retryable error without retrying when the retry policy is disabled", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([ERROR_503, fauxAssistantMessage("never")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setRetryPolicy({ enabled: false, maxRetries: 3, baseDelayMs: 1 }, context);
|
||||
harness.resume();
|
||||
const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
expect(settled).toMatchObject({ status: "unanswered", reason: "model_error" });
|
||||
expect(setup.faux.state.callCount).toBe(1);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reports section wrapper failures while preparing", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.registry.systemPrompt.section("cwd", () => "/repo");
|
||||
setup.registry.systemPrompt.wrap("cwd", "broken", () => {
|
||||
throw new Error("wrapper failed");
|
||||
});
|
||||
setup.faux.setResponses([fauxAssistantMessage("ok")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
harness.resume();
|
||||
expect((await (await root.submit({ type: "input", content: "hi" }, context)).wait(context)).status).toBe("done");
|
||||
expect(setup.reports).toContainEqual(new Error("wrapper failed"));
|
||||
// The failed section is absent, so nothing was rendered.
|
||||
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant"]);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("fails a non-retryable error without retrying", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([
|
||||
fauxAssistantMessage([], { stopReason: "error", errorMessage: "Invalid request" }),
|
||||
fauxAssistantMessage("never"),
|
||||
]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
harness.resume();
|
||||
const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
expect(settled).toMatchObject({ status: "unanswered", reason: "model_error", detail: "Invalid request" });
|
||||
const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 10), context);
|
||||
expect(tasks.items[0]!.state).toMatchObject({
|
||||
outcome: { status: "failed", error: { message: "Invalid request", detail: { reason: "model_error" } } },
|
||||
});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("polls a deferred response until it is ready", async () => {
|
||||
const setup = chatSetup({ deferred: { pendingFetches: 1, pollAfterMs: 1 } });
|
||||
setup.faux.setResponses([fauxAssistantMessage("deferred answer")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setStreamOptions({ deferred: true }, context);
|
||||
const values = livePublications(harness);
|
||||
harness.resume();
|
||||
const settled = await (await root.submit({ type: "input", content: "hi" }, context)).wait(context);
|
||||
if (settled.status !== "done" || settled.type !== "input") throw new Error(`Unexpected ${settled.status}`);
|
||||
expect(setup.faux.state.deferredFetchCount).toBe(2);
|
||||
const pollTimes = values.flatMap((value) =>
|
||||
value.generation?.deferred === undefined ? [] : [value.generation.deferred.pollAt],
|
||||
);
|
||||
expect(pollTimes.length).toBe(2);
|
||||
expect(pollTimes[1]!).toBeGreaterThan(pollTimes[0]!);
|
||||
const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
|
||||
expect(textOf(answer?.model?.[0])).toBe("deferred answer");
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("converts the committed partial when aborted during streaming", async () => {
|
||||
const setup = chatSetup({ tokensPerSecond: 20, tokenSize: { min: 1, max: 1 } });
|
||||
setup.faux.setResponses([fauxAssistantMessage("x".repeat(400))]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
harness.resume();
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
const taskId = await runTask(harness, root);
|
||||
await waitFor(async () => textOf((await live(harness, root))?.generation?.message as Message) !== undefined);
|
||||
const partial = (await live(harness, root))!.generation!.message!;
|
||||
expect(await harness.abortTask(taskId, context)).toBe("marked");
|
||||
expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
|
||||
const entries = await allEntries(root);
|
||||
expect(entries.map((entry) => entry.kind)).toEqual(["pi.user", "pi.assistant"]);
|
||||
const converted = entries[1]!.model![0] as AssistantMessage;
|
||||
expect(converted.stopReason).toBe("aborted");
|
||||
expect(textOf(converted)!.startsWith(textOf(partial as Message)!)).toBe(true);
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
expect((await harness.waitForTask(taskId, context)).state.outcome).toEqual({ status: "aborted" });
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("cancels a deferred response when aborted during polling", async () => {
|
||||
const setup = chatSetup({ deferred: { pendingFetches: 100, pollAfterMs: 60_000 } });
|
||||
setup.faux.setResponses([fauxAssistantMessage("never")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setStreamOptions({ deferred: true }, context);
|
||||
harness.resume();
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
const taskId = await runTask(harness, root);
|
||||
await waitFor(async () => (await live(harness, root))?.generation?.deferred !== undefined);
|
||||
await harness.abortTask(taskId, context);
|
||||
expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
|
||||
expect(setup.faux.state.cancelledDeferred).toHaveLength(1);
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reports a failed deferred cancellation and still ends the run aborted", async () => {
|
||||
const base = chatSetup({ deferred: { pendingFetches: 100, pollAfterMs: 60_000 } });
|
||||
base.faux.setResponses([fauxAssistantMessage("never")]);
|
||||
const models = new Proxy(base.models, {
|
||||
get(target, property) {
|
||||
if (property === "cancelDeferred") return async () => Promise.reject(new Error("cancel failed"));
|
||||
const value = Reflect.get(target, property, target);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const setup: ChatSetup = { ...base, models };
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setStreamOptions({ deferred: true }, context);
|
||||
harness.resume();
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
const taskId = await runTask(harness, root);
|
||||
await waitFor(async () => (await live(harness, root))?.generation?.deferred !== undefined);
|
||||
await harness.abortTask(taskId, context);
|
||||
expect(await submission.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
|
||||
expect(setup.reports).toContainEqual(new Error("cancel failed"));
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("forwards stream options and the thinking level", async () => {
|
||||
const setup = chatSetup();
|
||||
const seen: (SimpleStreamOptions | undefined)[] = [];
|
||||
setup.faux.setResponses([
|
||||
(_context, options) => {
|
||||
seen.push(options);
|
||||
return fauxAssistantMessage("a");
|
||||
},
|
||||
(_context, options) => {
|
||||
seen.push(options);
|
||||
return fauxAssistantMessage("b");
|
||||
},
|
||||
]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.setStreamOptions({ timeoutMs: 1234, headers: { "x-test": "1" } }, context);
|
||||
await root.setThinkingLevel("high", context);
|
||||
harness.resume();
|
||||
await (await root.submit({ type: "input", content: "one" }, context)).wait(context);
|
||||
await root.setThinkingLevel("off", context);
|
||||
await (await root.submit({ type: "input", content: "two" }, context)).wait(context);
|
||||
expect(seen[0]).toMatchObject({ timeoutMs: 1234, headers: { "x-test": "1" }, reasoning: "high" });
|
||||
expect(seen[0]!.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(seen[1]!.reasoning).toBeUndefined();
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reads and writes stream options and the retry policy", async () => {
|
||||
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
|
||||
expect(await root.getStreamOptions(context)).toEqual({});
|
||||
expect(await root.getRetryPolicy(context)).toEqual({
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 2000,
|
||||
maxAgentDelayMs: 60000,
|
||||
});
|
||||
await root.setStreamOptions({ cacheRetention: "long", deferred: { window: "1h" } }, context);
|
||||
await root.setRetryPolicy({ enabled: false, maxRetries: 0, baseDelayMs: 5 }, context);
|
||||
expect(await root.getStreamOptions(context)).toEqual({ cacheRetention: "long", deferred: { window: "1h" } });
|
||||
expect(await root.getRetryPolicy(context)).toEqual({ enabled: false, maxRetries: 0, baseDelayMs: 5 });
|
||||
await root.setRetryPolicy(undefined, context);
|
||||
expect(await root.getRetryPolicy(context)).toMatchObject({ enabled: true, maxRetries: 3 });
|
||||
await expect(
|
||||
root.setStreamOptions({ timeoutMs: undefined, headers: { a: undefined } } as never, context),
|
||||
).rejects.toThrow();
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("renders sections that read conversation documents through input.read", async () => {
|
||||
const Agent = defineDoc<{ cwd: string; kind: string }>({
|
||||
kind: "test.agent",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "current",
|
||||
initial: () => ({ cwd: "/", kind: "main" }),
|
||||
});
|
||||
const setup = chatSetup();
|
||||
setup.registry.systemPrompt.section(
|
||||
"cwd",
|
||||
async (input, ctx) => (await input.read.snapshot(Agent, input.conversationId, ctx))?.cwd,
|
||||
);
|
||||
setup.registry.systemPrompt.section("agents", async (input, ctx) =>
|
||||
(await input.read.snapshot(Agent, input.conversationId, ctx))?.kind === "sub" ? undefined : "Read AGENTS.md",
|
||||
);
|
||||
setup.faux.setResponses([fauxAssistantMessage("a"), fauxAssistantMessage("b")]);
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
await root.commit(async (tx) => {
|
||||
(await tx.doc(Agent, root.id)).cwd = "/repo";
|
||||
}, context);
|
||||
const sub = await harness.createConversation(
|
||||
{
|
||||
ownership: { kind: "ownerless" },
|
||||
init: async (tx, id) => {
|
||||
(await tx.doc(ConversationConfig, id)).model = { provider: "faux", modelId: "faux-1" };
|
||||
const agent = await tx.doc(Agent, id);
|
||||
agent.kind = "sub";
|
||||
agent.cwd = "/sub";
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
harness.resume();
|
||||
await (await root.submit({ type: "input", content: "one" }, context)).wait(context);
|
||||
await (await sub.submit({ type: "input", content: "two" }, context)).wait(context);
|
||||
const sections = async (conversation: Conversation) =>
|
||||
(await allEntries(conversation)).find((entry) => entry.kind === "pi.system")?.model?.[0];
|
||||
expect(await sections(root)).toMatchObject({
|
||||
sections: { cwd: "<cwd>\n/repo\n</cwd>", agents: "<agents>\nRead AGENTS.md\n</agents>" },
|
||||
});
|
||||
expect((await sections(sub)) as SystemMessage).toEqual({
|
||||
role: "system",
|
||||
content: "",
|
||||
sections: { cwd: "<cwd>\n/sub\n</cwd>" },
|
||||
timestamp: expect.any(Number),
|
||||
});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("faults a run task, settling its inputs and discarding the committed partial", async () => {
|
||||
const base = chatSetup();
|
||||
const setup: ChatSetup = { ...base, models: withStream(base.models, invalidFinalStream) };
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
const values = livePublications(harness);
|
||||
harness.resume();
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
expect(await submission.wait(context)).toMatchObject({
|
||||
status: "unanswered",
|
||||
reason: "faulted",
|
||||
detail: expect.stringContaining("non-JSON function"),
|
||||
});
|
||||
expect(values.some((value) => textOf(value.generation?.message as Message) === "partial")).toBe(true);
|
||||
expect((await allEntries(root)).map((entry) => entry.kind)).toEqual(["pi.user"]);
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 10), context);
|
||||
expect(tasks.items[0]!.state).toMatchObject({ status: "terminal", outcome: { status: "faulted" } });
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("orphans a blocked run task with full run cleanup", async () => {
|
||||
const setup = chatSetup();
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
// A run whose task was stored by a newer generation definition this process cannot run.
|
||||
const { taskId, submissionId } = await (harness as unknown as SessionImpl).commitWith(async (tx) => {
|
||||
const entry = await tx.appendEntry(UserEntry, root.id, {
|
||||
model: [{ role: "user", content: "hi", timestamp: 1 }],
|
||||
});
|
||||
const submission = await tx.createSubmission({
|
||||
conversationId: root.id,
|
||||
type: "input",
|
||||
status: "placed",
|
||||
entry: entry.id,
|
||||
});
|
||||
const taskId = await tx.createTask(
|
||||
{ definition: { ...GenerationTask.definition, version: 2 } },
|
||||
{},
|
||||
{ conversationId: root.id },
|
||||
);
|
||||
(await tx.doc(LiveDoc, root.id)).run = { taskId, inputs: [submission.id] };
|
||||
return { taskId, submissionId: submission.id };
|
||||
}, context);
|
||||
harness.resume();
|
||||
await expect(root.submit({ type: "input", content: "busy" }, context)).rejects.toThrow("is busy");
|
||||
expect(await harness.abortTask(taskId, context)).toBe("marked");
|
||||
expect((await harness.waitForTask(taskId, context)).state.outcome).toEqual({
|
||||
status: "orphaned",
|
||||
reason: "task_too_old",
|
||||
});
|
||||
expect(await (await harness.submission(submissionId, context))!.status(context)).toMatchObject({
|
||||
status: "unanswered",
|
||||
reason: "task_too_old",
|
||||
});
|
||||
expect(await live(harness, root)).toEqual({});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("rejects a registry without the built-in tasks", async () => {
|
||||
const empty = createRegistry().snapshot();
|
||||
const snapshot: RegistrySnapshot = {
|
||||
tools: () => [],
|
||||
tool: () => undefined,
|
||||
toolNames: () => [],
|
||||
task: (name) => (name === "pi.generation" ? undefined : empty.task(name)),
|
||||
hooks: () => [],
|
||||
sections: () => [],
|
||||
failures: () => [],
|
||||
conversationSetups: () => [],
|
||||
};
|
||||
await expect(
|
||||
Harness.open(
|
||||
new MemoryStorage(),
|
||||
{ models: chatSetup().models, registry: { snapshot: () => snapshot, subscribe: () => () => {} } },
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow("Registry lacks built-in task pi.generation, conversation setup pi");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { defineTask, MemoryStorage, type TaskId, type TaskInspection } from "@earendil-works/pi-durable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chatSetup, openChat, unanswered, waitFor } from "./chat-support.ts";
|
||||
import { tool } from "./harness-support.ts";
|
||||
import { context } from "./session-support.ts";
|
||||
import { completed, deferred, eventually, openTasks } from "./task-support.ts";
|
||||
|
||||
type Step = { phase: "run" };
|
||||
|
||||
/** A one-phase task that completes once `gate` resolves; `migrate` runs when an older stored version migrates. */
|
||||
function task(name: string, version: number, options: { gate?: Promise<void>; migrate?: () => void } = {}) {
|
||||
const migrate = options.migrate;
|
||||
return defineTask<null, Step, null>({
|
||||
name,
|
||||
version,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, ctx) => {
|
||||
await options.gate;
|
||||
await runtime.commit(() => completed(null), ctx);
|
||||
},
|
||||
},
|
||||
abort: async () => {},
|
||||
...(migrate === undefined
|
||||
? {}
|
||||
: {
|
||||
migrate: () => {
|
||||
migrate();
|
||||
return { input: null, checkpoint: { phase: "run" as const } };
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function stateOf(tasks: readonly TaskInspection[], id: TaskId): TaskInspection["state"] | undefined {
|
||||
return tasks.find((entry) => entry.record.id === id)?.state;
|
||||
}
|
||||
|
||||
describe("Harness.inspect()", () => {
|
||||
it("derives every live task's state without running task code", async () => {
|
||||
const gate = deferred();
|
||||
const Gate = task("test.gate", 1, { gate: gate.promise });
|
||||
const Dependent = task("test.dependent", 1);
|
||||
let migrations = 0;
|
||||
const registered = [
|
||||
Gate,
|
||||
Dependent,
|
||||
task("test.migrating", 2, {
|
||||
migrate: () => {
|
||||
migrations++;
|
||||
},
|
||||
}),
|
||||
task("test.no-migration", 2),
|
||||
task("test.failing", 2, {
|
||||
migrate: () => {
|
||||
throw new Error("cannot migrate");
|
||||
},
|
||||
}),
|
||||
task("test.too-old", 1),
|
||||
];
|
||||
const { harness } = await openTasks(new MemoryStorage(), registered);
|
||||
const root = await harness.root(context);
|
||||
const ids = await root.commit(async (tx) => {
|
||||
const gateId = await tx.createTask(Gate, null);
|
||||
return {
|
||||
gate: gateId,
|
||||
dependent: await tx.createTask(Dependent, null, { after: [gateId] }),
|
||||
// Stored by definitions other than the registered ones.
|
||||
migrating: await tx.createTask(task("test.migrating", 1), null),
|
||||
noMigration: await tx.createTask(task("test.no-migration", 1), null),
|
||||
failing: await tx.createTask(task("test.failing", 1), null),
|
||||
tooOld: await tx.createTask(task("test.too-old", 2), null),
|
||||
missing: await tx.createTask(task("test.missing", 1), null),
|
||||
};
|
||||
}, context);
|
||||
|
||||
const paused = await harness.inspect(context);
|
||||
expect(paused.scheduling).toBe("paused");
|
||||
expect(paused.tasks.map((entry) => entry.record.id)).toEqual(Object.values(ids));
|
||||
expect(stateOf(paused.tasks, ids.gate)).toEqual({ kind: "ready", migrates: false });
|
||||
expect(stateOf(paused.tasks, ids.dependent)).toEqual({ kind: "waiting", on: [ids.gate] });
|
||||
expect(stateOf(paused.tasks, ids.migrating)).toEqual({ kind: "ready", migrates: true });
|
||||
// A migration that was never tried is not run to find out.
|
||||
expect(stateOf(paused.tasks, ids.failing)).toEqual({ kind: "ready", migrates: true });
|
||||
expect(stateOf(paused.tasks, ids.noMigration)).toMatchObject({
|
||||
kind: "blocked",
|
||||
reason: "migration_failed",
|
||||
error: new Error("Task test.no-migration version 2 has no migration from 1"),
|
||||
});
|
||||
expect(stateOf(paused.tasks, ids.tooOld)).toEqual({ kind: "blocked", reason: "task_too_old" });
|
||||
expect(stateOf(paused.tasks, ids.missing)).toEqual({ kind: "blocked", reason: "missing_task" });
|
||||
expect(migrations).toBe(0);
|
||||
expect((await harness.inspect(context)).scheduling).toBe("paused");
|
||||
|
||||
harness.resume();
|
||||
await eventually(() => migrations === 1);
|
||||
await harness.waitForTask(ids.migrating, context);
|
||||
let running = await harness.inspect(context);
|
||||
await waitFor(async () => {
|
||||
running = await harness.inspect(context);
|
||||
return stateOf(running.tasks, ids.gate)?.kind === "running";
|
||||
});
|
||||
expect(running.scheduling).toBe("running");
|
||||
expect(stateOf(running.tasks, ids.gate)).toEqual({ kind: "running" });
|
||||
expect(stateOf(running.tasks, ids.migrating)).toBeUndefined();
|
||||
expect(stateOf(running.tasks, ids.failing)).toEqual({
|
||||
kind: "blocked",
|
||||
reason: "migration_failed",
|
||||
error: new Error("cannot migrate"),
|
||||
});
|
||||
|
||||
gate.resolve();
|
||||
await harness.waitForTask(ids.dependent, context);
|
||||
const settled = await harness.inspect(context);
|
||||
expect(settled.tasks.map((entry) => entry.record.id)).toEqual([
|
||||
ids.noMigration,
|
||||
ids.failing,
|
||||
ids.tooOld,
|
||||
ids.missing,
|
||||
]);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("lists unsettled submissions and registry failures", async () => {
|
||||
const setup = chatSetup();
|
||||
const busy = unanswered();
|
||||
setup.faux.setResponses([busy.step]);
|
||||
setup.registry.tools.add(tool("read"));
|
||||
setup.registry.tools.wrap("read", "broken", () => {
|
||||
throw new Error("wrapper failed");
|
||||
});
|
||||
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
||||
const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
||||
await other.submit({ type: "write", entry: { kind: "note" } }, context);
|
||||
const input = await root.submit({ type: "input", content: "hi" }, context);
|
||||
await busy.reached;
|
||||
|
||||
const inspection = await harness.inspect(context);
|
||||
expect(inspection.submissions).toEqual([await input.status(context)]);
|
||||
expect(inspection.tasks.map((entry) => [entry.record.kind, entry.state.kind])).toEqual([
|
||||
["pi.generation", "running"],
|
||||
]);
|
||||
expect(inspection.registry).toEqual([{ kind: "tool", name: "read", error: new Error("wrapper failed") }]);
|
||||
await harness.close(context);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { SystemMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type Conversation,
|
||||
createRegistry,
|
||||
type EntryId,
|
||||
MemoryStorage,
|
||||
type PromptInput,
|
||||
type PromptSection,
|
||||
SystemEntry,
|
||||
type ToolRegistration,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planSystemEntries, renderSections, replaySections } from "../src/harness/prompt.ts";
|
||||
import { openHarness, user } from "./harness-support.ts";
|
||||
import { context } from "./session-support.ts";
|
||||
|
||||
type Planned = { readonly sections: Record<string, string | null>; readonly omit?: readonly EntryId[] };
|
||||
|
||||
/** Plan against the current context, append the plan, and check that replay then yields `desired` in order. */
|
||||
async function apply(conversation: Conversation, desired: Record<string, string>): Promise<Planned[]> {
|
||||
const drafts = planSystemEntries(await conversation.context(context), new Map(Object.entries(desired)), 7);
|
||||
await conversation.commit(async (tx) => {
|
||||
for (const draft of drafts) await tx.appendEntry(SystemEntry, conversation.id, draft);
|
||||
}, context);
|
||||
const replayed = replaySections((await conversation.context(context)).messages);
|
||||
expect([...replayed]).toEqual(Object.entries(desired));
|
||||
return drafts.map((draft) => {
|
||||
const message = draft.model![0] as SystemMessage;
|
||||
expect(message).toMatchObject({ role: "system", content: "", timestamp: 7 });
|
||||
const omit = draft.edits?.map((edit) => {
|
||||
expect(edit.action).toBe("omit");
|
||||
return edit.target;
|
||||
});
|
||||
return omit === undefined ? { sections: message.sections! } : { sections: message.sections!, omit };
|
||||
});
|
||||
}
|
||||
|
||||
async function root(): Promise<Conversation> {
|
||||
const { harness } = await openHarness(new MemoryStorage());
|
||||
return harness.root(context);
|
||||
}
|
||||
|
||||
async function lastSystemId(conversation: Conversation): Promise<EntryId> {
|
||||
const page = await conversation.entries({}, 100, undefined, context);
|
||||
return page.items.find((entry) => entry.kind === "pi.system")!.id;
|
||||
}
|
||||
|
||||
async function marker(conversation: Conversation, head: EntryId | "self"): Promise<EntryId> {
|
||||
return conversation.commit(
|
||||
async (tx) => (await tx.appendEntry(conversation.id, { kind: "summary", head, model: [user("summary")] })).id,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
function section(key: string, render: PromptSection<ToolRegistration>["render"], tag?: boolean) {
|
||||
return tag === undefined ? { key, render } : { key, render, tag };
|
||||
}
|
||||
|
||||
const input: PromptInput<ToolRegistration> = {
|
||||
conversationId: 1 as never,
|
||||
tools: [],
|
||||
shown: {},
|
||||
thinkingLevel: "off",
|
||||
read: { snapshot: async () => undefined, snapshotAsOf: async () => undefined },
|
||||
};
|
||||
|
||||
describe("system prompt preparation", () => {
|
||||
it("renders sections in order with tags, omissions, wrappers, and failures", async () => {
|
||||
const registry = createRegistry();
|
||||
registry.systemPrompt.section("preamble", () => "You are helpful.", { tag: false });
|
||||
registry.systemPrompt.section("cwd", async () => "/repo");
|
||||
registry.systemPrompt.section("skipped", () => undefined);
|
||||
registry.systemPrompt.section("failing", () => {
|
||||
throw new Error("render failed");
|
||||
});
|
||||
registry.systemPrompt.section("new-failing", () => {
|
||||
throw new Error("also failed");
|
||||
});
|
||||
registry.systemPrompt.wrap("cwd", "suffix", (inner) => ({
|
||||
...inner,
|
||||
render: async (value, ctx) => `${await inner.render(value, ctx)} (git)`,
|
||||
}));
|
||||
const reports: unknown[] = [];
|
||||
const shown = new Map([
|
||||
["failing", "<failing>\nold\n</failing>"],
|
||||
["cwd", "stale"],
|
||||
]);
|
||||
const desired = await renderSections(
|
||||
registry.snapshot().sections(),
|
||||
input,
|
||||
shown,
|
||||
(error) => reports.push(error),
|
||||
context,
|
||||
);
|
||||
expect([...desired]).toEqual([
|
||||
["preamble", "You are helpful."],
|
||||
["cwd", "<cwd>\n/repo (git)\n</cwd>"],
|
||||
["failing", "<failing>\nold\n</failing>"],
|
||||
]);
|
||||
expect(reports.map((error) => (error as Error).message)).toEqual(["render failed", "also failed"]);
|
||||
});
|
||||
|
||||
it("propagates section errors after cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("cancelled"));
|
||||
const cancelled = { ...context, abortSignal: controller.signal };
|
||||
const failing = section("a", () => {
|
||||
throw new Error("cancelled");
|
||||
});
|
||||
await expect(renderSections([failing], input, new Map(), () => {}, cancelled)).rejects.toThrow("cancelled");
|
||||
});
|
||||
|
||||
it("replays sections in place, deletes on null, and appends re-additions", () => {
|
||||
const system = (sections: Record<string, string | null>): SystemMessage => ({
|
||||
role: "system",
|
||||
content: "",
|
||||
sections,
|
||||
timestamp: 1,
|
||||
});
|
||||
const shown = replaySections([
|
||||
system({ a: "1", b: "2", c: "3" }),
|
||||
user("x"),
|
||||
system({ b: "20", a: null }),
|
||||
system({ a: "10" }),
|
||||
]);
|
||||
expect([...shown]).toEqual([
|
||||
["b", "20"],
|
||||
["c", "3"],
|
||||
["a", "10"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits minimal value patches, removals, and additions", async () => {
|
||||
const conversation = await root();
|
||||
expect(await apply(conversation, { a: "1", b: "2", c: "3" })).toEqual([{ sections: { a: "1", b: "2", c: "3" } }]);
|
||||
expect(await apply(conversation, { a: "1", b: "20", c: "3", d: "4" })).toEqual([
|
||||
{ sections: { b: "20", d: "4" } },
|
||||
]);
|
||||
expect(await apply(conversation, { a: "1", c: "3", d: "4" })).toEqual([{ sections: { b: null } }]);
|
||||
expect(await apply(conversation, { a: "1", c: "3", d: "4" })).toEqual([]);
|
||||
expect(await apply(conversation, {})).toEqual([{ sections: { a: null, c: null, d: null } }]);
|
||||
});
|
||||
|
||||
it("rewrites order-only changes and re-additions as two entries", async () => {
|
||||
const conversation = await root();
|
||||
await apply(conversation, { a: "1", b: "2" });
|
||||
expect(await apply(conversation, { b: "2", a: "1" })).toEqual([
|
||||
{ sections: { a: null, b: null } },
|
||||
{ sections: { b: "2", a: "1" } },
|
||||
]);
|
||||
const readded = await root();
|
||||
await apply(readded, { a: "1", b: "2", c: "3" });
|
||||
expect(await apply(readded, { a: "1", c: "3" })).toEqual([{ sections: { b: null } }]);
|
||||
// Patching would append `b` after `c`.
|
||||
expect(await apply(readded, { a: "1", b: "2", c: "3" })).toEqual([
|
||||
{ sections: { a: null, c: null } },
|
||||
{ sections: { a: "1", b: "2", c: "3" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rebaselines after a head marker, omitting retained deltas on both sides of it", async () => {
|
||||
const conversation = await root();
|
||||
await apply(conversation, { a: "1", b: "2" });
|
||||
await conversation.commit(
|
||||
(tx) => tx.appendEntry(conversation.id, { kind: "pi.user", model: [user("hi")] }),
|
||||
context,
|
||||
);
|
||||
await apply(conversation, { a: "1", b: "20" });
|
||||
const delta = await lastSystemId(conversation);
|
||||
// The head keeps the delta but cuts its baseline: replay alone would show only `b`.
|
||||
await marker(conversation, delta);
|
||||
expect(await apply(conversation, { a: "1", b: "20" })).toEqual([
|
||||
{ sections: { a: "1", b: "20" }, omit: [delta] },
|
||||
]);
|
||||
const baseline = await lastSystemId(conversation);
|
||||
// A system entry follows the marker now, so later changes are ordinary patches.
|
||||
expect(await apply(conversation, { a: "1", b: "21" })).toEqual([{ sections: { b: "21" } }]);
|
||||
const after = await lastSystemId(conversation);
|
||||
// A second marker keeps deltas from both sides of the first one.
|
||||
await marker(conversation, delta);
|
||||
expect(await apply(conversation, { a: "1", b: "21" })).toEqual([
|
||||
{ sections: { a: "1", b: "21" }, omit: [delta, baseline, after] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes a complete post-head baseline even when replay already matches", async () => {
|
||||
const conversation = await root();
|
||||
await apply(conversation, { a: "1" });
|
||||
const baseline = await lastSystemId(conversation);
|
||||
await marker(conversation, baseline);
|
||||
expect(await apply(conversation, { a: "1" })).toEqual([{ sections: { a: "1" }, omit: [baseline] }]);
|
||||
await marker(conversation, "self");
|
||||
expect(await apply(conversation, {})).toEqual([{ sections: {} }]);
|
||||
expect(await apply(conversation, {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { JsonValue } from "@earendil-works/chord";
|
||||
import { Type } from "@earendil-works/pi-ai";
|
||||
import { createRegistry, defineTask, type ToolRegistration } from "@earendil-works/pi-durable";
|
||||
import { createRegistry, defineTask, GenerationTask, type ToolRegistration } from "@earendil-works/pi-durable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type AppTool = ToolRegistration & { readonly snippet?: string };
|
||||
@@ -202,7 +202,7 @@ describe("registry", () => {
|
||||
const worker = task("worker");
|
||||
registry.tasks.add(worker);
|
||||
expect(() => registry.tasks.add(task("worker"))).toThrow("Task worker is already registered");
|
||||
expect(registry.tasks.list()).toEqual([worker]);
|
||||
expect(registry.tasks.list()).toEqual([GenerationTask, worker]);
|
||||
|
||||
const first = { beforeRun: () => {} };
|
||||
const second = { beforeRun: () => {} };
|
||||
@@ -222,6 +222,22 @@ describe("registry", () => {
|
||||
expect(hooks[1]!.scope).toEqual({ conversationId: 5, subtree: true });
|
||||
});
|
||||
|
||||
it("starts with undisposable, non-overridable built-in tasks", () => {
|
||||
const registry = createRegistry();
|
||||
expect(registry.tasks.list()).toEqual([GenerationTask]);
|
||||
expect(registry.snapshot().task("pi.generation")).toBe(GenerationTask);
|
||||
expect(() => registry.tasks.add({ definition: { ...GenerationTask.definition } })).toThrow(
|
||||
"Task pi.generation is already registered",
|
||||
);
|
||||
expect(
|
||||
registry
|
||||
.snapshot()
|
||||
.conversationSetups()
|
||||
.map(({ key }) => key),
|
||||
).toEqual(["pi"]);
|
||||
expect(() => registry.conversations.setup("pi", () => {})).toThrow("Setup pi is already registered");
|
||||
});
|
||||
|
||||
it("keeps application tool fields on listed tools", () => {
|
||||
const execute = async (args: JsonValue) => ({ details: args });
|
||||
const registry = createRegistry<AppTool>();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
AssistantEntry,
|
||||
ConversationBusy,
|
||||
defineEntry,
|
||||
type EntryId,
|
||||
GenerationTask,
|
||||
LiveDoc,
|
||||
type SubmissionCreate,
|
||||
type SubmissionId,
|
||||
type SubmissionRecord,
|
||||
UserEntry,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { SessionImpl } from "../src/session/session.ts";
|
||||
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
|
||||
import { allEntries, chatSetup, openChat, unanswered } from "./chat-support.ts";
|
||||
import { ControlledStorage, context } from "./session-support.ts";
|
||||
import { deferred } from "./task-support.ts";
|
||||
|
||||
const directories = new Set<string>();
|
||||
|
||||
afterEach(async () => {
|
||||
for (const directory of directories) await rm(directory, { recursive: true, force: true });
|
||||
directories.clear();
|
||||
});
|
||||
|
||||
async function sqlitePath(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-durable-submissions-"));
|
||||
directories.add(directory);
|
||||
return join(directory, "session.sqlite");
|
||||
}
|
||||
|
||||
describe("submissions", () => {
|
||||
it("appends an idle write and settles it done without a turn", async () => {
|
||||
const { harness, root } = await openChat(new ControlledStorage(), chatSetup());
|
||||
const submission = await root.submit({ type: "write", entry: { kind: "note", data: { text: "x" } } }, context);
|
||||
const settled = await submission.wait(context);
|
||||
expect(settled).toEqual({
|
||||
id: submission.id,
|
||||
conversationId: root.id,
|
||||
type: "write",
|
||||
status: "done",
|
||||
entry: expect.any(Number),
|
||||
});
|
||||
const entries = await allEntries(root);
|
||||
expect(entries).toEqual([{ id: settled.entry, conversationId: root.id, kind: "note", data: { text: "x" } }]);
|
||||
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
|
||||
const tasks = await harness.commit((tx) => tx.scanTasks({ conversationId: root.id }, 10), context);
|
||||
expect(tasks.items).toEqual([]);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("places idle input and rejects every submission while busy without writing", async () => {
|
||||
const storage = new ControlledStorage();
|
||||
const setup = chatSetup();
|
||||
setup.now = () => 42;
|
||||
const busy = unanswered();
|
||||
setup.faux.setResponses([busy.step]);
|
||||
const { harness, root } = await openChat(storage, setup);
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
await busy.reached;
|
||||
const record = await submission.status(context);
|
||||
if (record.status !== "placed") throw new Error(`Unexpected ${record.status}`);
|
||||
const entry = await root.commit((tx) => tx.entry(UserEntry, record.entry), context);
|
||||
expect(entry?.model).toEqual([{ role: "user", content: "hi", timestamp: 42 }]);
|
||||
const live = await harness.snapshot(LiveDoc, root.id, context);
|
||||
expect(live?.run?.inputs).toEqual([submission.id]);
|
||||
expect((await harness.getTask(live!.run!.taskId, context))?.kind).toBe("pi.generation");
|
||||
|
||||
const commits = storage.commits.length;
|
||||
for (const whenBusy of ["steer", "followUp", "reject"] as const) {
|
||||
const busy = root.submit({ type: "input", content: "again", whenBusy }, context);
|
||||
await expect(busy).rejects.toBeInstanceOf(ConversationBusy);
|
||||
}
|
||||
await expect(root.submit({ type: "write", entry: { kind: "note" } }, context)).rejects.toMatchObject({
|
||||
name: "ConversationBusy",
|
||||
conversationId: root.id,
|
||||
});
|
||||
expect(storage.commits.length).toBe(commits);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("deduplicates request IDs per conversation before any write", async () => {
|
||||
const storage = new ControlledStorage();
|
||||
const setup = chatSetup();
|
||||
const busy = unanswered();
|
||||
setup.faux.setResponses([busy.step]);
|
||||
const { harness, root } = await openChat(storage, setup);
|
||||
const first = await root.submit({ type: "input", content: "hi", requestId: "r1" }, context);
|
||||
await busy.reached;
|
||||
const commits = storage.commits.length;
|
||||
// Deduplication runs before the busy check.
|
||||
const again = await root.submit({ type: "input", content: "different", requestId: "r1" }, context);
|
||||
expect(again.id).toBe(first.id);
|
||||
expect(storage.commits.length).toBe(commits);
|
||||
await expect(root.submit({ type: "write", entry: { kind: "note" }, requestId: "r1" }, context)).rejects.toThrow(
|
||||
"Request r1 already identifies a submission of type input",
|
||||
);
|
||||
expect(await first.status(context)).toMatchObject({ requestId: "r1", status: "placed" });
|
||||
|
||||
const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
||||
const write = await other.submit({ type: "write", entry: { kind: "note" }, requestId: "r1" }, context);
|
||||
expect(write.id).not.toBe(first.id);
|
||||
expect((await other.submit({ type: "write", entry: { kind: "note" }, requestId: "r1" }, context)).id).toBe(
|
||||
write.id,
|
||||
);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reports abort results and looks submissions up by conversation", async () => {
|
||||
const setup = chatSetup();
|
||||
const release = deferred();
|
||||
setup.faux.setResponses([
|
||||
async () => {
|
||||
await release.promise;
|
||||
return fauxAssistantMessage("answer");
|
||||
},
|
||||
]);
|
||||
const { harness, root } = await openChat(new ControlledStorage(), setup);
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
expect(await submission.abort(context)).toBe("already_placed");
|
||||
expect(await harness.abortSubmission(submission.id, context, root.id)).toBe("already_placed");
|
||||
const other = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
||||
expect(await harness.abortSubmission(submission.id, context, other.id)).toBe("not_found");
|
||||
expect(await harness.abortSubmission(999_999 as SubmissionId, context)).toBe("not_found");
|
||||
expect(await harness.submission(999_999 as SubmissionId, context)).toBeUndefined();
|
||||
|
||||
release.resolve();
|
||||
await submission.wait(context);
|
||||
expect(await submission.abort(context)).toBe("settled");
|
||||
expect(await harness.abortSubmission(submission.id, context)).toBe("settled");
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("cancels only a wait and rejects pending waits on close", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([unanswered().step]);
|
||||
const { harness, root } = await openChat(new ControlledStorage(), setup);
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
const controller = new AbortController();
|
||||
const cancelled = submission.wait({ ...context, abortSignal: controller.signal });
|
||||
const pending = submission.wait(context);
|
||||
controller.abort(new Error("stop waiting"));
|
||||
await expect(cancelled).rejects.toThrow("stop waiting");
|
||||
expect((await submission.status(context)).status).toBe("placed");
|
||||
await harness.close(context);
|
||||
await expect(pending).rejects.toThrow("Harness is closed");
|
||||
});
|
||||
|
||||
it("rejects a wait whose submission read spans the start of close", async () => {
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let hold = false;
|
||||
class HeldReads extends ControlledStorage {
|
||||
override async submission(
|
||||
id: SubmissionId,
|
||||
callContext: typeof context,
|
||||
): Promise<SubmissionRecord | undefined> {
|
||||
if (hold) {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return super.submission(id, callContext);
|
||||
}
|
||||
}
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([unanswered().step]);
|
||||
const { harness, root } = await openChat(new HeldReads(), setup);
|
||||
const submission = await root.submit({ type: "input", content: "hi" }, context);
|
||||
hold = true;
|
||||
const waiting = submission.wait(context);
|
||||
await entered.promise;
|
||||
const closing = harness.close(context);
|
||||
release.resolve();
|
||||
await expect(waiting).rejects.toThrow("Harness is closed");
|
||||
await closing;
|
||||
});
|
||||
|
||||
it("reacquires a submission after reopen and settles it durably", async () => {
|
||||
const path = await sqlitePath();
|
||||
const setup = chatSetup();
|
||||
// The first process never answers; the reopened one does.
|
||||
const busy = unanswered();
|
||||
setup.faux.setResponses([busy.step, fauxAssistantMessage("after reopen")]);
|
||||
let opened = await openChat(await openNodeSqliteStorage(path), setup);
|
||||
const id = (await opened.root.submit({ type: "input", content: "hi", requestId: "print" }, context)).id;
|
||||
await busy.reached;
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await openChat(await openNodeSqliteStorage(path), setup);
|
||||
const submission = (await opened.harness.submission(id, context))!;
|
||||
expect((await submission.status(context)).status).toBe("placed");
|
||||
opened.harness.resume();
|
||||
const settled = await submission.wait(context);
|
||||
if (settled.status !== "done" || settled.type !== "input") throw new Error(`Unexpected ${settled.status}`);
|
||||
await opened.harness.close(context);
|
||||
|
||||
opened = await openChat(await openNodeSqliteStorage(path), setup);
|
||||
expect(await (await opened.harness.submission(id, context))!.wait(context)).toEqual(settled);
|
||||
const again = await opened.root.submit({ type: "input", content: "hi", requestId: "print" }, context);
|
||||
expect(again.id).toBe(id);
|
||||
await opened.harness.close(context);
|
||||
});
|
||||
|
||||
it("enables scheduling when a caller submits or waits", async () => {
|
||||
const setup = chatSetup();
|
||||
setup.faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
const { harness, root } = await openChat(new ControlledStorage(), setup);
|
||||
// No resume(): submitting asks for progress.
|
||||
expect((await (await root.submit({ type: "input", content: "hi" }, context)).wait(context)).status).toBe("done");
|
||||
await harness.close(context);
|
||||
|
||||
const passive = await openChat(new ControlledStorage(), chatSetup());
|
||||
const taskId = await passive.root.commit((tx) => tx.createTask(GenerationTask, {}), context);
|
||||
// A committed task alone does not start scheduling; waiting for it does.
|
||||
expect((await passive.harness.getTask(taskId, context))?.state.status).toBe("pending");
|
||||
expect((await passive.harness.waitForTask(taskId, context)).state.status).toBe("terminal");
|
||||
await passive.harness.close(context);
|
||||
});
|
||||
|
||||
it("settles submissions by their current record in the transaction", async () => {
|
||||
const { harness, root } = await openChat(new ControlledStorage(), chatSetup());
|
||||
const session = harness as unknown as SessionImpl;
|
||||
const entry = await root.commit(async (tx) => (await tx.appendEntry(root.id, { kind: "note" })).id, context);
|
||||
const create = (record: SubmissionCreate) =>
|
||||
session.commitWith(async (tx) => (await tx.createSubmission(record)).id, context);
|
||||
const queued = await create({ conversationId: root.id, type: "input", status: "queued" });
|
||||
const write = await create({ conversationId: root.id, type: "write", status: "queued" });
|
||||
const answer = { status: "done", answer: entry } as const;
|
||||
await expect(root.commit((tx) => tx.settleSubmission(queued, answer), context)).rejects.toThrow(
|
||||
"is not a placed input",
|
||||
);
|
||||
await expect(root.commit((tx) => tx.settleSubmission(write, answer), context)).rejects.toThrow(
|
||||
"is not a placed input",
|
||||
);
|
||||
await expect(
|
||||
root.commit(
|
||||
(tx) => tx.settleSubmission(999_999 as SubmissionId, { status: "unanswered", reason: "x" }),
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow("does not exist");
|
||||
|
||||
// A submission created earlier in the same commit settles; a second settlement leaves the first.
|
||||
const placed = await session.commitWith(async (tx) => {
|
||||
const { id } = await tx.createSubmission({ conversationId: root.id, type: "input", status: "placed", entry });
|
||||
tx.settleSubmission(id, answer);
|
||||
tx.settleSubmission(id, { status: "unanswered", reason: "late" });
|
||||
return id;
|
||||
}, context);
|
||||
expect(await (await harness.submission(placed, context))!.status(context)).toMatchObject({
|
||||
status: "done",
|
||||
entry,
|
||||
answer: entry,
|
||||
});
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("appends and reads typed entries through tokens", async () => {
|
||||
const Counter = defineEntry<{ n: number }>("app.counter");
|
||||
const Marker = defineEntry("app.marker");
|
||||
const { harness, root } = await openChat(new ControlledStorage(), chatSetup());
|
||||
const counter = await root.commit((tx) => tx.appendEntry(Counter, root.id, { data: { n: 1 } }), context);
|
||||
const n: number = counter.data.n;
|
||||
expect(n).toBe(1);
|
||||
expect(counter).toEqual({ id: counter.id, conversationId: root.id, kind: "app.counter", data: { n: 1 } });
|
||||
const marker = await root.commit((tx) => tx.appendEntry(Marker, root.id, {}), context);
|
||||
expect(marker.kind).toBe("app.marker");
|
||||
expect(await root.commit((tx) => tx.entry(Counter, counter.id), context)).toEqual(counter);
|
||||
expect(await root.commit((tx) => tx.entry(Marker, counter.id), context)).toBeUndefined();
|
||||
expect(await root.commit((tx) => tx.entry(Counter, 999_999 as EntryId), context)).toBeUndefined();
|
||||
expect(Counter.is(counter)).toBe(true);
|
||||
expect(AssistantEntry.is(counter)).toBe(false);
|
||||
expect([UserEntry.kind, AssistantEntry.kind]).toEqual(["pi.user", "pi.assistant"]);
|
||||
await harness.close(context);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
defineEntry,
|
||||
defineTask,
|
||||
type EntryId,
|
||||
type EntryRecord,
|
||||
Harness,
|
||||
MemoryStorage,
|
||||
type RegistryReader,
|
||||
@@ -183,7 +182,7 @@ describe("task phases", () => {
|
||||
scope: "task",
|
||||
initial: () => ({ lines: [] }),
|
||||
});
|
||||
const Answer = defineEntry<EntryRecord & { readonly kind: "answer" }>("answer");
|
||||
const Answer = defineEntry("answer");
|
||||
const Child = oneStep("test.child", async (_task, runtime, ctx) => {
|
||||
await runtime.commit(() => completed(null), ctx);
|
||||
});
|
||||
@@ -370,6 +369,49 @@ describe("task runtime", () => {
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("reads committed documents and context through the runtime, and forwards the clock and reports", async () => {
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "test.runtime-notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
let captured: StepRuntime<null> | undefined;
|
||||
const seen: unknown[] = [];
|
||||
const Reader = oneStep("test.reader", async (_task, runtime, ctx) => {
|
||||
captured = runtime;
|
||||
const [first, second] = [...(await runtime.context(runtime.conversationId, ctx)).entries].map(
|
||||
(entry) => entry.id,
|
||||
);
|
||||
seen.push((await runtime.snapshot(Notes, runtime.conversationId, ctx))?.text);
|
||||
seen.push((await runtime.snapshotAsOf(Notes, runtime.conversationId, first!, ctx))?.text);
|
||||
seen.push((await runtime.context(runtime.conversationId, ctx, first)).entries.length);
|
||||
seen.push((await runtime.context(runtime.conversationId, ctx, second)).messages.length);
|
||||
seen.push(runtime.now());
|
||||
runtime.report(new Error("reported"));
|
||||
await runtime.commit(() => completed(null), ctx);
|
||||
});
|
||||
const { harness, root, reports } = await openRoot([Reader], { now: () => 1234 });
|
||||
for (const text of ["one", "two"]) {
|
||||
await root.commit(async (tx) => {
|
||||
(await tx.doc(Notes, root.id)).text = text;
|
||||
await tx.appendEntry(root.id, { kind: "message", model: [user(text)] });
|
||||
}, context);
|
||||
}
|
||||
const id = await start(root, Reader);
|
||||
harness.resume();
|
||||
await harness.waitForTask(id, context);
|
||||
expect(seen).toEqual(["two", "one", 1, 2, 1234]);
|
||||
expect(reports).toEqual([new Error("reported")]);
|
||||
// The step after the phase ends the invocation.
|
||||
await flush();
|
||||
await expect(captured!.snapshot(Notes, root.id, context)).rejects.toThrow("invocation has ended");
|
||||
await expect(captured!.context(root.id, context)).rejects.toThrow("invocation has ended");
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("orders runtime commits against the step: one queued before it lands, one after it rejects", async () => {
|
||||
const storage = new ControlledStorage();
|
||||
let before: Promise<void> | undefined;
|
||||
|
||||
@@ -103,6 +103,8 @@ class ReopeningStorage implements Storage {
|
||||
scanTasks: Storage["scanTasks"] = (query, limit, cursor, readContext) =>
|
||||
this.current.scanTasks(query, limit, cursor, readContext);
|
||||
submission: Storage["submission"] = (id, readContext) => this.current.submission(id, readContext);
|
||||
scanSubmissions: Storage["scanSubmissions"] = (query, limit, cursor, readContext) =>
|
||||
this.current.scanSubmissions(query, limit, cursor, readContext);
|
||||
submissionByRequest: Storage["submissionByRequest"] = (conversationId, requestId, readContext) =>
|
||||
this.current.submissionByRequest(conversationId, requestId, readContext);
|
||||
findDocument: Storage["findDocument"] = (address, at, readContext) =>
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
// A tour of the durable Session and Harness APIs in fourteen small examples.
|
||||
// Run from packages/durable:
|
||||
// node --conditions=source --experimental-strip-types test/scratch.ts
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createModels,
|
||||
type Message,
|
||||
type StopReason,
|
||||
type ToolResultMessage,
|
||||
Type,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import {
|
||||
ConversationConfig,
|
||||
type ConversationId,
|
||||
createRegistry,
|
||||
createSession,
|
||||
defineDoc,
|
||||
defineEntry,
|
||||
defineTask,
|
||||
type EntryRecord,
|
||||
Harness,
|
||||
MemoryStorage,
|
||||
type PromptInput,
|
||||
type ToolRegistration,
|
||||
} from "../src/index.ts";
|
||||
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
|
||||
|
||||
// A Session stores conversations, transcript entries, tasks, and documents.
|
||||
// MemoryStorage keeps everything in memory; other storage backends keep it on disk.
|
||||
const session = createSession(new MemoryStorage());
|
||||
|
||||
// Every Session call takes a context, which is used for cancellation.
|
||||
// BACKGROUND_CONTEXT means "never cancel".
|
||||
const context = BACKGROUND_CONTEXT;
|
||||
|
||||
// ─── 1. Create a standalone conversation ────────────────────────────────────
|
||||
// All writes happen inside session.commit(). The callback receives a
|
||||
// transaction `tx`; everything it writes is saved together when the callback
|
||||
// returns, or discarded if it throws.
|
||||
// "ownerless" means no task created this conversation.
|
||||
const standalone = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
console.log("1. standalone conversation:", standalone);
|
||||
|
||||
// ─── 2. Store document state next to transcript entries ─────────────────────
|
||||
// A document is a JSON object attached to something; here, one per conversation.
|
||||
// "rewindable" keeps old values readable, so you can ask what the document
|
||||
// looked like when a particular entry was written.
|
||||
// `fork` says what a forked copy of the conversation starts with (see example 3).
|
||||
const Notes = defineDoc<{ text: string }>({
|
||||
kind: "example.notes",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf", // a fork starts with the value these notes had at the fork entry
|
||||
initial: () => ({ text: "" }),
|
||||
});
|
||||
|
||||
const chat = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
|
||||
// tx.doc() returns an editable copy of the document (created on first use).
|
||||
// Plain assignments to it are saved when the commit finishes.
|
||||
const firstEntry = await session.commit(async (tx) => {
|
||||
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "hello" });
|
||||
(await tx.doc(Notes, chat.id)).text = "after hello";
|
||||
return entry;
|
||||
}, context);
|
||||
|
||||
const secondEntry = await session.commit(async (tx) => {
|
||||
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "goodbye" });
|
||||
(await tx.doc(Notes, chat.id)).text = "after goodbye";
|
||||
return entry;
|
||||
}, context);
|
||||
|
||||
// snapshot() reads the latest value. snapshotAsOf() reads the value that was
|
||||
// saved in the same commit as the given entry.
|
||||
console.log("2. latest notes:", await session.snapshot(Notes, chat.id, context));
|
||||
console.log("2. notes at first entry:", await session.snapshotAsOf(Notes, chat.id, firstEntry.id, context));
|
||||
console.log("2. notes at second entry:", await session.snapshotAsOf(Notes, chat.id, secondEntry.id, context));
|
||||
|
||||
// ─── 3. Fork a conversation ─────────────────────────────────────────────────
|
||||
// A fork is a new conversation that continues from one entry of another. It
|
||||
// sees the parent's transcript up to that entry, and each document follows its
|
||||
// own `fork` setting. Notes uses "asOf", so the fork starts with the notes
|
||||
// value from the fork entry.
|
||||
const branch = await session.commit(
|
||||
(tx) => tx.forkConversation(chat.id, firstEntry.id, { ownership: { kind: "ownerless" } }),
|
||||
context,
|
||||
);
|
||||
|
||||
// scanEntries() pages through visible entries, newest first. The fork sees
|
||||
// "hello" (inherited from the parent) but not "goodbye", which came later.
|
||||
const branchEntries = await session.commit((tx) => tx.scanEntries({ conversationId: branch.id }, 10), context);
|
||||
console.log(
|
||||
"3. fork transcript:",
|
||||
branchEntries.items.map((entry) => entry.data),
|
||||
);
|
||||
console.log("3. fork notes:", await session.snapshot(Notes, branch.id, context));
|
||||
|
||||
// The fork's copy is independent: editing it leaves the parent unchanged.
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Notes, branch.id)).text = "changed only in the fork";
|
||||
}, context);
|
||||
console.log("3. fork notes after edit:", await session.snapshot(Notes, branch.id, context));
|
||||
console.log("3. parent notes after edit:", await session.snapshot(Notes, chat.id, context));
|
||||
|
||||
// ─── 4. Background task that owns a child conversation ──────────────────────
|
||||
// A typical agent setup: a background task supervises a helper conversation,
|
||||
// and the main conversation keeps a registry that maps agent names to their
|
||||
// conversations. All three are created in one commit, so after a crash either
|
||||
// all of them exist or none do.
|
||||
|
||||
// A task definition needs a name, a version, the task's starting state, a
|
||||
// handler for every phase, and an abort handler (example 13 runs a task).
|
||||
// This example only creates the task record; a plain Session never runs it.
|
||||
const Supervisor = defineTask<null, { phase: "ready" }, null>({
|
||||
name: "example.supervisor",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "ready" }),
|
||||
phases: { ready: async () => {} },
|
||||
abort: async () => {},
|
||||
});
|
||||
|
||||
// "latest" keeps only the current value. "initial" means forks of this
|
||||
// conversation start without a registry, so a child doesn't inherit its
|
||||
// parent's list of agents.
|
||||
const AgentRegistry = defineDoc<{
|
||||
agents: Record<string, { conversationId: ConversationId; requestId: string }>;
|
||||
}>({
|
||||
kind: "example.agent-registry",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({ agents: {} }),
|
||||
});
|
||||
|
||||
const main = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
|
||||
|
||||
const setup = await session.commit(async (tx) => {
|
||||
// `background: true` means the task is side work: waiting for the main
|
||||
// conversation to finish does not wait for it.
|
||||
const supervisorId = await tx.createTask(Supervisor, null, { conversationId: main.id, background: true });
|
||||
|
||||
// The child records that it belongs to the supervisor task. The task was
|
||||
// created a few lines above in this same commit, which is allowed.
|
||||
const child = await tx.createConversation({ ownership: { kind: "task", taskId: supervisorId } });
|
||||
|
||||
// requestId is a fixed name for the child's first message. Later code sends
|
||||
// that message using this requestId, so a retry after a crash cannot
|
||||
// deliver it twice.
|
||||
(await tx.doc(AgentRegistry, main.id)).agents.researcher = {
|
||||
conversationId: child.id,
|
||||
requestId: `researcher:first-message:${supervisorId}`,
|
||||
};
|
||||
return { supervisorId, child };
|
||||
}, context);
|
||||
|
||||
console.log("4. supervisor task:", setup.supervisorId);
|
||||
console.log("4. child conversation:", setup.child);
|
||||
console.log("4. registry:", await session.snapshot(AgentRegistry, main.id, context));
|
||||
|
||||
// ─── 5. Expose a document through Chord ─────────────────────────────────────
|
||||
// documentState() never creates a document. It returns a hydrated read-only
|
||||
// Chord state bound to the current concrete incarnation.
|
||||
const notesState = await session.documentState(Notes, chat.id, context);
|
||||
if (notesState === undefined) throw new Error("notes are absent");
|
||||
const stopNotes = notesState.subscribe((value, _deliveryContext, delivery) => {
|
||||
console.log("5. Chord notes:", delivery.kind, delivery.sequence, value);
|
||||
});
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Notes, chat.id)).text = "published through Chord";
|
||||
}, context);
|
||||
stopNotes();
|
||||
notesState.dispose();
|
||||
|
||||
// ─── 6. Serialize asynchronous document work ────────────────────────────────
|
||||
// A watch starts from one stable acquisition revision. Slow callbacks never
|
||||
// overlap; exact committed frames buffer, with a full-value reset after 100.
|
||||
const notesWatch = await session.watchDoc(Notes, chat.id, context);
|
||||
if (notesWatch === undefined) throw new Error("notes are absent");
|
||||
console.log("6. watch baseline:", notesWatch.value);
|
||||
const delivered = new Promise<void>((resolve) => {
|
||||
notesWatch.start(async (value, _ops, _deliveryContext) => {
|
||||
console.log("6. watch update:", value);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Notes, chat.id)).text = "observed asynchronously";
|
||||
}, context);
|
||||
await delivered;
|
||||
await notesWatch.stop();
|
||||
|
||||
await session.close(context);
|
||||
|
||||
// ─── 7. Open a Harness with a registry ──────────────────────────────────────
|
||||
// A Harness is a Session plus conversation handles. Extension code (tools,
|
||||
// hooks, tasks, system prompt sections) lives in a registry the
|
||||
// application owns. Nothing in the registry is saved; it is this process's code.
|
||||
// Apps may attach their own metadata to tools, such as a prompt snippet.
|
||||
type AppTool = ToolRegistration & { readonly snippet?: string };
|
||||
|
||||
function exampleTool(name: string, description: string): AppTool {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parameters: Type.Object({ path: Type.String() }),
|
||||
snippet: `Use ${name} for files.`,
|
||||
execute: async (args) => ({ content: [{ type: "text", text: `${name} ${JSON.stringify(args)}` }] }),
|
||||
};
|
||||
}
|
||||
|
||||
const registry = createRegistry<AppTool>();
|
||||
registry.tools.add(exampleTool("read", "Read a file"));
|
||||
const writeRegistration = registry.tools.add(exampleTool("write", "Write a file"));
|
||||
const grepRegistration = registry.tools.add(exampleTool("grep", "Search files"));
|
||||
|
||||
// `models` is pi-ai's model access; generation uses it in later packages.
|
||||
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
|
||||
|
||||
// The root conversation always has ID 1. The first root() call creates it,
|
||||
// its configuration, and whatever `init` writes, all in one commit. Later
|
||||
// calls, including after a restart, return it and ignore `init`.
|
||||
const root = await harness.root(context, {
|
||||
init: async (tx, rootId) => {
|
||||
(await tx.doc(Notes, rootId)).text = "root notes";
|
||||
(await tx.doc(ConversationConfig, rootId)).thinkingLevel = "low";
|
||||
},
|
||||
});
|
||||
console.log("7. root:", root.id, await harness.snapshot(Notes, root.id, context));
|
||||
console.log("7. root config:", root.id, await harness.snapshot(ConversationConfig, root.id, context));
|
||||
|
||||
// ─── 8. Conversation configuration ──────────────────────────────────────────
|
||||
// Model, thinking level, and active tool names live in the built-in
|
||||
// ConversationConfig document. New conversations start with every registered
|
||||
// tool active. Each setter is one commit.
|
||||
console.log("8. active tools:", await root.getActiveTools(context));
|
||||
await root.setModel({ provider: "anthropic", modelId: "claude-sonnet-4-5" }, context);
|
||||
await root.setThinkingLevel("high", context);
|
||||
await root.setActiveTools(["write", "read"], context);
|
||||
console.log("8. snippet kept on the app tool:", registry.tools.list()[0]!.snippet);
|
||||
console.log("8. model:", await root.getModel(context), "thinking:", await root.getThinkingLevel(context));
|
||||
|
||||
// Adding a name that is not registered is rejected, and nothing is written.
|
||||
await root
|
||||
.setActiveTools(["read", "find"], context)
|
||||
.catch((error: Error) => console.log("8. rejected:", error.message));
|
||||
|
||||
// Names that were already active are never rechecked. After "write" is
|
||||
// unregistered it stays in the configuration; requests just stop offering it
|
||||
// until it is registered again.
|
||||
writeRegistration.dispose();
|
||||
await root.setActiveTools(["write", "read", "grep"], context);
|
||||
console.log("8. active tools without a registered write:", await root.getActiveTools(context));
|
||||
console.log(
|
||||
"8. registered tools:",
|
||||
registry.tools.list().map((entry) => entry.name),
|
||||
);
|
||||
|
||||
// ─── 9. Conversations and forks ─────────────────────────────────────────────
|
||||
// Conversation handles are stateless; compare them by id. They bind commits
|
||||
// to their conversation.
|
||||
const MessageEntry = defineEntry<EntryRecord & { readonly kind: "message" }>("message");
|
||||
const hello = await root.commit(
|
||||
(tx) => tx.appendEntry(root.id, { kind: "message", model: [{ role: "user", content: "hello", timestamp: 1 }] }),
|
||||
context,
|
||||
);
|
||||
console.log("9. typed entry:", MessageEntry.is(hello));
|
||||
|
||||
// createConversation() and fork() run `init` in the creating commit. A fork
|
||||
// starts with the configuration the parent had at the fork entry.
|
||||
const helper = await harness.createConversation(
|
||||
{
|
||||
ownership: { kind: "ownerless" },
|
||||
init: async (tx, id) => {
|
||||
(await tx.doc(ConversationConfig, id)).activeTools = ["read"];
|
||||
},
|
||||
},
|
||||
context,
|
||||
);
|
||||
const retry = await root.fork(hello.id, { ownership: { kind: "ownerless" } }, context);
|
||||
console.log("9. helper tools:", await helper.getActiveTools(context));
|
||||
console.log("9. fork thinking:", await retry.getThinkingLevel(context));
|
||||
console.log("9. lookup:", (await harness.conversation(retry.id, context))?.id === retry.id);
|
||||
|
||||
// ─── 10. Transcript history and model context ───────────────────────────────
|
||||
// Entries are immutable. `model` holds the messages an entry contributes to
|
||||
// the next model request; `data` is for the app only. context() turns the
|
||||
// stored transcript into those request messages:
|
||||
// - an entry with `head` starts a new context; older entries stay stored,
|
||||
// - `edits` replace or omit what an earlier entry contributes,
|
||||
// - aborted, error, and deferred assistant messages are not sent,
|
||||
// - tool results are sent right after their call, in call order,
|
||||
// - a call without a result gets a synthesized error result.
|
||||
function assistantMessage(text: string, calls: readonly string[] = [], stopReason?: StopReason): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text },
|
||||
...calls.map((id) => ({ type: "toolCall" as const, id, name: "read", arguments: {} })),
|
||||
],
|
||||
api: "example",
|
||||
provider: "example",
|
||||
model: "example",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: stopReason ?? (calls.length > 0 ? "toolUse" : "stop"),
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessage(id: string): ToolResultMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: id,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: `file ${id}` }],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function show(message: Message): string {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return `user: ${message.content as string}`;
|
||||
case "system":
|
||||
return `system: ${JSON.stringify(message.sections)}`;
|
||||
case "assistant":
|
||||
return `assistant: ${message.content
|
||||
.map((part) => (part.type === "text" ? part.text : part.type === "toolCall" ? `call(${part.id})` : ""))
|
||||
.join(" ")}`;
|
||||
case "toolResult":
|
||||
return `result(${message.toolCallId})${message.isError ? " error" : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
const transcript = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
||||
const say = (kind: string, ...model: Message[]) =>
|
||||
transcript.commit((tx) => tx.appendEntry(transcript.id, { kind, model }), context);
|
||||
|
||||
const question = await say("message", { role: "user", content: "read a and b", timestamp: 1 });
|
||||
await say("message", assistantMessage("I crashed", [], "aborted")); // stored, never sent
|
||||
const calls = await say("message", assistantMessage("reading", ["a", "b"]));
|
||||
await say("message", toolResultMessage("b")); // results finish out of order
|
||||
await say("pi.system", { role: "system", content: "", sections: { cwd: "<cwd>/repo</cwd>" }, timestamp: 4 });
|
||||
await say("message", toolResultMessage("a"));
|
||||
await say("message", assistantMessage("a and b look fine"));
|
||||
await transcript.commit(
|
||||
(tx) =>
|
||||
tx.appendEntry(transcript.id, {
|
||||
kind: "edit",
|
||||
data: "user fixed a typo",
|
||||
edits: [
|
||||
{
|
||||
target: question.id,
|
||||
action: "replace",
|
||||
messages: [{ role: "user", content: "read files a and b", timestamp: 1 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
context,
|
||||
);
|
||||
await transcript.commit((tx) => tx.appendEntry(transcript.id, { kind: "note", data: "display only" }), context);
|
||||
|
||||
let transcriptView = await transcript.context(context);
|
||||
console.log(
|
||||
"10. raw active entries:",
|
||||
transcriptView.entries.map((entry) => entry.kind),
|
||||
);
|
||||
console.log("10. request messages:", transcriptView.messages.map(show));
|
||||
|
||||
// A fork at the tool call has no results yet; context() fills them in.
|
||||
const cut = await transcript.fork(calls.id, { ownership: { kind: "ownerless" } }, context);
|
||||
console.log("10. fork messages:", (await cut.context(context)).messages.map(show));
|
||||
|
||||
// A headed summary replaces everything before the entry it points at.
|
||||
// "self" points the head at the summary entry itself.
|
||||
await transcript.commit(
|
||||
(tx) =>
|
||||
tx.appendEntry(transcript.id, {
|
||||
kind: "summary",
|
||||
head: "self",
|
||||
model: [{ role: "user", content: "Summary: a and b are fine.", timestamp: 5 }],
|
||||
}),
|
||||
context,
|
||||
);
|
||||
transcriptView = await transcript.context(context);
|
||||
console.log("10. after summary:", transcriptView.head?.kind, transcriptView.messages.map(show));
|
||||
|
||||
// entries() pages the stored transcript, newest first, including inherited
|
||||
// parent entries. Nothing is ever deleted by heads or edits.
|
||||
const history = await transcript.entries({}, 3, undefined, context);
|
||||
console.log(
|
||||
"10. newest stored entries:",
|
||||
history.items.map((entry) => entry.kind),
|
||||
"more:",
|
||||
history.next !== undefined,
|
||||
);
|
||||
|
||||
// ─── 11. Reload extension code ──────────────────────────────────────────────
|
||||
// batch() publishes a replacement at once, so no snapshot ever sees the tool
|
||||
// missing. Work that already started keeps using the snapshot it took.
|
||||
registry.batch(() => {
|
||||
grepRegistration.dispose();
|
||||
registry.tools.add(exampleTool("grep", "Search files, faster"));
|
||||
});
|
||||
console.log(
|
||||
"11. tools after reload:",
|
||||
registry.tools.list().map((entry) => `${entry.name}: ${entry.description}`),
|
||||
);
|
||||
|
||||
// ─── 12. Register system prompt sections ────────────────────────────────────
|
||||
// Pico stores no prompt state. Before each model request, the registry's
|
||||
// sections render the desired prompt, and only the difference to what the
|
||||
// model already saw is appended to the transcript as a `pi.system` entry.
|
||||
// Sections read per-conversation data through `input.read`; here a coding
|
||||
// agent keeps its own profile document.
|
||||
const AgentProfile = defineDoc<{ role: "main" | "subagent"; cwd: string }>({
|
||||
kind: "example.agent-profile",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "rewindable",
|
||||
fork: "asOf",
|
||||
initial: () => ({ role: "main", cwd: "/" }),
|
||||
});
|
||||
await root.commit(async (tx) => {
|
||||
(await tx.doc(AgentProfile, root.id)).cwd = "/repo";
|
||||
}, context);
|
||||
|
||||
const prompt = registry.batch(() => {
|
||||
// `tag: false` sends the text as is; by default it is wrapped in <key>...</key>.
|
||||
registry.systemPrompt.section("preamble", () => "You are a coding agent.", { tag: false });
|
||||
registry.systemPrompt.section("cwd", async (input, renderContext) => {
|
||||
return (await input.read.snapshot(AgentProfile, input.conversationId, renderContext))?.cwd;
|
||||
});
|
||||
// Returning undefined omits the section, here for subagents.
|
||||
registry.systemPrompt.section("agents_md", async (input, renderContext) => {
|
||||
const profile = await input.read.snapshot(AgentProfile, input.conversationId, renderContext);
|
||||
return profile?.role === "subagent" ? undefined : "Run npm run check after changes.";
|
||||
});
|
||||
// Sections see the offered tools, including the app's own metadata.
|
||||
registry.systemPrompt.section("tools", (input) =>
|
||||
input.tools.length === 0
|
||||
? undefined
|
||||
: input.tools.map((entry) => `- ${entry.name}: ${entry.snippet ?? entry.description}`).join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
// Another extension decorates a section without replacing it.
|
||||
registry.systemPrompt.wrap("preamble", "tone", (section) => ({
|
||||
...section,
|
||||
render: async (input, renderContext) => `${await section.render(input, renderContext)} Be terse.`,
|
||||
}));
|
||||
|
||||
// Package 15's request preparation runs these for every request. This loop
|
||||
// only stands in for it to show the output.
|
||||
const promptSnapshot = registry.snapshot();
|
||||
const input: PromptInput<AppTool> = {
|
||||
conversationId: root.id,
|
||||
tools: promptSnapshot.tools(),
|
||||
shown: {},
|
||||
thinkingLevel: await root.getThinkingLevel(context),
|
||||
read: harness,
|
||||
};
|
||||
const rendered: string[] = [];
|
||||
for (const section of promptSnapshot.sections()) {
|
||||
const text = await section.render(input, context);
|
||||
if (text === undefined) continue;
|
||||
rendered.push(section.tag === false ? text : `<${section.key}>\n${text}\n</${section.key}>`);
|
||||
}
|
||||
console.log(`12. rendered prompt:\n${rendered.join("\n")}`);
|
||||
|
||||
prompt.dispose();
|
||||
|
||||
// ─── 13. Run a durable task ─────────────────────────────────────────────────
|
||||
// A task is a small state machine. Its state, the checkpoint, is saved after
|
||||
// every step, so after a crash the next open continues from the last saved
|
||||
// step. The usual pattern: save what you are about to do, do it, then save
|
||||
// the result. A crash between doing and saving reruns that step, so the step
|
||||
// must be safe to repeat; here the fake payment service ignores a repeated key.
|
||||
const payments = new Map<string, number>();
|
||||
type PaymentState = { phase: "prepare" } | { phase: "charge"; key: string };
|
||||
const Payment = defineTask<{ amount: number }, PaymentState, { receipt: number }>({
|
||||
name: "example.payment",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "prepare" }),
|
||||
// One handler per phase. Each must save progress through runtime.commit():
|
||||
// its callback returns the next checkpoint or the final outcome, and that
|
||||
// state is saved in the same commit as everything else the callback wrote.
|
||||
phases: {
|
||||
prepare: async (task, runtime, taskContext) => {
|
||||
await runtime.commit(
|
||||
() => ({ status: "running", checkpoint: { phase: "charge", key: `payment-${task.id}` } }),
|
||||
taskContext,
|
||||
);
|
||||
},
|
||||
charge: async (task, runtime, taskContext) => {
|
||||
const key = task.state.checkpoint.key;
|
||||
if (!payments.has(key)) payments.set(key, task.input.amount * 100);
|
||||
const receipt = payments.get(key)!;
|
||||
await runtime.commit(
|
||||
() => ({ status: "terminal", outcome: { status: "completed", result: { receipt } } }),
|
||||
taskContext,
|
||||
);
|
||||
},
|
||||
},
|
||||
// Runs instead of the phases after harness.abortTask(); it decides the outcome.
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
|
||||
},
|
||||
});
|
||||
|
||||
// The Harness finds task code by name in the registry. Nothing runs until
|
||||
// resume(); a host calls it once it is ready for work to start.
|
||||
registry.tasks.add(Payment);
|
||||
const paymentId = await root.commit((tx) => tx.createTask(Payment, { amount: 5 }), context);
|
||||
harness.resume();
|
||||
// The finished task record is the durable receipt; waitForTask() knows its result type.
|
||||
const paid = await harness.waitForTask(paymentId, context);
|
||||
console.log("13. payment outcome:", paid.state.outcome);
|
||||
|
||||
await harness.close(context);
|
||||
|
||||
// ─── 14. Close, reopen, and continue where the task stopped ─────────────────
|
||||
// Everything a task needs to continue is in storage, so a new Harness over the
|
||||
// same storage picks up where the last one stopped. This example keeps its
|
||||
// storage in a SQLite file so it survives closing.
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-durable-scratch-"));
|
||||
const databasePath = join(directory, "session.sqlite");
|
||||
|
||||
let reachedTick = (_n: number): void => {};
|
||||
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({
|
||||
name: "example.ticker",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "tick", n: 1 }),
|
||||
phases: {
|
||||
tick: async (task, runtime, taskContext) => {
|
||||
const n = task.state.checkpoint.n;
|
||||
// Save the intent before the effect. A memo keeps the first value
|
||||
// written under its name, so if the process dies after printing but
|
||||
// before the next checkpoint is saved, the rerun sees the memo and
|
||||
// does not print the same tick twice.
|
||||
if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) {
|
||||
await runtime.memo(`printed-${n}`, true, taskContext);
|
||||
console.log(`14. tick ${n}`);
|
||||
}
|
||||
reachedTick(n);
|
||||
// Save the outcome: the next tick, or the final result.
|
||||
await runtime.commit(
|
||||
() =>
|
||||
n === task.input.to
|
||||
? { status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }
|
||||
: { status: "running", checkpoint: { phase: "tick", n: n + 1 } },
|
||||
taskContext,
|
||||
);
|
||||
// Wait a little between ticks. Closing the Harness cancels this wait;
|
||||
// the checkpoint saved above is where the next Harness continues.
|
||||
await runtime.sleep(Date.now() + 50, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
|
||||
},
|
||||
});
|
||||
registry.tasks.add(Ticker);
|
||||
|
||||
// First run: start counting to 5, and close the Harness right after tick 2 is
|
||||
// printed, before its next checkpoint is saved. That is the same situation as
|
||||
// a crash between the effect and saving its outcome.
|
||||
const firstRun = await Harness.open(
|
||||
await openNodeSqliteStorage(databasePath),
|
||||
{ models: createModels(), registry },
|
||||
context,
|
||||
);
|
||||
const tickerId = await (await firstRun.root(context)).commit((tx) => tx.createTask(Ticker, { to: 5 }), context);
|
||||
const tickTwo = new Promise<void>((resolve) => {
|
||||
reachedTick = (n) => {
|
||||
if (n === 2) resolve();
|
||||
};
|
||||
});
|
||||
firstRun.resume();
|
||||
await tickTwo;
|
||||
await firstRun.close(context);
|
||||
reachedTick = () => {};
|
||||
const saved = await readTicker();
|
||||
console.log("14. closed; saved checkpoint:", saved.state, "memos:", saved.memos);
|
||||
|
||||
// Second run: nothing to restart by hand. Opening the storage finds the
|
||||
// unfinished task and resume() continues it. Tick 2 runs again because its
|
||||
// outcome was never saved, but its memo says it was already printed.
|
||||
const secondRun = await Harness.open(
|
||||
await openNodeSqliteStorage(databasePath),
|
||||
{ models: createModels(), registry },
|
||||
context,
|
||||
);
|
||||
secondRun.resume();
|
||||
const counted = await secondRun.waitForTask(tickerId, context);
|
||||
console.log("14. after reopen:", counted.state.outcome);
|
||||
await secondRun.close(context);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
|
||||
/** Read the ticker record through a short-lived Harness over the same file. */
|
||||
async function readTicker() {
|
||||
const reader = await Harness.open(
|
||||
await openNodeSqliteStorage(databasePath),
|
||||
{ models: createModels(), registry },
|
||||
context,
|
||||
);
|
||||
const record = await reader.getTask(tickerId, context);
|
||||
await reader.close(context);
|
||||
return record!;
|
||||
}
|
||||
@@ -17,6 +17,28 @@ function documentWrites(writes: readonly StorageWrite[]): readonly StorageWrite[
|
||||
}
|
||||
|
||||
describe("Session document checkpoints", () => {
|
||||
it("calls checkpointWhen with its definition as the receiver", async () => {
|
||||
const Doc = defineDoc<{ count: number }>({
|
||||
kind: "checkpoint.receiver",
|
||||
version: 1,
|
||||
scope: "session",
|
||||
initial: () => ({ count: 0 }),
|
||||
checkpointWhen(value) {
|
||||
return value.count === this.initial().count + 2;
|
||||
},
|
||||
});
|
||||
const { session, storage } = openTestSession();
|
||||
for (let count = 0; count <= 2; count++) {
|
||||
await session.commit(async (tx) => {
|
||||
(await tx.doc(Doc)).count = count;
|
||||
}, context);
|
||||
}
|
||||
const kinds = storage.commits.flatMap((writes) =>
|
||||
writes.flatMap((write) => (write.type === "document.change" ? [write.content.kind] : [])),
|
||||
);
|
||||
expect(kinds).toEqual(["delta", "base"]);
|
||||
});
|
||||
|
||||
it("selects bases only for nonempty ordinary batches and passes the exact prepared revision and ops", async () => {
|
||||
const calls: { value: Readonly<JsonObject>; ops: readonly Op[] }[] = [];
|
||||
const falseCalls: { value: Readonly<JsonObject>; ops: readonly Op[] }[] = [];
|
||||
|
||||
@@ -93,6 +93,8 @@ class ReopeningStorage implements Storage {
|
||||
scanTasks: Storage["scanTasks"] = (query, limit, cursor, readContext) =>
|
||||
this.current.scanTasks(query, limit, cursor, readContext);
|
||||
submission: Storage["submission"] = (id, readContext) => this.current.submission(id, readContext);
|
||||
scanSubmissions: Storage["scanSubmissions"] = (query, limit, cursor, readContext) =>
|
||||
this.current.scanSubmissions(query, limit, cursor, readContext);
|
||||
submissionByRequest: Storage["submissionByRequest"] = (conversationId, requestId, readContext) =>
|
||||
this.current.submissionByRequest(conversationId, requestId, readContext);
|
||||
findDocument: Storage["findDocument"] = (address, at, readContext) =>
|
||||
|
||||
Reference in New Issue
Block a user