From f3c672245d25ef2283ffc0d9cdec8a5482651103 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 11 Sep 2026 12:57:47 +0200 Subject: [PATCH] docs(agent): require typed status payloads for pico task writes --- packages/agent/docs/pico/pico-handoff.md | 26 ++- packages/agent/docs/pico/pico-usage-guide.md | 56 +++-- packages/agent/docs/pico/pico-v3.md | 215 +++++++++++++------ packages/agent/docs/pico/pico-work.md | 21 +- 4 files changed, 231 insertions(+), 87 deletions(-) diff --git a/packages/agent/docs/pico/pico-handoff.md b/packages/agent/docs/pico/pico-handoff.md index e62e94f18..c013dc8f6 100644 --- a/packages/agent/docs/pico/pico-handoff.md +++ b/packages/agent/docs/pico/pico-handoff.md @@ -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()({ 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; // raw, kind-free - preview

(kind: TaskKind, task: Id): P | undefined; // typed; undefined if missing or another kind + preview>( + kind: TaskKind, 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 diff --git a/packages/agent/docs/pico/pico-usage-guide.md b/packages/agent/docs/pico/pico-usage-guide.md index 3958a697c..4633fc6ac 100644 --- a/packages/agent/docs/pico/pico-usage-guide.md +++ b/packages/agent/docs/pico/pico-usage-guide.md @@ -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()({ 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) => ({ 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()` 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: diff --git a/packages/agent/docs/pico/pico-v3.md b/packages/agent/docs/pico/pico-v3.md index c78fa4293..693ea2c5c 100644 --- a/packages/agent/docs/pico/pico-v3.md +++ b/packages/agent/docs/pico/pico-v3.md @@ -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 = Readonly>; interface Task { 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 = UnionToIntersection; // fields present in every variant -type Orphaned = Omit, "status"> & { status: "orphaned" }; -type ToolState = ToolStates | Orphaned; // { status: "orphaned"; call; assistant } +type CommonPayload = Pick>; +type Orphaned = CommonPayload & { readonly status: "orphaned" }; +type ToolState = ToolStates | Orphaned; // 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 { +declare const taskType: unique symbol; // compiler-only witness; never populated or serialized +interface TaskType> { + readonly [taskType]?: { readonly state: (state: S) => S; readonly roles: R }; +} +type TypedTask> = Task & TaskType; +interface TaskDefinition> extends TaskType { readonly kind: string; - readonly initialStatus: States["status"]; - readonly roles: Readonly>; // `orphaned` is added as terminal (§5.1) + readonly roles: R; +} + +interface TaskKind = TaskRoles> extends TaskDefinition { + readonly initialStatus: StatesWithRole["status"]; // helper in §9.2 + is(task: Task | undefined): task is ReadTask; readonly turn?: true; // this kind drives a turn (§5.8) readonly hooks?: HookSpecs; // 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, runtime: TaskRuntime, call: Call): Promise; - recover(task: Task, runtime: TaskRuntime, call: Call): Promise; - abort(task: Task, runtime: TaskRuntime, call: Call): Promise; + execute(task: TypedTask, runtime: TaskRuntime, call: Call): Promise; + recover(task: TypedTask, runtime: TaskRuntime, call: Call): Promise; + abort(task: TypedTask, runtime: TaskRuntime, call: Call): Promise; } -type TaskState = K extends TaskKind ? S | Orphaned : never; // what readers see -} +type ReadTask> = + TypedTask, R & { readonly orphaned: "terminal" }>; +type TaskState = K extends TaskDefinition ? S | Orphaned : 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()({ ... })`: 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`, 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` or its roles to `TaskRoles` 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>; -const generationKind = defineTaskKind({ +const generationKind = defineTaskKind()({ kind: "generation", config: { model: conversationValue("pi.model", { rewind: true }), @@ -648,7 +680,7 @@ interface TaskRuntime { readonly preview: Tracker; // flushed after successful scratch commits now(): number; // injected clock, not Date.now() in task code sleep(untilMs: number, call: Call): Promise; - config(kind: TaskKind, call: Call): Promise>; + config(kind: { readonly kind: string; readonly config?: C }, call: Call): Promise>; conversation(id: Id, call: Call): Promise; abortTask(id: Id, call: Call): Promise; waitForTask(id: Id, options: { budgetMs?: number } | undefined, call: Call): Promise; @@ -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(kind: TaskKind): HookRunner; // run(point, input, call) + hooks(kind: { readonly kind: string; readonly hooks?: HookSpecs }): HookRunner; // 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(kind: TaskKind, id: Id, call: Call): Promise | undefined>; -getTasks(kind: TaskKind, ids: readonly Id[], call: Call): Promise>>; -// inside Tx: same asynchronous reads, but no additional Call -getTask(kind: TaskKind, id: Id): Promise | undefined>; -getTasks(kind: TaskKind, ids: readonly Id[]): Promise>>; +getTask>( + kind: TaskDefinition, id: Id, call: Call): Promise | undefined>; +getTasks>( + kind: TaskDefinition, ids: readonly Id[], call: Call): Promise>>; +// inside Tx: same asynchronous typed reads, but no additional Call +getTask>( + kind: TaskDefinition, id: Id): Promise | undefined>; +getTasks>( + kind: TaskDefinition, ids: readonly Id[]): Promise>>; ``` -The getter returns `Task` 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` 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> + const tools = await tx.getTasks(toolKind, task.state.tools); // Map> 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 = { ..., hooks: { before_request: {}, after_response: {}, on_yield: { failClosed: false } } }; -const toolKind: TaskKind = { ..., hooks: { before_tool: { failClosed: true }, after_tool: {} } }; +const generationKind = defineTaskKind()({ ..., hooks: { before_request: {}, after_response: {}, on_yield: { failClosed: false } } }); +const toolKind = defineTaskKind()({ ..., 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; spawn(options, call: Call): Promise; value(addr) / list(addr) // async reads/writes take a final Call - config(kind: TaskKind): { get(call: Call): Promise>; set(partial: Partial>, call: Call): Promise }; + config(kind: { readonly kind: string; readonly config?: C }): { get(call: Call): Promise>; set(partial: Partial>, call: Call): Promise }; readonly hooks: { on(kind, point, handler, o?: { subtree?: boolean }): () => void }; // scoped to this conversation (§8.7) readonly settings: ConfigHandle; // sugar: config(generationKind) commit(plan: (tx: ConversationTx) => T | Promise, call: Call): Promise; @@ -1972,6 +2018,21 @@ interface EntryDraft { readonly edits?: readonly ContextEdit[]; } +// Distribute over the declared variants; orphaned is never a public write target. +type StatesWithRole, 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 + ? [status: S["status"], payload: Omit] + : never; +type ExactWrite> = A & [ + status: A[0], + payload: Record, "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(kind: EntryKind, conversationId: Id, input: EntryInput): Id; // §5.8 write(kind: EntryKind, input: EntryInput): Id; // inputId; appends now or queues (§5.8) queueInput(input: QueuedInput): Id; // inputId (§8.1) - task(kind: TaskKind, spec: { state: S; after?: Id[]; background?: true; owns?: Id[] }): Id; - // stay in the current variant: a partial of that variant, no status - patch( - task: Task & { state: { status: K } }, - changes: Partial, "status">>): void; - // move to another variant, or patch by id: the whole variant - patch(task: Task | Id, state: NonTerminal): void; - settle(task: Task | Id, state: Terminal): void; // terminal; retires scratch - // NonTerminal / Terminal 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>(kind: TaskDefinition, + spec: { state: S; after?: Id[]; background?: true; owns?: Id[] }): Id; + patch, + A extends NoInfer>>>( + task: TypedTask, ...args: A & NoInfer, A>>): void; + settle, + A extends NoInfer>>>( + task: TypedTask, ...args: A & NoInfer, 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(kind: TaskKind, spec: { + task>( + kind: TaskDefinition & { 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; diff --git a/packages/agent/docs/pico/pico-work.md b/packages/agent/docs/pico/pico-work.md index 535edbcb5..72669c49d 100644 --- a/packages/agent/docs/pico/pico-work.md +++ b/packages/agent/docs/pico/pico-work.md @@ -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()({ ... })` 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>`, 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