mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(agent): require typed status payloads for pico task writes
This commit is contained in:
parent
7b4cfd6eb0
commit
f3c672245d
4 changed files with 231 additions and 87 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue