docs(agent): require typed status payloads for pico task writes

This commit is contained in:
Mario Zechner 2026-09-11 12:57:47 +02:00
parent 7b4cfd6eb0
commit f3c672245d
4 changed files with 231 additions and 87 deletions

View file

@ -86,14 +86,27 @@ between leaves nothing to repair. Use `tx.write`: it appends immediately when sa
until the next turn boundary:
```typescript
const userBashKind = defineTaskKind({
type UserBashInput = { cmd: string; cwd: string; includeInContext: boolean; limits?: ShellOutputLimits };
type UserBashStates = UserBashInput & (
| { status: 'planned' }
| { status: 'running' }
| { status: 'done'; exitCode: number }
| { status: 'killed' }
| { status: 'lost' }
);
function userBashInput(state: UserBashStates): UserBashInput {
return { cmd: state.cmd, cwd: state.cwd, includeInContext: state.includeInContext,
...(state.limits === undefined ? {} : { limits: state.limits }) };
}
const userBashKind = defineTaskKind<UserBashStates>()({
kind: 'pi.user_bash',
initialStatus: 'planned',
roles: { planned: 'start', running: 'inflight', done: 'terminal', killed: 'terminal', lost: 'terminal' },
preview: { init: async scratch => (await scratch.value(output).get()) ?? emptyOutput() },
async execute(task, runtime, call) {
await runtime.commit(tx => tx.patch(task, { ...task.state, status: 'running' }), call);
await runtime.commit(tx => tx.patch(task, 'running', userBashInput(task.state)), call);
const result = await execIntoScratch(runtime, task.state, call); // same helper as jobKind
const out = runtime.preview.state;
await runtime.commit(tx => {
@ -101,14 +114,14 @@ const userBashKind = defineTaskKind({
data: { cmd: task.state.cmd, exitCode: result.exitCode, output: out },
model: task.state.includeInContext ? [userBashMessage(task.state.cmd, out, result.exitCode)] : undefined,
});
tx.settle(task, { ...task.state, status: 'done', exitCode: result.exitCode });
tx.settle(task, 'done', { ...userBashInput(task.state), exitCode: result.exitCode });
}, call);
},
async recover(task, runtime, call) {
await runtime.commit(tx => tx.settle(task, { ...task.state, status: 'lost' }), call);
await runtime.commit(tx => tx.settle(task, 'lost', userBashInput(task.state)), call);
},
async abort(task, runtime, call) {
await runtime.commit(tx => tx.settle(task, { ...task.state, status: 'killed' }), call);
await runtime.commit(tx => tx.settle(task, 'killed', userBashInput(task.state)), call);
},
});
@ -170,7 +183,8 @@ raw map the reducer and the wire use:
```typescript
interface ConversationView {
readonly previews: ReadonlyMap<Id, JsonValue>; // raw, kind-free
preview<P>(kind: TaskKind<any, any, any, P>, task: Id): P | undefined; // typed; undefined if missing or another kind
preview<S extends TaskStateBase, H extends HookPoints, C extends ConfigSpec, P, R extends TaskRoles<S>>(
kind: TaskKind<S, H, C, P, R>, task: Id): P | undefined; // typed; undefined if missing or another kind
}
const msg = w.view.preview(generationKind, event.task); // AssistantMessage | undefined
const out = w.view.preview(toolKind, event.task); // ToolOutputState | undefined

View file

@ -754,7 +754,7 @@ async abort(task, runtime, call) {
if (r?.status !== 'placed') throw new Error(`Invalid active input ${id}`);
tx.value(address).set({ status: 'unanswered', requestId: r.requestId, entry: r.entry, reason: 'aborted' });
}
tx.settle(task, { status: 'aborted', ...common(task) });
tx.settle(task, 'aborted', { inputs: task.state.inputs });
}, call);
}
```
@ -1166,11 +1166,14 @@ goes on:
```typescript
async execute(toolCallId, params, out, runtime, call) {
const job = await runtime.commit(tx => {
const job = await runtime.commit(async tx => {
const task = await tx.getTask(toolKind, runtime.taskId);
if (task?.state.status !== 'running') throw new Error('Expected a running tool');
const { status, ...payload } = task.state;
const id = tx.task(jobKind, { background: true,
state: { status: 'planned', cmd: params.cmd, cwd: params.cwd ?? runtime.env.cwd,
origin: { tool: 'bash', task: runtime.taskId, callId: toolCallId } } });
tx.patch(task, { jobId: id, cancelJobOnAbort: !params.background }); // partial: still 'running'
tx.patch(task, status, { ...payload, jobId: id, cancelJobOnAbort: !params.background });
return id;
}, call);
@ -1346,7 +1349,7 @@ type ReminderStates =
| { status: 'done'; about: Id; at: number; fired: boolean }
| { status: 'aborted'; about: Id; at: number };
export const reminderKind = defineTaskKind({
export const reminderKind = defineTaskKind<ReminderStates>()({
kind: 'myplugin.reminder',
initialStatus: 'scheduled',
roles: { scheduled: 'start', firing: 'inflight', done: 'terminal', aborted: 'terminal' },
@ -1355,17 +1358,17 @@ export const reminderKind = defineTaskKind({
async execute(task, runtime, call) {
if (task.state.at > runtime.now()) await runtime.sleep(task.state.at, call); // throws on cancellation
await runtime.commit(tx => tx.patch(task, { status: 'firing', ...common(task) }), call); // intent before any effect
await runtime.commit(tx => tx.patch(task, 'firing', common(task)), call); // intent before any effect
const { skip } = await runtime.hooks(reminderKind).run('before_fire', { about: task.state.about }, call);
await runtime.commit(tx => {
if (!skip) tx.entry(noticeKind, { model: [noticeMessage(`Reminder: see entry ${task.state.about}`)] });
tx.settle(task, { status: 'done', ...common(task), fired: !skip }); // a marked task's commit rejects
if (!skip) tx.write(noticeKind, { model: [noticeMessage(`Reminder: see entry ${task.state.about}`)] });
tx.settle(task, 'done', { ...common(task), fired: !skip }); // a marked task's commit rejects
}, call);
},
async recover(task, runtime, call) { return this.execute(task, runtime, call); }, // safe to redo
async abort(task, runtime, call) {
await runtime.commit(tx => tx.settle(task, { status: 'aborted', ...common(task) }), call);
await runtime.commit(tx => tx.settle(task, 'aborted', common(task)), call);
},
// no preview: nothing to show while sleeping
@ -1374,12 +1377,37 @@ export const reminderKind = defineTaskKind({
const common = (t: Task<ReminderStates>) => ({ about: t.state.about, at: t.state.at });
```
State is a tagged union over status: one variant per status carrying exactly the fields that exist
in it, so a reader narrows instead of checking optionals, and `patch` within a variant takes a
partial (`tx.patch(task, { at: later })`) while a transition takes the whole variant. The harness
adds an `orphaned` terminal variant with the fields common to all of yours, for the case where this
plugin is not installed when the session is opened; you never write it, and a task that depends on
yours handles it in the same `switch` that handles your other terminal statuses.
`defineTaskKind<ReminderStates>()` binds the declared union; the following call infers the literal
role map. Keep that inferred kind type so the compiler knows which statuses patch and settle accept.
Kind definition rejects missing/extra role entries, a non-start initial status, a declared `orphaned`
status, and inconsistent types or optionality for fields shared by every variant.
State is a tagged union over status. **Both `patch` and `settle` take a status and its complete
payload, without a second status inside it.** Patch accepts nonterminal targets; settle accepts
terminal targets. There is no status-free partial patch or implicit merge with the old state:
```typescript
tx.patch(task, 'firing', { about: task.state.about, at: later });
tx.settle(task, 'done', { about: task.state.about, at: task.state.at, fired: true });
// Rejected: missing fired, extra fields, wrong field types, or using done with patch.
```
For a same-status update, narrow the state, destructure out `status`, and spread the remaining
payload with your changes. A transition must supply the target variant's fields, not spread the
previous variant's unrelated fields. Stored state becomes `{ ...payload, status }`. Task snapshots
remain immutable; read the task again if a later write needs state committed since that snapshot.
Typed tasks retain a compiler-only witness for the full state union and role map; no field or
callback is added to storage. Given only an id, read through its kind before patching or settling.
The type checks reject visible extra top-level keys even on variables/spreads and preserve correlation
between status and payload. They cannot detect fields erased by casts or a narrower static type;
wire validation and on-line invocation/liveness checks still apply.
The harness adds `orphaned` with fields common to every variant: for this reminder, `about` and `at`,
not `fired`. Common optional fields remain optional. This uses common keys (`keyof` on the union),
not a literal TypeScript intersection of incompatible statuses. Typed reads include orphaned; kind
execution methods receive only declared variants. You never write orphaned through patch/settle;
a dependent handles it in the same switch as the other terminal outcomes.
The rules an execution follows, and the driver enforces:

View file

@ -331,7 +331,7 @@ history through X:
await runtime.commit(tx => {
tx.value(planMode).set(true); // 300
const result = tx.entry(toolResultKind, { data: resultData, model: [message] }); // 301
tx.settle(task, { status: "done", call: task.state.call, assistant: task.state.assistant, output, result }); // 302
tx.settle(task, "done", { call: task.state.call, assistant: task.state.assistant, output, result }); // 302
}, call);
await conv.value(model).set("claude-opus-5", call); // 303, a later commit
@ -493,7 +493,7 @@ const partial = await runtime.scratch(sc => sc.list(frames).read(), call);
// settlement: the result becomes an entry; settle retires the scope in the same commit
await runtime.commit(tx => {
const id = tx.entry(assistantKind, { model: [assemble(partial)] });
tx.settle(task, { status: "done", call: task.state.call, assistant: task.state.assistant, output, result: id });
tx.settle(task, "done", { inputs: task.state.inputs, assistant: id });
}, call);
```
@ -526,6 +526,7 @@ supplies the inputs, progress and outcome that must survive a process.
type TaskRole = "start" | "inflight" | "terminal";
type TaskStateBase = JsonObject & { readonly status: string }; // state is strict JSON with a status
type TaskRoles<S extends TaskStateBase> = Readonly<Record<S["status"], TaskRole>>;
interface Task<State extends TaskStateBase = TaskStateBase> {
readonly id: Id;
@ -577,11 +578,17 @@ one terminal variant to every state union, carrying the fields common to all of
variants:
```ts
type Common<S> = UnionToIntersection<S>; // fields present in every variant
type Orphaned<S> = Omit<Common<S>, "status"> & { status: "orphaned" };
type ToolState = ToolStates | Orphaned<ToolStates>; // { status: "orphaned"; call; assistant }
type CommonPayload<S extends TaskStateBase> = Pick<S, Exclude<keyof S, "status">>;
type Orphaned<S extends TaskStateBase> = CommonPayload<S> & { readonly status: "orphaned" };
type ToolState = ToolStates | Orphaned<ToolStates>; // orphaned carries only call and assistant
```
`keyof S` selects the keys present in every union member; `Pick` keeps their types and optionality.
This is the intersection of field names, not a TypeScript intersection of the variants (which would
intersect incompatible status literals into `never`). Kind definition rejects inconsistent types or
optionality for common fields, and rejects a user-declared `orphaned` status. A field optional in every
variant remains optional on orphaned; a variant-specific field is not exposed.
It is written in one situation only: at open, for a live foreground task whose kind is not
registered in this process (§6.4). The stored record keeps whatever fields it had; the type
promises only the common ones, which is all a dependent may rely on, because the kind never ran its
@ -591,32 +598,57 @@ when their kind returns. This is the only case in which the harness settles a ta
### 5.2 Kinds
```ts
interface TaskKind<States extends TaskStateBase, Hooks extends HookPoints = {}, Config extends ConfigSpec = {}, Preview = never> {
declare const taskType: unique symbol; // compiler-only witness; never populated or serialized
interface TaskType<S extends TaskStateBase, R extends TaskRoles<S>> {
readonly [taskType]?: { readonly state: (state: S) => S; readonly roles: R };
}
type TypedTask<S extends TaskStateBase, R extends TaskRoles<S>> = Task<S> & TaskType<S, R>;
interface TaskDefinition<S extends TaskStateBase, R extends TaskRoles<S>> extends TaskType<S, R> {
readonly kind: string;
readonly initialStatus: States["status"];
readonly roles: Readonly<Record<States["status"], TaskRole>>; // `orphaned` is added as terminal (§5.1)
readonly roles: R;
}
interface TaskKind<States extends TaskStateBase, Hooks extends HookPoints = {}, Config extends ConfigSpec = {},
Preview = never, R extends TaskRoles<States> = TaskRoles<States>> extends TaskDefinition<States, R> {
readonly initialStatus: StatesWithRole<States, R, "start">["status"]; // helper in §9.2
is(task: Task | undefined): task is ReadTask<States, R>;
readonly turn?: true; // this kind drives a turn (§5.8)
readonly hooks?: HookSpecs<Hooks>; // the points this kind runs (§8.7)
readonly config?: Config; // the values this kind reads (below)
preview?: { // what UIs see while the task runs (§9.4)
init(scratch: ScratchReader): Preview; // built once, on attach or reopen, from durable scratch
}; // afterwards the kind mutates runtime.preview.state in place
execute(task: Task<States>, runtime: TaskRuntime, call: Call): Promise<void>;
recover(task: Task<States>, runtime: TaskRuntime, call: Call): Promise<void>;
abort(task: Task<States>, runtime: TaskRuntime, call: Call): Promise<void>;
execute(task: TypedTask<States, R>, runtime: TaskRuntime, call: Call): Promise<void>;
recover(task: TypedTask<States, R>, runtime: TaskRuntime, call: Call): Promise<void>;
abort(task: TypedTask<States, R>, runtime: TaskRuntime, call: Call): Promise<void>;
}
type TaskState<K> = K extends TaskKind<infer S, any, any, any> ? S | Orphaned<S> : never; // what readers see
}
type ReadTask<S extends TaskStateBase, R extends TaskRoles<S>> =
TypedTask<S | Orphaned<S>, R & { readonly orphaned: "terminal" }>;
type TaskState<K> = K extends TaskDefinition<infer S, infer R> ? S | Orphaned<S> : never;
```
`Task` remains the raw stored record. `TypedTask` adds only a compile-time witness for the complete
state union and literal role map, so narrowing `task.state` does not lose the other transition targets.
Typed reads include `orphaned`; execute/recover/abort receive only the kind's declared variants.
The witness is not a runtime capability: invocation identity and current durable state still decide
write authority on the line.
Use `defineTaskKind<States>()({ ... })`: the first call binds the declared union (and optional hook,
config and preview types); the second infers the literal roles from the definition. It checks an exact
role-map entry for each declared status, a start-role initial status and common-field compatibility.
Its return type retains `TaskDefinition<States, R>`, including the compiler-only state witness;
otherwise kind-witnessed reads could infer statuses from roles but lose their payload types.
Do not widen the result to a generic `TaskKind<States>` or its roles to `TaskRoles<States>` when using
typed mutations: that would discard the information needed to distinguish patch from settle.
A kind that reads config declares it, so the values it depends on are on the kind and nowhere
else, typed, and usable by callers that initialize a conversation:
```ts
type ConfigSpec = Record<string, Value<any>>;
const generationKind = defineTaskKind({
const generationKind = defineTaskKind<GenerationState>()({
kind: "generation",
config: {
model: conversationValue<ModelId>("pi.model", { rewind: true }),
@ -648,7 +680,7 @@ interface TaskRuntime {
readonly preview: Tracker<Preview>; // flushed after successful scratch commits
now(): number; // injected clock, not Date.now() in task code
sleep(untilMs: number, call: Call): Promise<void>;
config<C extends ConfigSpec>(kind: TaskKind<unknown, HookPoints, C>, call: Call): Promise<ConfigValues<C>>;
config<C extends ConfigSpec>(kind: { readonly kind: string; readonly config?: C }, call: Call): Promise<ConfigValues<C>>;
conversation(id: Id, call: Call): Promise<ConversationHandle | undefined>;
abortTask(id: Id, call: Call): Promise<void>;
waitForTask(id: Id, options: { budgetMs?: number } | undefined, call: Call): Promise<boolean>;
@ -657,7 +689,7 @@ interface TaskRuntime {
value(addr) / list(addr) // session state handles; async methods take Call
readonly models: ModelRegistry; // stream/deferred operations take Call
readonly tools: ToolRegistry;
hooks<H extends HookPoints>(kind: TaskKind<unknown, H>): HookRunner<H>; // run(point, input, call)
hooks<H extends HookPoints>(kind: { readonly kind: string; readonly hooks?: HookSpecs<H> }): HookRunner<H>; // run(point, input, call)
readonly env: ExecutionEnv; // existing Context-final methods; no wrappers
}
```
@ -672,15 +704,20 @@ transaction reads use the transaction view without another Call:
```ts
// public/runtime
getTask<S>(kind: TaskKind<S>, id: Id, call: Call): Promise<Task<S> | undefined>;
getTasks<S>(kind: TaskKind<S>, ids: readonly Id[], call: Call): Promise<ReadonlyMap<Id, Task<S>>>;
// inside Tx: same asynchronous reads, but no additional Call
getTask<S>(kind: TaskKind<S>, id: Id): Promise<Task<S> | undefined>;
getTasks<S>(kind: TaskKind<S>, ids: readonly Id[]): Promise<ReadonlyMap<Id, Task<S>>>;
getTask<S extends TaskStateBase, R extends TaskRoles<S>>(
kind: TaskDefinition<S, R>, id: Id, call: Call): Promise<ReadTask<S, R> | undefined>;
getTasks<S extends TaskStateBase, R extends TaskRoles<S>>(
kind: TaskDefinition<S, R>, ids: readonly Id[], call: Call): Promise<ReadonlyMap<Id, ReadTask<S, R>>>;
// inside Tx: same asynchronous typed reads, but no additional Call
getTask<S extends TaskStateBase, R extends TaskRoles<S>>(
kind: TaskDefinition<S, R>, id: Id): Promise<ReadTask<S, R> | undefined>;
getTasks<S extends TaskStateBase, R extends TaskRoles<S>>(
kind: TaskDefinition<S, R>, ids: readonly Id[]): Promise<ReadonlyMap<Id, ReadTask<S, R>>>;
```
The getter returns `Task<S>` without casts, or `undefined` when the task is missing or of another
kind; without the kind it returns the untyped task, and `kind.is(task)` narrows.
The getter returns `ReadTask<S, R>` without casts, or `undefined` when the task is missing or of another
kind; without the kind it returns the untyped task. `kind.is(task)` narrows to the same read type.
A bare numeric id supplies no state/role witness for typed mutation; read it with its kind first.
### 5.3 What an execution may do
@ -737,7 +774,7 @@ await runtime.commit(tx => {
after: tools,
state: { status: "waiting", assistant, tools, inputs: task.state.inputs },
});
tx.settle(task, { status: "done", inputs: task.state.inputs, assistant });
tx.settle(task, "done", { inputs: task.state.inputs, assistant });
}, call);
```
@ -748,7 +785,7 @@ typed getter, narrows on their status, and decides what happens next:
```ts
async execute(task, runtime, call) {
await runtime.commit(async tx => {
const tools = await tx.getTasks(toolKind, task.state.tools); // Map<id, Task<ToolState>>
const tools = await tx.getTasks(toolKind, task.state.tools); // Map<id, ReadTask<ToolStates, typeof toolKind.roles>>
const outcomes: ToolOutputState[] = [];
for (const t of tools.values()) {
switch (t.state.status) {
@ -765,7 +802,7 @@ async execute(task, runtime, call) {
tx.value(inputResult(inputId)).set({ status: "unanswered", requestId: r.requestId, entry: r.entry,
reason: "terminated" });
}
return tx.settle(task, { status: "stopped", assistant: task.state.assistant, tools: task.state.tools }); // no successor
return tx.settle(task, "stopped", { assistant: task.state.assistant, tools: task.state.tools }); // no successor
}
const added = outcomes.flatMap(o => o.addedTools ?? []);
const selectedTools = added.length ? [...current, ...added] : current;
@ -776,7 +813,7 @@ async execute(task, runtime, call) {
});
const inputs = await landPostToolsInbox(tx, task.conversationId, task.state.inputs); // writes + steer (§8.1)
tx.task(generationKind, { state: { status: "pending", ...nextGeneration(task, { selectedTools, inputs }) } });
tx.settle(task, { status: "done", assistant: task.state.assistant, tools: task.state.tools, inputs });
tx.settle(task, "done", { assistant: task.state.assistant, tools: task.state.tools, inputs });
}, call);
}
```
@ -1254,7 +1291,7 @@ one line per batch:
{"first":100,"writes":[
{"type":"value.set","conversationId":1,"namespace":"plugin.plan","value":true},
{"type":"entry","conversationId":1,"kind":"user","model":[{"role":"user","content":"Inspect","timestamp":0}]},
{"type":"task","conversationId":1,"kind":"generation","status":"pending"}]}
{"type":"task","conversationId":1,"kind":"generation","state":{"status":"pending"}}]}
```
Write all bytes, then publish in-memory changes, never before. Durability against process crash is
@ -1668,10 +1705,13 @@ budget runs out, the call settles now with what it has, `out.delegate(job)` and
work continues as the job:
```ts
const job = await runtime.commit(tx => {
const job = await runtime.commit(async tx => {
const task = await tx.getTask(toolKind, runtime.taskId);
if (task?.state.status !== "running") throw new Error("Expected a running tool");
const { status, ...payload } = task.state;
const id = tx.task(jobKind, { background: true,
state: { status: "planned", cmd, cwd, origin: { tool: "bash", task: runtime.taskId, callId: toolCallId } } });
tx.patch(task, { jobId: id, cancelJobOnAbort: true }); // partial: still "running" (§9.2)
tx.patch(task, status, { ...payload, jobId: id, cancelJobOnAbort: true }); // complete running payload
return id;
}, call);
const done = await runtime.waitForTask(job, { budgetMs: runtime.budgetMs }, call);
@ -1768,12 +1808,18 @@ last complete exchange before the launching one.
A job is a background task that runs a process. Its state:
```ts
interface JobState {
type JobInput = {
cmd: string; cwd: string; limits?: ShellOutputLimits;
origin?: { tool: string; task: Id; callId: string }; // the call that started it, so UIs render it with that tool's component
every?: number; notBefore?: number; rerun?: "safe"; // recurrence and recovery policy
startedAt?: number; output?: ToolOutputState; exitCode?: number;
}
origin?: { tool: string; task: Id; callId: string }; // originating call, for UIs
every?: number; notBefore?: number; rerun?: "safe"; // recurrence and recovery policy
};
type JobState = JobInput & (
| { status: "planned" }
| { status: "running"; startedAt: number }
| { status: "exited"; output: ToolOutputState; exitCode: number }
| { status: "killed"; output: ToolOutputState }
| { status: "lost" }
);
```
Starting one is creating the task; a tool does it in its execute (§8.3), a UI in a commit:
@ -1847,8 +1893,8 @@ interface CollapseHooks extends HookPoints {
output: { decline?: boolean; instructions?: string; summary?: string } };
}
const generationKind: TaskKind<GenerationState, GenerationHooks> = { ..., hooks: { before_request: {}, after_response: {}, on_yield: { failClosed: false } } };
const toolKind: TaskKind<ToolState, ToolHooks> = { ..., hooks: { before_tool: { failClosed: true }, after_tool: {} } };
const generationKind = defineTaskKind<GenerationState, GenerationHooks>()({ ..., hooks: { before_request: {}, after_response: {}, on_yield: { failClosed: false } } });
const toolKind = defineTaskKind<ToolStates, ToolHooks>()({ ..., hooks: { before_tool: { failClosed: true }, after_tool: {} } });
// a plugin registers a handler
harness.hooks.on(toolKind, "before_tool", async ({ toolName, args }, call) => {
@ -1906,7 +1952,7 @@ interface ConversationHandle {
fork(options: { at: Id | "start"; abort?: boolean; values? }, call: Call): Promise<ConversationHandle>;
spawn(options, call: Call): Promise<Id>;
value(addr) / list(addr) // async reads/writes take a final Call
config<C>(kind: TaskKind<unknown, HookPoints, C>): { get(call: Call): Promise<ConfigValues<C>>; set(partial: Partial<ConfigValues<C>>, call: Call): Promise<void> };
config<C extends ConfigSpec>(kind: { readonly kind: string; readonly config?: C }): { get(call: Call): Promise<ConfigValues<C>>; set(partial: Partial<ConfigValues<C>>, call: Call): Promise<void> };
readonly hooks: { on(kind, point, handler, o?: { subtree?: boolean }): () => void }; // scoped to this conversation (§8.7)
readonly settings: ConfigHandle<GenerationConfig>; // sugar: config(generationKind)
commit<T>(plan: (tx: ConversationTx) => T | Promise<T>, call: Call): Promise<T>;
@ -1972,6 +2018,21 @@ interface EntryDraft {
readonly edits?: readonly ContextEdit[];
}
// Distribute over the declared variants; orphaned is never a public write target.
type StatesWithRole<S extends TaskStateBase, R extends TaskRoles<S>, Role extends TaskRole> =
S extends TaskStateBase
? S["status"] extends "orphaned" ? never : R[S["status"]] extends Role ? S : never
: never;
// Keep status and payload correlated, even when callers pass unions.
type WriteArgs<S extends TaskStateBase> = S extends TaskStateBase
? [status: S["status"], payload: Omit<S, "status">]
: never;
type ExactWrite<S extends TaskStateBase, A extends WriteArgs<S>> = A & [
status: A[0],
payload: Record<Exclude<keyof A[1], keyof Omit<Extract<S, { status: A[0] }>, "status">>, never>,
];
interface Tx {
// reads (committed state)
getEntry(id) / getEntry(kind, id) / getEntries(...) / getTask(id) / getTask(kind, id) / getTasks(...)
@ -1981,27 +2042,48 @@ interface Tx {
entry<E extends Entry>(kind: EntryKind<E>, conversationId: Id, input: EntryInput<E>): Id; // §5.8
write<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>): Id; // inputId; appends now or queues (§5.8)
queueInput(input: QueuedInput): Id; // inputId (§8.1)
task<S>(kind: TaskKind<S>, spec: { state: S; after?: Id[]; background?: true; owns?: Id[] }): Id;
// stay in the current variant: a partial of that variant, no status
patch<S extends TaskStateBase, K extends S["status"]>(
task: Task<S> & { state: { status: K } },
changes: Partial<Omit<Extract<S, { status: K }>, "status">>): void;
// move to another variant, or patch by id: the whole variant
patch<S extends TaskStateBase>(task: Task<S> | Id, state: NonTerminal<S>): void;
settle<S extends TaskStateBase>(task: Task<S> | Id, state: Terminal<S>): void; // terminal; retires scratch
// NonTerminal<S> / Terminal<S> are S filtered by the kind's roles map. The partial overload needs a task
// whose variant the compiler can see (the normal case inside a kind's methods); a bare id or an unnarrowed
// union falls back to the whole-variant form, so no field of another variant is smuggled in and a partial
// cannot change status:
// tx.patch(task, { jobId }); // still "running"
// tx.patch(task, { status: "running", call, assistant, args, replay }); // planned → running
// tx.settle(task, { status: "done", call, assistant, output, result }); // terminal
task<S extends TaskStateBase, R extends TaskRoles<S>>(kind: TaskDefinition<S, R>,
spec: { state: S; after?: Id[]; background?: true; owns?: Id[] }): Id;
patch<S extends TaskStateBase, R extends TaskRoles<S>,
A extends NoInfer<WriteArgs<StatesWithRole<S, R, "start" | "inflight">>>>(
task: TypedTask<S, R>, ...args: A & NoInfer<ExactWrite<StatesWithRole<S, R, "start" | "inflight">, A>>): void;
settle<S extends TaskStateBase, R extends TaskRoles<S>,
A extends NoInfer<WriteArgs<StatesWithRole<S, R, "terminal">>>>(
task: TypedTask<S, R>, ...args: A & NoInfer<ExactWrite<StatesWithRole<S, R, "terminal">, A>>): void;
createConversation(spec): Id; deleteConversation(id: Id): void;
}
```
The implementation is small enough to show. It numbers as it goes, keeps the one ordering rule, and
hands storage a batch; storage numbers from its own `lastSeq` and gets the same ids (§7.3).
Both operations take an explicit target status and its **complete payload without status**:
```ts
tx.patch(task, "running", { call, assistant, args, replay });
tx.settle(task, "done", { call, assistant, output, result });
// Same-status update: narrow, remove the tag, then supply the full replacement.
if (task.state.status === "running") {
const { status, ...payload } = task.state;
tx.patch(task, status, { ...payload, jobId });
}
```
There is no status-free or partial-merge overload. Both writes replace the stored state with
`{ ...payload, status }`; fields from the previous variant are not retained implicitly. `patch`
accepts start/inflight targets, `settle` terminal targets and retires scratch. Neither permits
`orphaned`; only open's internal orphan handling writes it. Same-status replacement remains valid
but does not advance `statusEpoch`. Creation still receives the complete initial tagged state.
An invocation's task snapshot does not change after patch; read again when a later replacement
needs state committed since that snapshot, or carry the explicitly constructed next payload.
`WriteArgs` is a union of status/payload tuples: a union-valued status and an unrelated payload do
not prove a valid pair. `NoInfer` prevents arguments from widening the task's known union/role map.
`ExactWrite` rejects extra top-level keys visible in a variable or spread, not just object literals,
including a duplicated status. These are static checks, not runtime schema validation: casts, erased
extra fields and nested structural typing still follow TypeScript's limits. Wire boundaries validate
untrusted payloads; line validation checks current liveness, invocation ownership and allowed roles.
The implementation below shows erased runtime signatures; the public signatures are above. It
numbers as it goes, keeps the one ordering rule, and hands storage a batch; storage numbers from its own `lastSeq` and gets the same ids (§7.3).
```ts
class Tx {
@ -2044,7 +2126,8 @@ class Tx {
this.push({ type: "entry", entry });
return id;
}
task<S extends TaskStateBase>(kind: TaskKind<S>, spec: {
task<S extends TaskStateBase, R extends TaskRoles<S>>(
kind: TaskDefinition<S, R> & { readonly initialStatus: S["status"]; readonly turn?: true }, spec: {
conversationId: Id; state: S; after?: Id[]; background?: true; owns?: Id[];
}): Id {
const id = this.seq + 1;
@ -2053,16 +2136,20 @@ class Tx {
this.push({ type: "task", task: { id, kind: kind.kind, role, turn: kind.turn, ...spec } });
return id;
}
patch(task: Task | Id, state: TaskStateBase) { // whole variant, or a partial of the current one
const id = typeof task === "number" ? task : task.id;
this.push({ type: "patch", id, role: this.roleFor(id, state.status), state });
patch(task: Task, status: string, payload: JsonObject) {
if (Object.hasOwn(payload, "status")) throw new Error("payload must not include status");
const role = this.roleFor(task.id, status);
if (role === "terminal") throw new Error(`${status} requires settle`);
this.push({ type: "patch", id: task.id, role, state: { ...payload, status } });
}
settle(task: Task | Id, state: TaskStateBase) {
const id = typeof task === "number" ? task : task.id;
if (this.roleFor(id, state.status) !== "terminal") throw new Error(`${state.status} is not terminal`);
this.push({ type: "settle", id, role: "terminal", state });
settle(task: Task, status: string, payload: JsonObject) {
if (Object.hasOwn(payload, "status")) throw new Error("payload must not include status");
if (this.roleFor(task.id, status) !== "terminal") throw new Error(`${status} is not terminal`);
this.push({ type: "settle", id: task.id, role: "terminal", state: { ...payload, status } });
}
private roleFor(id: Id, status: string): TaskRole {
/* kind from transaction view; reject unknown status and reserved orphaned */
}
private roleFor(id: Id, status: string): TaskRole { /* kind from the transaction view; reject unknown status */ }
getTask = this.storage.getTask; getTasks = this.storage.getTasks; // reads: committed state
getEntry = this.storage.getEntry; getEntries = this.storage.getEntries;

View file

@ -15,7 +15,8 @@ reference design; `pico-usage-guide.md` shows the intended surface.
`Id`, `EntryIdentity`, `EntryBase`, the composable `EntryData` / `ModelProjection` /
`ContextHead` / `ContextEdits` facets, `Entry`, `EntryKind`, `EntryInput`, `ContextEdit`, `Task`,
`TaskRole`, status-tagged task-state unions and their derived `Orphaned` variant, stored task roles
`TaskRole`, status-tagged task-state unions and their derived `Orphaned` variant, compiler-only
`TypedTask`/`TaskDefinition` witnesses preserving the complete union and literal role map, stored task roles
and `turn` flag, `Conversation`, `QueuedInput`, `InputResult`, `InboxOp`, acceptance
receipts, `Call` (an alias of Chord Context), `TaskRuntime`/`ToolRuntime`, private typed invocation
identity, `Address`/`Value`/`List`/`Scope`, `Write`/`CommitBatch`, `Page`/`Cursor` and query shapes.
@ -50,6 +51,10 @@ definitions live only in model fields, never duplicated in SystemData.
Tests: typed token/draft inference without plugin casts; owned-copy reads; JSON null versus deletion;
every entry facet and built-in combination; persisted payload+rendered text and model-only tool definitions.
Task type tests: `defineTaskKind<States>()({ ... })` infers literal roles; exact role-map keys, start-only
initial status, reserved orphaned, consistent common-field types/optionality; orphaned exposes only
common fields via `Pick<S, Exclude<keyof S, "status">>`, preserving common optional fields. Typed reads
include orphaned; kind methods exclude it. No compiler witness is stored or needed by untyped readers.
## 2. Memory storage
@ -71,8 +76,11 @@ conversation value/list writes after an entry throw, a throwing plan discards ev
after persist; apply the entire batch to live indexes before scheduling or testing idle. Driver
callbacks, signals and task methods dispatch outside the line. Session and sticky conversation state, task
writes and conversation writes may appear anywhere. `task` / `patch` / `settle` materialize the role
from the kind's status map; status lives only in `state.status`. Same-variant patches accept partial
fields without status; transitions require a complete variant. Materialize `turn` from the kind and
from the kind's status map; status lives only in `state.status`. `patch(task, status, payload)` and
`settle(task, status, payload)` always require an explicit status and its complete payload without
status. Both replace state with `{ ...payload, status }`; no partial merge or status-free overload.
Patch targets start/inflight roles, settle targets terminal roles and retires scratch; neither exposes
orphaned. A bare id must first be read with a kind to obtain the typed witness. Materialize `turn` from the kind and
maintain the indexed `inTurn` predicate. `entry` rejects outside-turn model-visible writes while turn
tasks are live; `write` places immediately or queues at a safe boundary, returning an inputId.
`value` / `list` / `entry` / `task` / `patch` / `settle` build the batch of §7.3.
@ -85,6 +93,13 @@ post-mark main/scratch mutation rejects before builder; only an owned task's cur
patch its state/status or settle it (host abort marks remain allowed); caller cancellation never
abandons admitted persistence; Tx/ScratchTx reads are asynchronous and builders may await them
without releasing the line; writes remain synchronous. No external effects or nested line entry in builders.
Compile-time cases: required target fields and types, no extra top-level keys on literals/variables/spreads,
no duplicated status, status/payload correlation under union arguments, no role/status widening from
inference, narrowed task retains all transition targets, typed reads cannot write orphaned, no bare-id
escape. Cover valid same-status full replacement and optional fields; document structural typing/cast
limits rather than adding deep exact-type machinery. Runtime cases: replacement drops prior-variant
fields, rejects wrong roles/reserved statuses/duplicated payload status, current terminal tasks cannot
change, and same-status replacement does not advance the epoch. Payload validation stays at wire boundaries.
## 4. Entry kinds and context