Merge branch 'v3' into v3-t17

This commit is contained in:
2026-11-07 20:55:49 +00:00
73 changed files with 135909 additions and 261 deletions
+54
View File
@@ -0,0 +1,54 @@
# Dream
A dream is a read-only review in an ordinary branch. It does not change code, memory, settings, or storage automatically.
The user decides which proposals to accept. Approved proposals become ordinary work.
## For @ultimate
A user request or a built-in automation can request a dream.
If a dream branch is active, report that fact. Do not start another branch.
Otherwise, write a handoff while the current context still contains the relevant evidence.
Include open work, decisions, user corrections, memory slugs, and the next steps.
Use `branch` with an explicit repository path and that handoff in its prompt.
Ask the branch to follow this document.
Review the branch report with the user. Apply approved memory changes through the memory workflow.
Start ordinary branches for approved code or instruction changes.
Use `dream` with `action: prepare`, exactly three `learned` lines, and a fresh `handoff` when the review finishes.
Each learned line has at most 200 characters. The handoff has at most 12,000 characters.
A later preparation replaces the previous prepared handoff.
The preparation adds a passive `dream.prepared` entry. It does not switch storage or restart the host.
There are no chapters, seeds, or Fall asleep actions.
## For the dream branch
Coordinate these six read-only passes with background `dream-pass` subagents:
- **memory:** Propose evidence-based additions, updates, or deletions. Preserve the memory about the user.
- **failures:** Group repeated failures by cause. Propose small corrections.
- **tools:** Examine supplied tool results and repeated manual steps. Do not invent trace statistics.
- **corrections:** Identify repeated user corrections and requests.
- **codify:** Identify lessons that belong in code, agent instructions, or a reusable procedure.
- **project:** Identify repeated work that a project-specific program can simplify.
Give every pass the handoff and its relevant evidence. The handoff is the primary input, not proof by itself.
P3 supplies no general history-query or trace database interface. Name missing evidence rather than invent a query.
Each pass cites conversation IDs, commits, or explicit file paths and line numbers.
Check each report against its evidence. Return three learned lines, a journal, and one proposal per slug.
Each proposal names its pass, title, size, risk, evidence, and any memory it replaces.
Return the final answer normally. The messages service reports the branch answer to its starter.
Do not edit files, apply proposals, or switch storage during a dream.
## Built-in automations
Dream creates two ordinary context automations after @ultimate exists:
- `Dream before compaction`: the context exceeds 70%.
- `Dream after a pause`: the context exceeds 50% after eight hours without user input.
The automations wake @ultimate. Each fire also adds one passive `dream.requested` entry.
The automation ID and fire number identify that entry across replay and reopen.
Creation keys recover a lost receipt without a duplicate automation.
A user can pause or delete an automation. Reopen preserves those choices.
+40
View File
@@ -0,0 +1,40 @@
/** Two ordinary context automations, with durable creation and deletion receipts. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { defineDoc, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { Automations, AutomationsApi } from '../automations/server.ts'
import { Standing } from '../subagents/api.ts'
import { deliver } from './delivery.ts'
export const AUTOMATION_NAME='Dream before compaction'
export const PAUSE_NAME='Dream after a pause'
export const BuiltInDoc=defineDoc<{id?:string;deleted?:true}>({kind:'dream.after-compaction',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})})
export const PauseDoc=defineDoc<{id?:string;deleted?:true}>({kind:'dream.after-pause',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})})
const builtIns=[
{doc:BuiltInDoc,name:AUTOMATION_NAME,condition:{metric:'context',of:'@ultimate',above:70}},
{doc:PauseDoc,name:PAUSE_NAME,condition:{metric:'context',of:'@ultimate',above:50,idleFor:8*3600_000}},
]
export const DREAM_ASK='Dream now: follow "For @ultimate" in /src/sausage/extensions/dream/DREAM.md. Write your handoff into the dream branch prompt in this turn, while your context still holds everything. If a dream branch is already active, say so and do not start another.'
export async function ensureAutomation(host:any) {
const service=host.want(AutomationsApi)
if(!service || (await host.harness().snapshot(Standing,ROOT,context))?.ultimate===undefined) return
for(const {doc,name,condition} of builtIns) {
const state=await host.harness().snapshot(doc,ROOT,context)
if(state?.deleted) continue
if(state?.id) {
const list=await service.operate({action:'list'})
if(!list.some((a:any)=>a.id===state.id)) await host.harness().commit(async(tx:any)=>{(await tx.doc(doc,ROOT)).deleted=true},context)
} else {
const made=await service.operate({action:'create',name,condition,subscribers:['@ultimate'],key:`dream:${doc.definition.kind}`})
await host.harness().commit(async(tx:any)=>{(await tx.doc(doc,ROOT)).id=made.id},context)
}
}
}
export async function fired(host:any,tx:any,id:string,fire:number) {
const builtIn=await Promise.all(builtIns.map(async(item)=>({item,id:(await tx.doc(item.doc,ROOT)).id})))
const found=builtIn.find(item=>item.id===id)?.item
const ultimate=(await tx.doc(Standing,ROOT)).ultimate
if(!found || ultimate===undefined) return
const automation=(await tx.doc(Automations)).automations[id]
const pause=found.doc===PauseDoc && automation ? Date.now()-(automation.condition?.inputAt??automation.createdAt) : undefined
const data={automation:{id,name:automation?.name??found.name,...(pause===undefined?{}:{pause})}}
await deliver(host,tx,ultimate,{type:'write',requestId:`dream.requested:${id}:${fire}`,kind:'dream.requested',data,content:`Automation ${id} "${data.automation.name}" fired${pause===undefined?'':` after ${Math.round(pause/3600_000)} h away`}. ${DREAM_ASK}`})
}
+16
View File
@@ -0,0 +1,16 @@
/** Durable dream inputs and passive notes, admitted once per request ID. */
import { defineTask } from '@earendil-works/pi-durable'
const terminal = {status:'terminal',outcome:{status:'completed',result:null}} as const
export const deliverTask = (host:any) => defineTask<any,{phase:'submit'},null>({
name:'dream.deliver', version:1, initial:()=>({phase:'submit'}),
phases:{submit:async(task,runtime,ctx)=>{
const {to,type,requestId,kind,data,content}=task.input
const target=(await host.harness().conversation(to,ctx))!
await target.submit(type==='input'
? {type,requestId,content,entry:{kind,data},whenBusy:'followUp'}
: {type,requestId,entry:{kind,data,model:[{role:'user',content,timestamp:Date.now()}]}},ctx)
await runtime.commit(()=>terminal,ctx)
}},
abort:(_task,runtime,ctx)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx),
})
export const deliver = (host:any,tx:any,to:number,delivery:any) => tx.createTask(deliverTask(host),{to,...delivery},{conversationId:to,ownership:{kind:'conversation'},background:true})
+58
View File
@@ -0,0 +1,58 @@
/** Dream requests and prepared handoffs in the current world. No storage chapters or host restart. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { defineDoc, defineExtension, defineTool, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { arr, obj, opt, str } from '@earendil-works/pi-schema'
import { Fired } from '../automations/refs.ts'
import { Standing, Started } from '../subagents/api.ts'
import { BuiltInDoc, PauseDoc, DREAM_ASK, ensureAutomation, fired } from './built-ins.ts'
import { deliver, deliverTask } from './delivery.ts'
export const DreamApi={name:'dream'}
export const Next=defineDoc<any>({kind:'dream.next',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})})
const Receipts=defineDoc<Record<string,any>>({kind:'dream.tool-receipts',version:1,scope:'task',checkpointWhen:()=>false,initial:()=>({})})
const plain=(value:any)=>JSON.parse(JSON.stringify(value))
export function prepare(args:any,now:number) {
if(!Array.isArray(args?.learned)||args.learned.length!==3) throw new Error('learned must be 3 lines')
const text=(value:any,name:string,max:number)=>{
if(typeof value!=='string'||!value.trim()) throw new Error(`${name} must be nonempty text`)
if(value.trim().length>max) throw new Error(`${name} must be at most ${max} characters`)
return value.trim()
}
return {learned:args.learned.map((line:any)=>text(line,'learned',200)),handoff:text(args.handoff,'handoff',12000),at:now}
}
export default function dream(host:any) {
const queue=host.resource('dream.ensure',()=>({last:Promise.resolve()}))
const ensure=()=>{
const run=queue.last.then(()=>ensureAutomation(host))
queue.last=run.catch(()=>undefined)
return run.catch((error:any)=>host.notice(`Dream automations: ${String(error)}`))
}
host.onOpen(ensure)
host.react('ultimate-started',[Started],(_tx:any,{data}:any)=>{
// Creation commits run after this reactor transaction completes.
if(data.kind==='standing'&&data.agent==='ultimate') void ensure()
},{from:'now'})
host.react('requested',[Fired],(tx:any,{data}:any)=>fired(host,tx,data.id,data.fire),{from:'now'})
async function change(tx:any,args:any) {
const ultimate=(await tx.doc(Standing,ROOT)).ultimate
if(ultimate===undefined) throw new Error('@ultimate has no conversation yet')
if(args.action==='start') {
await deliver(host,tx,ultimate,{type:'input',requestId:`dream.requested:${crypto.randomUUID()}`,kind:'dream.requested',data:{},content:`The user requested a dream. ${DREAM_ASK}`})
return {requested:true}
}
if(args.action!=='prepare') throw new Error(`Unknown dream action ${args.action}`)
const prepared=prepare(args,Date.now())
;(await tx.doc(Next,ROOT)).prepared=prepared
await deliver(host,tx,ultimate,{type:'write',requestId:`dream.prepared:${crypto.randomUUID()}`,kind:'dream.prepared',data:prepared,content:`Prepared dream handoff:\n${prepared.learned.join('\n')}\n\n${prepared.handoff}\nContinue in this world. No chapter switch is available.`})
return prepared
}
host.implement(DreamApi,{operate:(args:any)=>host.harness().commit((tx:any)=>change(tx,args),context)})
return defineExtension({name:'dream',tasks:[deliverTask(host)],tools:[defineTool({name:'dream',description:'Request a read-only dream branch, or prepare three learned lines and a handoff in the current world. No chapter switch.',parameters:obj({action:str(),learned:opt(arr(str())),handoff:opt(str())}),execute:async(args,api,ctx)=>{
const result=await api.commit(async(tx:any)=>{
const receipts=await tx.doc(Receipts,api.taskId)
if(receipts[api.callId]===undefined) receipts[api.callId]=await change(tx,args)
return plain(receipts[api.callId])
},ctx)
return {content:[{type:'text',text:JSON.stringify(result)}]}
}})]})
}
export const docs=[Next,BuiltInDoc,PauseDoc]
+22
View File
@@ -0,0 +1,22 @@
# Hands-off goals
The user starts or ends a goal through the exported server actions.
Only the standing `@ultimate` conversation uses the `goal` tool.
The root document stores one open goal and its decisions.
Three failed tries block a criterion. Rewording preserves unchanged criteria.
Decisions can name screenshots from the caller's conversation.
The `latest` reference never crosses an input boundary.
Each input to `@ultimate` receives the current open goal note.
A queued start card grants nothing after the user ends the goal.
The start wakes `@ultimate`. The report uses a passive write.
The optional automations service creates one keyed reminder every 30 minutes.
A durable task synchronizes its state after each lifecycle change.
A replay adopts the existing reminder. A user-deleted reminder stays deleted.
Pause disables the reminder. Resume enables it. End deletes it.
A reminder input adds the reminder checklist to the goal note.
World automations identify a fire with the `[automation <id> fired]` prefix.
The goal hook recognizes that prefix and the structured `automations.fired` entry.
Side chats and browser components are outside this server port.
+4
View File
@@ -0,0 +1,4 @@
/** Server action references. Side chats are not part of the world port. */
export const docRef = <T>(kind: string) => ({kind})
export const actionRef = <A, R>(extension: string, action: string) => ({extension, action})
export const implementAction = (_ref: unknown, run: any) => run
+47
View File
@@ -0,0 +1,47 @@
/** Durable delivery of goal start cards and passive report cards. */
import { defineTask } from '@earendil-works/pi-durable'
import type { ConversationId, Tx } from '@earendil-works/pi-durable'
import type { Host } from '../../host/js/extensions.ts'
/** A goal card for @ultimate: `goal.started` as an input that wakes it, `goal.report` as a write. */
type Card = { to: number; goal: number; kind: 'goal.started' | 'goal.report'; content: string }
/** Created in the commit that changes the goal, so a crash cannot lose the card; the request id makes retries harmless. */
export const deliverTask = (host: Pick<Host, 'harness'>) =>
defineTask<Card, { phase: 'submit' }, null>({
name: 'goal.deliver',
version: 1,
initial: () => ({ phase: 'submit' }),
phases: {
submit: async (task, runtime, ctx) => {
const { to, goal, kind, content } = task.input
const requestId = `${kind}:${goal}`
const data = { goal }
// The host handle, unlike the task runtime's, admits passive writes.
const target = (await host.harness().conversation(to as ConversationId, ctx))!
await target.submit(
kind === 'goal.started'
? { type: 'input', requestId, content, entry: { kind, data }, whenBusy: 'followUp' }
: {
type: 'write',
requestId,
entry: { kind, data, model: [{ role: 'user', content, timestamp: Date.now() }] },
},
ctx,
)
await runtime.commit(
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
ctx,
)
},
},
abort: (_task, runtime, ctx) =>
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
})
export const deliver = (host: Pick<Host, 'harness'>, tx: Tx, card: Card) =>
tx.createTask(deliverTask(host), card, {
conversationId: card.to as ConversationId,
ownership: { kind: 'conversation' },
background: true,
})
+57
View File
@@ -0,0 +1,57 @@
/** Hands-off goals in the browser: the goal the user put @ultimate on, and every decision it took for them. Browser-safe. */
import { actionRef, docRef } from '../goal/compat.ts'
/** One decision @ultimate took instead of asking the user. */
export type Decision = {
at: number
/** What it would have asked the user. */
question: string
/** What it chose. */
picked: string
reason: string
/** The branch the decision was for. */
conversation?: number
/** The entry of @ultimate's conversation holding the screenshots it judged, such as a UI preview it approved. */
images?: number
}
/** How a goal ended: met, blocked (every remaining criterion stuck), or ended by the user. */
export type Ending = {
at: number
by: 'user' | 'ultimate'
outcome: 'met' | 'blocked' | 'ended'
summary?: string
}
/** What "done" means, as @ultimate wrote it down: each criterion open, met, or blocked after repeated failed tries. */
export type Criterion = { text: string; status: 'open' | 'met' | 'blocked'; tries?: number }
export type Goal = {
id: number
/** The user's words: what to reach, and when to stop if they said so. */
text: string
startedAt: number
criteria: Criterion[]
decisions: Decision[]
/** The "Goal reminder" automation that wakes @ultimate every 30 minutes while the goal is open. */
reminder?: string
/** Set while @ultimate is blocked on the user: the reminder is paused until it resumes. */
waiting?: { reason: string; at: number }
ended?: Ending
}
/** `{ next, goals }` on conversation 1, oldest first; at most one goal is open (no `ended`). */
export const GoalDoc = docRef<{ next: number; goals: Goal[] }>('goal')
/** `goal.started` and `goal.report` entries in @ultimate's conversation name their goal. */
export type GoalEntry = { goal: number }
/** `{ text }` → the new goal. User-only: puts @ultimate hands-off on it. */
export const StartAction = actionRef<{ text: string }, Goal>('goal', 'start')
/** No arguments → the ended goal. User-only: ends the open goal; @ultimate asks again as usual. */
export const EndAction = actionRef<null, Goal>('goal', 'end')
/** `{ goal, entry }` → the images of a decision's screenshot entry. */
export const ImagesAction = actionRef<
{ goal: number; entry: number },
{ data: string; mimeType: string }[]
>('goal', 'images')
+94
View File
@@ -0,0 +1,94 @@
/**
* The goal reminder: an automation that wakes @ultimate every 30 minutes while a goal is open, so a stalled goal never
* waits silently. Every goal change (start, pause, resume, end) creates a sync task in its own commit; the task brings
* the automation in line with the goal as it is when the task runs. A crash leaves at most a task to rerun, and a rerun
* gets the automation an earlier try made back from its create key instead of making a second one.
*/
import { defineTask, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import type { ConversationDocToken, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import type { Automation } from '../automations/types.ts'
import type { Goal } from './refs.ts'
export const SCHEDULE = 'every 30m'
export const reminderName = (goal: number) => `Goal reminder · goal ${goal}`
/** Each goal's reminder has its own create key: a rerun gets it back, and no goal touches another's. */
const reminderKey = (goal: number) => `goal.reminder:${goal}`
/**
* Steps at most: each makes one change (create, link, delete, pause or resume) and looks again, so a pause and resume
* that race still end on the newest goal.
*/
const STEPS = 6
type Goals = ConversationDocToken<{ next: number; goals: Goal[] }>
export function reminderTask(host: Host, Goals: Goals) {
const find = async (tx: Tx, id: number) =>
(await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((item) => item.id === id)
/** One look: true when it changed something and must look again, false once the automation matches the goal. */
async function step(id: number): Promise<boolean> {
const automations = host.want<{ operate(args: Record<string, unknown>): Promise<unknown> }>(AutomationsApi)
// Automations is off: the goal runs without a reminder.
if (!automations) return false
// A plain copy: the document's overlay is unusable after the commit.
const goal = await host
.harness()
.commit(
async (tx) =>
await find(tx, id).then((found) => found && (JSON.parse(JSON.stringify(found)) as Goal)),
context,
)
const list = (await automations.operate({ action: 'list' })) as Automation[]
// The linked one by its id, whatever the user renamed it to; before it is linked, the one its key made.
const own = goal?.reminder
? list.find((item) => item.id === goal.reminder)
: list.find((item) => item.key === reminderKey(id))
if (!goal || goal.ended) {
if (!own) return false
await automations.operate({ action: 'delete', id: own.id })
return true
}
if (!own) {
// One the user deleted in the Automations panel stays deleted.
if (goal.reminder) return false
await automations.operate({
action: 'create',
name: reminderName(id),
schedule: SCHEDULE,
subscribers: ['@ultimate'],
key: reminderKey(id),
})
return true
}
if (goal.reminder !== own.id) {
await host.harness().commit(async (tx) => {
const current = await find(tx, id)
if (current) current.reminder = own.id
}, context)
return true
}
if (own.enabled === !goal.waiting) return false
if (goal.waiting) await automations.operate({ action: 'pause', id: own.id })
else await automations.operate({ action: 'resume', id: own.id })
return true
}
return defineTask<{ goal: number }, { phase: 'sync' }, null>({
name: 'goal.reminder',
version: 1,
initial: () => ({ phase: 'sync' }),
phases: {
sync: async (task, runtime, ctx) => {
for (let i = 0; i < STEPS; i++) if (!(await step(task.input.goal))) break
await runtime.commit(
() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }),
ctx,
)
},
},
abort: (_task, runtime, ctx) =>
runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
})
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Hands-off goals: the user puts @ultimate on a goal and goes away. While it is open, @ultimate decides what it would
* otherwise ask them, records each decision with its reason, and ends the goal with a report. Only the user starts or
* ends a goal early; @ultimate hears it on every run through a per-input note.
*/
import { arr, int, lit, obj, oneOf, opt, str, union } from '@earendil-works/pi-schema'
import {
defineDoc,
defineExtension,
defineTool,
GenerationTask,
hook,
ROOT_CONVERSATION_ID,
} from '@earendil-works/pi-durable'
import type { ConversationId, EntryId, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { implementAction } from '../goal/compat.ts'
import { imagesOf, latestImagesEntry } from '../messages/compat.ts'
import type { Host } from '../../host/js/extensions.ts'
import { AutomationsApi } from '../automations/server.ts'
import { Standing } from '../subagents/api.ts'
import { deliver, deliverTask } from './delivery.ts'
import { reminderTask } from './reminder.ts'
import { EndAction, GoalDoc, ImagesAction, StartAction } from './refs.ts'
import type { Criterion, Decision, Ending, Goal } from './refs.ts'
/** Failed tries after which a criterion is blocked; work goes on with the others. */
export const TRIES = 3
const TEXT_CHARS = 2000
export const Goals = defineDoc<{ next: number; goals: Goal[] }>({
kind: GoalDoc.kind,
checkpointWhen: () => false,
version: 1,
scope: 'conversation',
history: 'latest',
fork: 'initial',
initial: () => ({ next: 1, goals: [] }),
})
export const docs = [Goals]
/** A plain copy: the document's overlay is a proxy that structuredClone refuses, and unusable after the commit. */
const copy = (goal: Goal) => JSON.parse(JSON.stringify(goal)) as Goal
const ultimateOf = async (tx: Tx) => (await tx.doc(Standing, ROOT_CONVERSATION_ID)).ultimate
/** The open goal, if any. */
export async function openGoal(tx: Tx) {
return (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find((goal) => !goal.ended)
}
const text = (value: unknown, name: string) => {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be nonempty`)
if (value.trim().length > TEXT_CHARS)
throw new Error(`${name} must be at most ${TEXT_CHARS} characters`)
return value.trim()
}
/** Start a goal for the user's words `words`; refuses while another is open. */
export async function start(tx: Tx, words: unknown, now: number) {
const goals = await tx.doc(Goals, ROOT_CONVERSATION_ID)
const open = goals.goals.find((goal) => !goal.ended)
if (open) throw new Error(`Goal ${open.id} is still open; end it first`)
const goalText = text(words, 'The goal')
const goal: Goal = {
id: goals.next++,
text: goalText,
startedAt: now,
criteria: [],
decisions: [],
}
goals.goals.push(goal)
return goal
}
/** End the open goal; refuses when none is open. */
export async function end(tx: Tx, ending: Ending) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No goal is open')
goal.ended = ending
return copy(goal)
}
const status = (criterion: Criterion) =>
criterion.status === 'open' && criterion.tries
? `open, ${criterion.tries}/${TRIES} failed tries`
: criterion.status
/**
* @ultimate's per-run note while a goal is open: the goal, its criteria, and how it works; on a fire of its reminder,
* what to check.
*/
export function noteText(goal: Goal, reminded = false) {
const criteria = goal.criteria.length
? goal.criteria.map((item, index) => `${index + 1}. [${status(item)}] ${item.text}`).join('\n')
: "None yet: write them first with goal {action: 'criteria', criteria}, each one you can check."
return [
`Hands-off goal ${goal.id}, granted by the user, who is away: ${goal.text}`,
`Done means:\n${criteria}`,
`Work toward it without the user: decide what you would ask them yourself (a go, a decision, a UI preview approval against the criteria), record each with goal {action: 'decide'} and its reason, and keep going. ${goal.decisions.length} decided so far. Mark criteria met or failed as you check them; a criterion is blocked after ${TRIES} failed tries, so learn from each failure before the next try. Finish with goal {action: 'finish'} when every criterion is met or every open one is blocked. Only when you are truly blocked on the user, goal {action: 'pause', reason} stops the reminder that wakes you.`,
...(goal.waiting
? [
`Waiting on the user since ${new Date(goal.waiting.at).toISOString()}: ${goal.waiting.reason}. The reminder is paused. Once they answer and you can go on, goal {action: 'resume'} turns it back on.`,
]
: []),
...(reminded
? [
"This is the goal reminder: check that the goal's branches are moving, the lander queue, reactor lag and watchdog cards. Nudge or fix what is stuck and record a decide only when you changed something; otherwise end the turn quietly.",
]
: []),
].join('\n\n')
}
/** The newest entry with images in this turn of conversation `id`, or entry `ref` when it has some. */
async function imagesEntry(tx: Tx, id: ConversationId, ref: 'latest' | number) {
if (ref !== 'latest') {
const entry = await tx.entry(ref as EntryId)
if (entry?.conversationId !== id || !imagesOf(entry).length)
throw new Error(`images: entry ${ref} in your conversation has no images`)
return ref
}
return (await latestImagesEntry(tx, id)).id as number
}
type ToolArgs = {
action: 'criteria' | 'decide' | 'met' | 'failed' | 'finish' | 'pause' | 'resume'
criteria?: string[]
n?: number
question?: string
picked?: string
reason?: string
conversation?: number
images?: 'latest' | number
outcome?: 'met' | 'blocked'
summary?: string
}
/** One `goal` tool call by @ultimate (`ultimate`) in `tx`; returns its answer, and the goal when it finished. */
export async function act(tx: Tx, ultimate: ConversationId, args: ToolArgs, now: number) {
const goal = await openGoal(tx)
if (!goal) throw new Error('No hands-off goal is open; ask the user as usual.')
const criterion = () => {
const found = goal.criteria[(args.n ?? 0) - 1]
if (!found) throw new Error(`No criterion ${String(args.n)}; there are ${goal.criteria.length}`)
return found
}
if (args.action === 'criteria') {
const items = (args.criteria ?? []).map((item) => text(item, 'A criterion'))
if (!items.length) throw new Error('criteria must list at least one')
// Rewording keeps what was learned about a criterion with the same text.
goal.criteria = items.map(
(item) =>
goal.criteria.find((old) => old.text === item) ?? { text: item, status: 'open' as const },
)
return { answer: `Goal ${goal.id} has ${items.length} criteria.` }
}
if (args.action === 'decide') {
const decision: Decision = {
at: now,
question: text(args.question, 'question'),
picked: text(args.picked, 'picked'),
reason: text(args.reason, 'reason'),
...(args.conversation === undefined ? {} : { conversation: args.conversation }),
...(args.images === undefined
? {}
: { images: await imagesEntry(tx, ultimate, args.images) }),
}
goal.decisions.push(decision)
return { answer: `Recorded decision ${goal.decisions.length}: ${decision.picked}` }
}
if (args.action === 'met') {
criterion().status = 'met'
return { answer: `Criterion ${args.n} met.` }
}
if (args.action === 'failed') {
const found = criterion()
found.tries = (found.tries ?? 0) + 1
if (found.tries >= TRIES) found.status = 'blocked'
const open = goal.criteria.filter((item) => item.status === 'open').length
return {
answer:
found.status === 'blocked'
? `Criterion ${args.n} is blocked after ${TRIES} failed tries.${open ? ` Go on with the ${open} still open.` : ' None is open: finish the goal as blocked.'}`
: `Criterion ${args.n}: ${found.tries}/${TRIES} failed tries. Say what you change before the next try.`,
}
}
if (args.action === 'pause') {
goal.waiting = { reason: text(args.reason, 'reason'), at: now }
return {
answer: `Goal ${goal.id} waits on the user; the reminder is paused. Ask them now.`,
sync: goal.id,
}
}
if (args.action === 'resume') {
if (!goal.waiting) throw new Error(`Goal ${goal.id} is not paused`)
delete goal.waiting
return {
answer: `Goal ${goal.id} goes on; the reminder wakes you again.`,
sync: goal.id,
}
}
if (args.outcome !== 'met' && args.outcome !== 'blocked')
throw new Error("finish needs outcome 'met' or 'blocked'")
const ended = await end(tx, {
at: now,
by: 'ultimate',
outcome: args.outcome,
summary: text(args.summary, 'summary'),
})
return {
answer: `Goal ${goal.id} finished: ${args.outcome}. The report is in your conversation.`,
ended,
}
}
/** The report card's model text: what @ultimate (or the user, ending it) should take from it. */
const reportText = (goal: Goal) =>
goal.ended?.by === 'user'
? `The user ended hands-off goal ${goal.id}. From now on ask them as usual.`
: `Hands-off goal ${goal.id} ended (${goal.ended?.outcome}); its report card is here for the user. From now on ask them as usual.`
/**
* Goal `goal`'s card of `kind` for @ultimate, delivered by a task created in `tx`, the commit that changed the goal.
* The start card says only that the user started it: what @ultimate may do comes from the goal note, built from the
* goal as it is when the input runs, so a start still queued after the user ended the goal grants nothing.
*/
async function card(host: Host, tx: Tx, kind: 'goal.started' | 'goal.report', goal: Goal) {
const to = await ultimateOf(tx)
if (to === undefined) throw new Error('@ultimate has no conversation yet')
await deliver(host, tx, {
to,
goal: goal.id,
kind,
content:
kind === 'goal.started' ? `The user started hands-off goal ${goal.id}.` : reportText(goal),
})
}
/** `name` of an action's arguments; the helpers above validate it. */
const field = (args: unknown, name: string) =>
args && typeof args === 'object' ? (args as Record<string, unknown>)[name] : undefined
/** In the commit that changes goal `goal`: a task that brings its reminder in line once the change is durable. */
const sync = (host: Host, tx: Tx, goal: number) =>
tx.createTask(
reminderTask(host, Goals),
{ goal },
{
conversationId: ROOT_CONVERSATION_ID,
ownership: { kind: 'conversation' },
background: true,
},
)
/** Starting and ending a goal is the user's grant; @ultimate only finishes one through its tool. */
export const userActions = ['start', 'end']
export const actions = {
start: implementAction(StartAction, (args, { host }) =>
host.harness().commit(async (tx) => {
const goal = await start(tx, field(args, 'text'), Date.now())
await card(host, tx, 'goal.started', goal)
await sync(host, tx, goal.id)
return copy(goal)
}, context),
),
end: implementAction(EndAction, (_args, { host }) =>
host.harness().commit(async (tx) => {
const goal = await end(tx, { at: Date.now(), by: 'user', outcome: 'ended' })
await card(host, tx, 'goal.report', goal)
await sync(host, tx, goal.id)
return goal
}, context),
),
images: implementAction(ImagesAction, async (args, { host }) =>
host.harness().commit(async (tx) => {
const goal = (await tx.doc(Goals, ROOT_CONVERSATION_ID)).goals.find(
(item) => item.id === field(args, 'goal'),
)
const entry = field(args, 'entry')
// Only the screenshots a decision names, not any entry by id.
if (!goal?.decisions.some((decision) => decision.images === entry))
throw new Error('No decision of that goal has these images')
return imagesOf(await tx.entry(entry as EntryId)).map(({ data, mimeType }) => ({
data,
mimeType,
}))
}, context),
),
}
export default function goalExtension(host: Host) {
// Optional: without automations a goal runs as before, with no reminder.
host.want(AutomationsApi)
return defineExtension({
name: 'goal',
tasks: [deliverTask(host), reminderTask(host, Goals)],
hooks: [
hook(GenerationTask, {
// Pi upstream: @earendil-works/pi-durable@1.5.0 — patched transactional beforeInput hook.
// A per-run note while a goal is open, never a system-prompt section, keeps the prompt cached.
beforeInput: async (input, { tx, conversationId }) => {
if ((await ultimateOf(tx)) !== conversationId) return undefined
const goal = await openGoal(tx)
if (!goal) return undefined
// World automations use a plain input with their fire prefix.
const reminded = goal.reminder !== undefined && (
(input.kind === 'automations.fired' &&
(input.data as { id?: string } | undefined)?.id === goal.reminder) ||
input.model?.some((message) => message.role === 'user' &&
(typeof message.content === 'string' ? message.content :
message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n'))
.startsWith(`[automation ${goal.reminder} fired]`))
)
return {
entries: [
{
kind: 'goal.note',
data: { goal: goal.id },
model: [
{
role: 'user' as const,
content: noteText(goal, reminded),
timestamp: Date.now(),
},
],
},
],
}
},
}),
],
tools: [
defineTool({
name: 'goal',
deferred: true,
description:
"The hands-off goal the user put you on (only while one is open; the goal note names it): criteria {criteria: [...]} writes what done means, each checkable; decide {question, picked, reason, conversation?, images?} records a decision you took instead of asking the user (images: \"latest\" or an entry id of your conversation with screenshots you judged); met {n} / failed {n} mark criterion n; finish {outcome: 'met' | 'blocked', summary} ends the goal and leaves the report card; pause {reason} when you are truly blocked on the user stops the 30-minute goal reminder and shows them what you wait on, resume turns it back on once you can go on.",
parameters: obj({
action: oneOf(['criteria', 'decide', 'met', 'failed', 'finish', 'pause', 'resume']),
criteria: opt(arr(str())),
n: opt(int({ minimum: 1 })),
question: opt(str()),
picked: opt(str()),
reason: opt(str()),
conversation: opt(int({ minimum: 1 })),
images: opt(union([lit('latest'), int({ minimum: 1 })])),
outcome: opt(oneOf(['met', 'blocked'])),
summary: opt(str()),
}),
// Not replay-safe (the default): an interrupted call is never rerun, so a decision is recorded at most once.
async execute(args, api, ctx) {
const caller = api.conversationId
const result = await api.commit(async (tx) => {
if ((await ultimateOf(tx)) !== caller)
throw new Error('Only @ultimate keeps the hands-off goal.')
const acted = await act(tx, caller, args, Date.now())
if (acted.ended) await card(host, tx, 'goal.report', acted.ended)
const changed = acted.sync ?? acted.ended?.id
if (changed !== undefined) await sync(host, tx, changed)
return acted
}, ctx)
return { content: [{ type: 'text', text: result.answer }] }
},
}),
],
})
}
+7 -2
View File
@@ -14,6 +14,7 @@ export const branchTool = (host: any) => defineTool({
let id = await api.memo<number>('branch-id', ctx)
if (id === undefined) {
const lineage = host.need(LineageApi)
const workspaces = input.isolated ? host.need({name:'workspaces'}) : undefined
const roots = (await api.snapshot(Roots, ROOT_CONVERSATION_ID, ctx))?.roots ?? []
const root = input.root ?? await lineage.rootOf(api.conversationId, ctx) ?? (roots.length === 1 ? roots[0] : undefined)
if (root === undefined || !roots.includes(root)) throw new Error('Choose a listed root')
@@ -21,9 +22,13 @@ export const branchTool = (host: any) => defineTool({
const coordinator = definitions.find((definition: any) => definition.name === 'coordinator')
if (!coordinator) throw new Error('Missing coordinator definition')
const rootAgent = await (await host.harness().conversation(root, ctx))!.agent(ctx)
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, isolated: input.isolated ?? false, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
const branch = await lineage.branch({from: root, repo: input.repo, prompt: input.prompt, title: input.title, deliver: false, by: api.conversationId, receipt: {taskId: api.taskId, callId: api.callId}}, ctx)
const existing = await workspaces?.get(branch.id, ctx)
const cwd = existing ? (await (await host.harness().conversation(branch.id, ctx)).agent(ctx)).cwd : input.repo
const ancestors = workspaces ? (await workspaces.chain(branch.id, ctx)).slice(1) : []
await host.harness().commit(async (tx: any) => {
await configureChild(host, tx, branch.id, {definition: coordinator, parentAgent: rootAgent, cwd: input.repo})
await configureChild(host, tx, branch.id, {definition: coordinator, parentAgent: rootAgent, cwd})
if (workspaces && !existing) await workspaces.create(tx, branch.id, input.repo, ancestors)
;(await tx.doc(host.metadata, branch.id)).label = 'coordinator'
}, ctx)
id = await api.memo('branch-id', branch.id, ctx)
+17
View File
@@ -0,0 +1,17 @@
# Question cards
The exported user actions answer or dismiss an open message to the user.
The messages service delivers the answer to the asking conversation.
The action validates the question text and rejects duplicate answers.
Only one concurrent answer settles an open message.
Answer text preserves the message's numbers and option keys.
A note follows the answers. A note alone also settles the message.
Free text answers a message without structured answers.
Dismissal sends a passive note and starts no run.
Closed messages reject further answers and dismissals.
The messages extension owns question schemas and open-question notes.
Browser components are outside this server port.
The “settling for the user” case awaits the later world UI work.
It uses the messages UI action, not the question-card service.
+26
View File
@@ -0,0 +1,26 @@
/**
* Questions in the browser: the actions that answer an agent's questions to the user from its message's card.
* Browser-safe.
*/
import { actionRef } from '../goal/compat.ts'
/** One answered question: the question's text and the chosen or typed answer (several choices joined by ", "). */
export type Answer = { question: string; answer: string }
/**
* `{ messageId, answers?, note? }` or `{ messageId, text }` → `{ id }`: answer open message `messageId` with a reply
* to its sender, from its questions (`note` goes after the answer lines) or as free text; `id` is the reply's message
* id. Fails when the message no longer waits on the user.
*/
export const AnswerAction = actionRef<
{ messageId: number; answers?: Answer[]; note?: string; text?: string },
{ id: number }
>('questions', 'answer')
/**
* `{ messageId }` → `{ id }`: close open message `messageId` without an answer; its sender gets a one-way note,
* `id`, that starts no run. Fails when the message no longer waits on the user.
*/
export const DismissAction = actionRef<{ messageId: number }, { id: number }>(
'questions',
'dismiss',
)
+109
View File
@@ -0,0 +1,109 @@
/** User actions that answer or dismiss question cards through the messages service. */
import { defineExtension, ROOT_CONVERSATION_ID } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT as context, withAbortSignal } from '@earendil-works/chord/context'
import type { Host } from '../../host/js/extensions.ts'
import { Messages, MessagesApi } from '../messages/api.ts'
import type { Message, Question } from '../messages/api.ts'
import { implementAction } from '../goal/compat.ts'
import { AnswerAction, DismissAction } from './refs.ts'
const open = (message: Message) =>
message.to === 'user' && message.reply && message.state === 'delivered'
const NOT_OPEN = 'These questions are no longer open'
function validAnswers(value: unknown): value is { question: string; answer: string }[] {
return (
Array.isArray(value) &&
value.every(
(a: { question?: unknown; answer?: unknown } | null) =>
a && typeof a.question === 'string' && typeof a.answer === 'string' && a.answer.trim(),
)
)
}
function checkAnswers(answers: { question: string }[], questions: Question[]) {
if (
new Set(answers.map((a) => a.question)).size !== answers.length ||
answers.some((a) => !questions.some((q) => q.text === a.question))
)
throw new Error('Unknown question')
}
/** The keys of the chosen options, in option order: "a", "a,c"; none when an answer names no option. */
function keysOf(question: Question, answer: string) {
const labels = question.kind === 'multi' ? answer.split(', ') : [answer]
const keys = (question.options ?? []).flatMap((o) =>
o.key && labels.includes(o.label) ? [o.key] : [],
)
return keys.length === labels.length ? keys.join(',') : ''
}
/**
* The reply naming each answer by the message's own numbering: `1a. Topic: label` for an option (`2a,c.` for
* several), `3: text` for typed text or a rating, `Topic: answer` when the message numbered nothing; a note from the
* card's field follows the answers.
*/
export function answerText(
answers: { question: string; answer: string }[],
questions: Question[],
note?: string,
) {
const lines = answers.map(({ question, answer }) => {
const q = questions.find((candidate) => candidate.text === question)!
const keys = keysOf(q, answer)
if (!q.ref) return `${q.text}: ${answer}`
return keys ? `${q.ref}${keys}. ${q.text}: ${answer}` : `${q.ref}: ${answer}`
})
if (note?.trim()) lines.push(answers.length ? `Note: ${note.trim()}` : note.trim())
return lines.join('\n\n')
}
export const userActions = ['answer', 'dismiss']
export const actions = {
/** Answer an open message from its questions, with a note, or as free text: a user reply to its sender. */
answer: implementAction(AnswerAction, async (args, { host, signal, device }) => {
const data = args as {
messageId: number
answers?: { question: string; answer: string }[]
note?: unknown
text?: unknown
}
const text = typeof data?.text === 'string' ? data.text.trim() : undefined
const note = typeof data?.note === 'string' ? data.note.trim() : undefined
const answers = data?.answers ?? []
if (
!data ||
!Number.isSafeInteger(data.messageId) ||
(text === undefined
? !validAnswers(answers) || (!answers.length && !note)
: !text || data.answers !== undefined || note)
)
throw new Error('Invalid answers')
const reply = await host.harness().commit(async (tx) => {
const log = await tx.doc(Messages, ROOT_CONVERSATION_ID)
const message = log.messages.find((m) => m.id === data.messageId)
if (!message || !open(message)) throw new Error(NOT_OPEN)
if (text !== undefined) return text
checkAnswers(answers, message.questions ?? [])
return answerText(answers, message.questions ?? [], note)
}, context)
return {
id: await host
.need(MessagesApi)
.answer(data.messageId, reply, withAbortSignal(signal, context), device),
}
}),
/** Close an open message to the user unanswered; its sender gets a one-way note. */
dismiss: implementAction(DismissAction, async (args, { host, signal, device }) => {
const data = args as { messageId: number }
if (!data || !Number.isSafeInteger(data.messageId)) throw new Error('Invalid message')
return {
id: await host
.need(MessagesApi)
.dismiss(data.messageId, withAbortSignal(signal, context), device),
}
}),
}
export default function questionsExtension(host: Host) {
// Core, always on; resolved where it is called, declared here so the catalogue lists it.
host.need(MessagesApi)
return defineExtension({ name: 'questions' })
}
+13 -3
View File
@@ -334,7 +334,7 @@ export default function subagents(host: any) {
const spawnDefinition = async (args: any, definitions: any[]) => {
const definition = definitions.find(d => d.name === args.agent)
if (!definition) throw new Error(`Unknown agent: ${args.agent}. Available definitions: ${definitions.map(d => d.name).join(', ')}`)
if ((args.isolate ?? definition.isolate ?? 'none') !== 'none') throw new Error('worktree isolation requires the workspaces extension (P3-k)')
if ((args.isolate ?? definition.isolate ?? 'none') === 'worktree' && !host.want({name:'workspaces'})) throw new Error('worktree isolation requires the workspaces extension (P3-k)')
return definition
}
const Reporter = createReporter()
@@ -554,11 +554,11 @@ export default function subagents(host: any) {
defineTool({
name: 'subagent',
description:
'Delegate to a named definition. start returns an answer or a background child ID. message steers a busy child unless steer is false. wait returns its latest answer. Foreground waits yield on parent steering. stop ends current child work. list shows your children. Parent stop leaves children independent. fork inherits history before this call. Only isolate none is available until workspaces arrive. Background answers steer the parent exactly once.',
'Delegate to a named definition. start returns an answer or a background child ID. message steers a busy child unless steer is false. wait returns its latest answer. Foreground waits yield on parent steering. stop ends current child work. list shows your children. Parent stop leaves children independent. fork inherits history before this call. Worktree isolation gives the child a checkout. land fast-forwards your checkout and releases the child checkout. discard removes a clean checkout and retains its branch. Background answers steer the parent exactly once.',
// Call-id receipts deduplicate starts/messages; memoized targets and guards protect stop/workspace replays.
replay: 'safe',
parameters: obj({
action: oneOf(['start', 'message', 'wait', 'stop', 'list']),
action: oneOf(['start', 'message', 'wait', 'stop', 'list', 'land', 'discard']),
agent: opt(
str({
description:
@@ -603,6 +603,12 @@ export default function subagents(host: any) {
(!args.id || !Object.hasOwn(index.agents, String(args.id)))
)
throw new Error('id must identify a child of this conversation')
if (args.action === 'land' || args.action === 'discard') {
const workspaces = host.need({name:'workspaces'})
const result = await workspaces[args.action](args.id, context)
if (args.action === 'land') await workspaces.release([args.id], context)
return finish(result.text)
}
if (args.action === 'start' || args.action === 'message') {
const text = args.action === 'start' ? args.task : args.text
if (!text?.trim())
@@ -632,6 +638,8 @@ export default function subagents(host: any) {
if (forkAt === undefined) throw new Error('No parent history before this call')
}
const workspaceChain = definition && (args.isolate ?? definition.isolate ?? 'none') === 'worktree'
? await host.need({name:'workspaces'}).chain(api.conversationId, context) : []
const action = await api.commit(async (tx) => {
const state = await tx.doc(Subagents, api.conversationId)
if (state.actions[api.callId]) return { ...state.actions[api.callId] }
@@ -655,6 +663,8 @@ export default function subagents(host: any) {
: { fork: { conversationId: api.conversationId, at: forkAt } }),
cwd: parent.cwd ?? '/src/sausage',
})
if ((args.isolate ?? definition.isolate ?? 'none') === 'worktree')
await host.need({name:'workspaces'}).create(tx, child.id, parent.cwd ?? '/src/sausage', workspaceChain)
conversationId = child.id
await started(host, tx, child, label!, 'child')
const runs = await tx.doc(Runs, ROOT_CONVERSATION_ID)
+46
View File
@@ -0,0 +1,46 @@
# Workspaces
An isolated child uses `/worktrees/<conversation-id>`.
The preparation task creates its Git checkout before the first tool.
The tool gate checks the registration through the VFS. It does not spawn Git.
Preparation and lander commands use world Git through `host.exec`.
Each command carries its task tag and the conversation chain of its caller.
`subagent start` accepts `isolate: "worktree"`.
A definition can select the same default.
`subagent land` fast-forwards the parent checkout and releases the child checkout.
`subagent discard` removes a clean checkout and retains its branch.
The service refuses release when the branch retains unlanded commits.
`workspace submit` requires a clean checkout and a divergence description.
Use `"None"` when the implementation matches the request.
The lander processes one FIFO queue per source checkout.
It merges the submitted commit in a detached checkout, runs checks, and fast-forwards the source.
A failed check returns an input to the submitter.
A successful landing leaves a report without another model turn.
It sends a one-way note to the branch starter through the messages API.
Both reports use stable request IDs.
The landing event lets lineage update the root tally and project memory.
The world settings file can contain this configuration:
```json
{
"lander": {
"checks": ["sh -c 'test -f feature.txt'", {"command": "sh -c 'true'", "timeout": 30}],
"push": "origin"
}
}
```
Checks run sequentially. The default timeout is 600 seconds.
Omit `push` to land locally.
Fetch, checks, and push use the submitter's conversation chain, not the lander conversation.
A push failure does not erase a local landing.
The queue records the checked candidate before the fast-forward.
It records `landed.sha` before push and report.
After a restart, the lander pushes and reports that exact SHA without another merge.
A later source commit does not change the SHA that the lander pushes.
The report uses a submission-scoped request ID to prevent duplicate delivery.
+12
View File
@@ -0,0 +1,12 @@
/** Durable workspace records and the landing event consumed by coordination extensions. */
import { defineDoc } from '@earendil-works/pi-durable'
import { defineEvent } from '@earendil-works/sausage-events'
export type Workspace = {source:string;target:string;path:string;branch:string;base?:string;state:'preparing'|'ready'|'failed'|'removed';preparation?:number;error?:string;landed?:string}
export type Submission = {id:string;conversationId:number;source:string;target:string;tip:string;at:number;divergence:string;title:string;conversations:string[];prepared?:{base:string;sha:string};landed?:{base:string;sha:string;commits:{sha:string;subject:string}[];paths:string[]};outcome?:any}
export const Workspaces = defineDoc<{items:Record<string,Workspace>;queue:Submission[];landers:Record<string,number>;landerConversations:Record<string,number>;results:Record<string,any>}>({
kind:'ultimate.workspaces',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,
initial:()=>({items:{},queue:[],landers:{},landerConversations:{},results:{}}),
})
export const WorkspacesApi = {name:'workspaces'}
export const SubmissionLanded = defineEvent<any>('workspaces.landed')
export const CheckRan = defineEvent<any>('workspaces.check')
+145
View File
@@ -0,0 +1,145 @@
/** A durable FIFO per source. A recorded landing always resumes at push/report, never at merge. */
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
import { configure, defineTask, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { CheckRan, SubmissionLanded, Workspaces } from './api.ts'
import { MessagesApi } from '../messages/api.ts'
import { starterOf } from '../messages/addresses.ts'
import { chain, git, hash, mkdir, registered } from './tree.ts'
export function lander(host:any,workspaces:any) {
const task=defineTask<any,any,null>({name:'workspaces.lander',version:1,initial:()=>({phase:'next'}),phases:{
next:(_task:any,runtime:any,ctx:any)=>runtime.commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
const entry=index.queue.find((entry:any)=>entry.source===_task.input.source)
if(!entry){delete index.landers[_task.input.source];return {status:'terminal',outcome:{status:'completed',result:null}}}
return {status:'running',checkpoint:{phase:'land',id:entry.id}}
},ctx),
land:async(task:any,runtime:any,ctx:any)=>{
const id=task.state.checkpoint.id
const entry={...(await runtime.snapshot(Workspaces,ROOT,ctx)).queue.find((entry:any)=>entry.id===id)}
const identity={task:task.id,conversations:entry.conversations,signal:runtime.signal}
const path=`/worktrees/lander-${hash(entry.source)}`
const run=(cwd:string,...args:string[])=>git(host,cwd,args,{...identity,timeout:['fetch','push'].includes(args[0])?300:30})
const save=async(update:any)=>runtime.commit(async(tx:any)=>{Object.assign((await tx.doc(Workspaces,ROOT)).queue.find((e:any)=>e.id===id),update);return {status:'running',checkpoint:{phase:'land',id}}},ctx)
let failure:string|undefined
let pushed:string|undefined
try {
if(!entry.landed)await workspaces.serial(entry.source,async()=>{
if(await run(entry.source,'symbolic-ref','--short','HEAD')!==entry.target)throw new Error('Target branch changed')
if(await run(entry.source,'status','--porcelain'))throw new Error('Target worktree is dirty')
const base=await run(entry.source,'rev-parse','HEAD')
if(!entry.prepared) {
await mkdir(host,'/worktrees')
if(!await registered(host,{source:entry.source,path}))await run(entry.source,'worktree','add','--detach',path,base)
await run(path,'reset','--hard',base)
const remote=host.settings().lander?.push
if(remote)await run(path,'fetch',remote,entry.target)
if(remote)await run(path,'merge','--no-edit','FETCH_HEAD')
await run(path,'merge','--no-ff','--no-edit','-m',`Merge from #${entry.conversationId} into ${entry.target}`,entry.tip)
const sha=await run(path,'rev-parse','HEAD')
for(const configured of host.settings().lander?.checks??[]) {
const check=typeof configured==='string'?{command:configured}:configured
const start=Date.now()
let result:any
let error:any
try {result=await host.exec(check.command,{cwd:path,timeout:check.timeout??600,...identity})}catch(cause){error=cause}
const passed=!error&&result.code===0
await runtime.commit(async(tx:any)=>{
await host.emit(tx,CheckRan,{submission:id,command:check.command,result:passed?'passed':error?'timeout':'failed',ms:Date.now()-start})
return {status:'running',checkpoint:{phase:'land',id}}
},ctx)
if(!passed)throw new Error(`Check ${check.command} failed: ${String(error??(result.stderr||result.stdout)).slice(-4000)}`)
}
if(await run(path,'status','--porcelain')||await run(path,'rev-parse','HEAD')!==sha)throw new Error('Check changed the lander checkout')
entry.prepared={base,sha}
await save({prepared:entry.prepared})
}
const {sha,base:previous}=entry.prepared
// Recover a fast-forward before its document commit, even when another commit followed it.
const current=await run(entry.source,'rev-parse','HEAD')
if(current===previous)await run(entry.source,'merge','--ff-only',sha)
else if(current!==sha)await run(entry.source,'merge-base','--is-ancestor',sha,'HEAD')
const commits=(await run(path,'log','--format=%H %s',`${previous}..${sha}`)).split('\n').filter(Boolean).map(line=>({sha:line.slice(0,40),subject:line.slice(41)}))
const paths=(await run(path,'diff','--name-only',previous,sha)).split('\n').filter(Boolean)
entry.landed={base:previous,sha,commits,paths}
await save({landed:entry.landed})
})
const remote=host.settings().lander?.push
if(remote) {
// World git accepts HEAD, not raw object IDs. Pin the detached lander checkout, never the moving source branch.
await run(path,'checkout','--detach',entry.landed.sha)
await run(path,'push',remote,`HEAD:refs/heads/${entry.target}`)
pushed=`${remote}/${entry.target}`
}
}catch(error){if(runtime.signal.aborted)throw error;failure=String(error)}
const outcome=entry.landed?{outcome:'landed',...entry.landed,...(pushed?{pushed}:{}),...(failure?{reason:`Push failed: ${failure}`}:{})}:{outcome:'failed',reason:failure}
const text=entry.landed?`[lander] Submission ${id} landed as ${entry.landed.sha} on ${entry.target}. ${pushed?`Pushed to ${pushed}.`:failure?`Push failed: ${failure}`:'Not pushed.'}`:`[lander] Submission ${id} was not landed: ${failure}`
await runtime.commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
const recorded=index.results[id]
index.results[id]=outcome
if(entry.landed){
index.items[entry.conversationId].landed=entry.landed.sha
// Lineage records the landing in the root tally and distills its memory.
if(!recorded)await host.emit(tx,SubmissionLanded,{submission:id,conversationId:entry.conversationId,at:entry.at,sha:entry.landed.sha,base:entry.landed.base,target:entry.target,lander:runtime.conversationId,divergence:entry.divergence,title:entry.title,commits:entry.landed.commits.slice(0,10),commitCount:entry.landed.commits.length,paths:entry.landed.paths.slice(0,8),...(pushed?{pushed}:{})})
}
return {status:'running',checkpoint:{phase:'report',id,conversationId:entry.conversationId,text,outcome}}
},ctx)
},
report:async(task:any,runtime:any,ctx:any)=>{
const {id,conversationId,text,outcome}=task.state.checkpoint
const conversation=await runtime.conversation(conversationId,ctx)
const entry={kind:'workspaces.lander-report',data:{submission:id,...outcome}}
if(outcome.outcome==='failed')
await conversation.submit({type:'input',content:text,entry,whenBusy:'followUp',requestId:`lander:${id}`},ctx)
else {
await conversation.submit({type:'write',entry:{...entry,model:[{role:'user',content:text,timestamp:Date.now()}]},requestId:`lander:${id}`},ctx)
const starter=await starterOf(host,conversationId,ctx)
if(starter!==undefined&&starter!==conversationId)
await host.need(MessagesApi).send(conversationId,{to:`#${starter}`,text,reply:false},`lander:${id}`,ctx)
}
await runtime.commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
index.queue=index.queue.filter((entry:any)=>entry.id!==id)
return {status:'running',checkpoint:{phase:'next'}}
},ctx)
},
},abort:(_task:any,runtime:any,ctx:any)=>runtime.commit(async(tx:any)=>{delete (await tx.doc(Workspaces,ROOT)).landers[_task.input.source];return {status:'terminal',outcome:{status:'aborted'}}},ctx)})
const ensure=async(tx:any,index:any,source:string,known?:any)=>{
const existing=known===undefined?(index.landers[source]&&await tx.task(index.landers[source])):known
if(existing&&existing.state.status!=='terminal')return
if(!index.landerConversations[source]){
const conversation=await tx.createConversation({ownership:{kind:'ownerless'}})
index.landerConversations[source]=conversation.id
await configure(tx,conversation.id,{cwd:`/worktrees/lander-${hash(source)}`})
Object.assign(await tx.doc(host.metadata,conversation.id),{title:'@lander',label:'lander'})
}
index.landers[source]=await tx.createTask(task,{source},{conversationId:index.landerConversations[source],ownership:{kind:'conversation'},background:true})
}
host.onOpen(()=>host.harness().commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
const sources=[...new Set<string>(index.queue.map((entry:any)=>entry.source))]
const tasks=await Promise.all(sources.map(source=>index.landers[source]?tx.task(index.landers[source]):null))
for(let i=0;i<sources.length;i++)await ensure(tx,index,sources[i],tasks[i]??null)
},BACKGROUND_CONTEXT))
return {task,async submit(id:number,divergence:string,ctx:any,requestId?:string){
const unavailable=await workspaces.unavailable(id,ctx)
if(unavailable)throw new Error(unavailable)
const workspace=await workspaces.get(id,ctx)
if(!workspace)throw new Error('This conversation has no workspace')
const conversations=await chain(host,id,ctx)
const identity={task:`submit-${id}`,conversations}
if(await git(host,workspace.path,['status','--porcelain'],identity))throw new Error('Commit work before submitting')
const tip=await git(host,workspace.path,['rev-parse','HEAD'],identity)
return host.harness().commit(async(tx:any)=>{
const index=await tx.doc(Workspaces,ROOT)
if(requestId&&index.results[requestId])return {id:requestId,position:0}
let entry=index.queue.find((entry:any)=>entry.conversationId===id)
if(!entry){
entry={id:requestId??globalThis.crypto.randomUUID(),conversationId:id,source:workspace.source,target:workspace.target,tip,conversations,divergence,title:(await tx.doc(host.metadata,id)).title??'',at:await tx.latestEntryId(id)??0}
index.queue.push(entry)
}
await ensure(tx,index,workspace.source)
return {id:entry.id,position:index.queue.filter((item:any)=>item.source===workspace.source).findIndex((item:any)=>item.id===entry.id)+1}
},ctx)
}}
}
+31
View File
@@ -0,0 +1,31 @@
/** World-loaded worktree tools and the VFS-only gate before every guest tool. */
import { defineExtension, defineTool, hook, ToolTask, GenerationTask, ROOT_CONVERSATION_ID, section } from '@earendil-works/pi-durable'
import { obj, oneOf, opt, str } from '@earendil-works/pi-schema'
import { Workspaces, WorkspacesApi } from './api.ts'
import { workspaces } from './workspaces.ts'
import { lander } from './lander.ts'
export const order=-200
export const docs=[Workspaces]
export default function(host:any) {
const service=workspaces(host), landing=lander(host,service)
host.implement(WorkspacesApi,{...service,submit:landing.submit,doc:Workspaces})
return defineExtension({name:'workspaces',tasks:[service.Preparation,landing.task],
hooks:[hook(ToolTask,{beforeTool:async(_call:any,api:any,ctx:any)=>{
const reason=await service.unavailable(api.conversationId,ctx)
return reason?{block:reason}:undefined
}}),hook(GenerationTask,{beforeInput:async(_input:any,api:any)=>{
const index=await api.tx.doc(Workspaces,ROOT_CONVERSATION_ID)
if(Object.values(index.landerConversations).includes(api.conversationId))throw new Error('The lander runs no model')
}})],
sections:[section('workspace',async(input:any,ctx:any)=>{
await service.ready(input.conversationId,ctx)
const workspace=await service.get(input.conversationId,ctx)
return workspace?`Workspace: ${workspace.path} (${workspace.state}), branch ${workspace.branch}, target ${workspace.target}.`:''
})],
tools:[defineTool({name:'workspace',description:'Inspect your workspace or submit its clean tip to the lander. State any divergence from the request.',parameters:obj({action:oneOf(['status','submit']),divergence:opt(str())}),replay:'safe',execute:async(args:any,api:any,ctx:any)=>{
if(args.action==='submit'&&!args.divergence?.trim())throw new Error('State divergence, or None')
const result=args.action==='submit'?await landing.submit(api.conversationId,args.divergence,ctx,`workspace:${api.conversationId}:${api.callId}`):await service.get(api.conversationId,ctx)
return {output:[{type:'text',text:JSON.stringify(result??{})}]}
}})],
})
}
+50
View File
@@ -0,0 +1,50 @@
/** World Git execution, inherited caller identity, and VFS-only checkout registration checks. */
import { Started } from '../messages/addresses.ts'
export const resolve = (base:string,path:string) => {
const parts:string[]=[]
for(const part of (path.startsWith('/')?path:`${base}/${path}`).split('/')) {
if(part==='..')parts.pop();else if(part&&part!=='.')parts.push(part)
}
return '/'+parts.join('/')
}
export async function chain(host:any,id:number,context:any):Promise<string[]> {
const ids=new Set<number>()
const visit=async(id:number)=>{
if(ids.has(id))return
ids.add(id)
const row=await host.harness().commit((tx:any)=>tx.conversation(id),context)
const up=row?.owner?.conversationId??row?.parent?.conversationId
if(up!==undefined)await visit(up)
const starter=(await host.harness().snapshot(Started,id,context))?.by
if(starter!==undefined)await visit(starter)
}
await visit(id)
return [...ids].map(String)
}
export const git = async(host:any,cwd:string,args:string[],identity:any={})=>{
const result=await host.exec(['git',...args],{cwd,timeout:30,...identity})
if(result.code)throw new Error(`${cwd}: git ${args.join(' ')}: ${result.stderr||result.stdout||`exited ${result.code}`}`)
return result.stdout.trim()
}
export async function mkdir(host:any,path:string) {
let current=''
for(const part of path.split('/').filter(Boolean)) {
current+='/'+part
try {await host.fs.mkdir(current)} catch(error) {if((error as any).code!=='already_exists')throw error}
}
}
export async function gitdir(host:any,source:string) {
const path=`${source}/.git`
return (await host.fs.stat(path)).kind==='directory'?path:resolve(source,(await host.fs.read(path)).trim().replace(/^gitdir: /,''))
}
export async function registered(host:any,workspace:{source:string;path:string}) {
try {
const admin=await gitdir(host,workspace.path)
const source=await gitdir(host,workspace.source)
const common=await host.fs.read(`${source}/commondir`).then((p:string)=>resolve(source,p.trim()),(e:any)=>{if(e.code==='not_found')return source;throw e})
return admin.startsWith(`${common}/worktrees/`) &&
resolve(admin,(await host.fs.read(`${admin}/gitdir`)).trim())===`${workspace.path}/.git` &&
(await host.fs.stat(`${admin}/HEAD`)).kind==='file'
} catch(error) {if((error as any).code==='not_found')return false;throw error}
}
export const hash=(text:string)=>{let hash=2166136261;for(const c of text)hash=Math.imul(hash^c.charCodeAt(0),16777619);return (hash>>>0).toString(16)}
+137
View File
@@ -0,0 +1,137 @@
/** Workspace preparation, safe release, and synchronous child-to-parent fast-forwards. */
import { configure, defineTask, LiveDoc, InboxDoc, ROOT_CONVERSATION_ID as ROOT } from '@earendil-works/pi-durable'
import { Workspaces } from './api.ts'
import { chain, git, mkdir, registered, resolve } from './tree.ts'
export function workspaces(host:any) {
const queues=host.resource('source-queues',()=>new Map<string,Promise<any>>())
const serial=async(source:string,operation:()=>Promise<any>)=>{
const pending=(queues.get(source)??Promise.resolve()).catch(()=>{}).then(operation)
queues.set(source,pending)
try{return await pending}finally{if(queues.get(source)===pending)queues.delete(source)}
}
const get=async(id:number,ctx:any)=>(await host.harness().snapshot(Workspaces,ROOT,ctx))?.items[id]
const Preparation=defineTask<any,any,null>({name:'workspaces.prepare',version:1,initial:()=>({phase:'prepare'}),phases:{
prepare:async(task:any,runtime:any,ctx:any)=>{
const workspace=await get(task.input.id,ctx)
const identity={task:task.id,conversations:await chain(host,task.input.id,ctx),signal:runtime.signal}
try {
await serial(workspace.source,async()=>{
await mkdir(host,'/worktrees')
if(!await registered(host,workspace)) {
// The preparation owns the branch name, but never adopts an unrelated directory.
const exists=await host.fs.stat(workspace.path).then(()=>true,(e:any)=>{if(e.code==='not_found')return false;throw e})
if(exists)throw new Error(`Workspace path already exists: ${workspace.path}`)
const existing=await git(host,workspace.source,['branch','--list',workspace.branch],identity)
if(existing)await git(host,workspace.source,['worktree','add',workspace.path,workspace.branch],identity)
else await git(host,workspace.source,['worktree','add','-b',workspace.branch,workspace.path,workspace.base],identity)
}
})
for(const command of host.settings().isolates?.worktree?.setup??[]) {
const result=await host.exec(command,{cwd:workspace.path,timeout:300,env:[['SAUSAGE_SOURCE',workspace.source]],...identity})
if(result.code)throw new Error(result.stderr||result.stdout||'Workspace setup failed')
}
await runtime.commit(async(tx:any)=>{
const saved=(await tx.doc(Workspaces,ROOT)).items[task.input.id]
saved.state='ready';delete saved.error
return {status:'terminal',outcome:{status:'completed',result:null}}
},ctx)
}catch(error){
if(runtime.signal.aborted)throw error
await runtime.commit(async(tx:any)=>{
const saved=(await tx.doc(Workspaces,ROOT)).items[task.input.id]
saved.state='failed';saved.error=String(error)
return {status:'terminal',outcome:{status:'completed',result:null}}
},ctx)
}
},
},abort:(_task:any,runtime:any,ctx:any)=>runtime.commit(()=>({status:'terminal',outcome:{status:'aborted'}}),ctx)})
const ready=async(id:number,ctx:any)=>{
const workspace=await get(id,ctx)
if(!workspace)return
if(workspace.state==='removed') {
await host.harness().commit(async(tx:any)=>{
const saved=(await tx.doc(Workspaces,ROOT)).items[id]
if(saved.state==='removed'){
saved.state='preparing'
saved.preparation=await tx.createTask(Preparation,{id},{conversationId:id,ownership:{kind:'conversation'},background:true})
}
},ctx)
}
const saved=await get(id,ctx)
if(saved.state==='preparing') {
host.harness().resume()
await host.harness().waitForTask(saved.preparation,ctx)
}
}
const unavailable=async(id:number,ctx:any)=>{
await ready(id,ctx)
const workspace=await get(id,ctx)
if(!workspace)return
if(workspace.state!=='ready')return `Workspace is ${workspace.state}: ${workspace.error??''}`
if(!await registered(host,workspace))return `Workspace checkout is missing or unregistered: ${workspace.path}`
}
const idle=async(id:number,ctx:any)=>{
if((await host.harness().snapshot(LiveDoc,id,ctx))?.run||(await host.harness().snapshot(InboxDoc,id,ctx))?.items.length)
throw new Error('Conversation is running or has queued input; wait or stop it first')
}
const discard=async(id:number,ctx:any)=>{
await idle(id,ctx)
const workspace=await get(id,ctx)
if(!workspace)throw new Error('Conversation has no workspace')
const identity={task:`discard-${id}`,conversations:await chain(host,id,ctx)}
return serial(workspace.source,async()=>{
if(workspace.state==='preparing')throw new Error('Workspace is preparing')
if(workspace.state!=='removed') {
await git(host,workspace.source,['worktree','remove',workspace.path],identity)
await host.harness().commit(async(tx:any)=>{(await tx.doc(Workspaces,ROOT)).items[id].state='removed'},ctx)
}
return {text:`Discarded isolate; kept branch ${workspace.branch}.`}
})
}
const release=async(ids:number[],ctx:any)=>{
const kept:any[]=[]
for(const id of ids) {
const workspace=await get(id,ctx)
if(!workspace||workspace.state==='removed')continue
try {
// Refuse detached work that its named branch would not retain.
const identity={task:`release-${id}`,conversations:await chain(host,id,ctx)}
await git(host,workspace.path,['merge-base','--is-ancestor','HEAD',workspace.branch],identity)
await git(host,workspace.source,['merge-base','--is-ancestor',workspace.branch,workspace.target],identity)
await discard(id,ctx)
}catch(error){kept.push({id,reason:String(error)})}
}
return kept
}
const land=async(id:number,ctx:any)=>{
await idle(id,ctx)
const workspace=await get(id,ctx)
if(workspace?.state==='removed'&&workspace.landed)return {text:`Landed commits from #${id} as ${workspace.landed}.`,landed:workspace.landed}
if(!workspace||workspace.state!=='ready')throw new Error('Conversation has no ready workspace')
const identity={task:`land-${id}`,conversations:await chain(host,id,ctx)}
return serial(workspace.source,async()=>{
if(await git(host,workspace.path,['status','--porcelain'],identity))throw new Error('Worktree is dirty')
if(await git(host,workspace.source,['symbolic-ref','--short','HEAD'],identity)!==workspace.target)throw new Error('Target branch changed')
if(await git(host,workspace.path,['symbolic-ref','--short','HEAD'],identity)!==workspace.branch)throw new Error('Workspace branch changed')
const tip=await git(host,workspace.path,['rev-parse','HEAD'],identity)
await git(host,workspace.source,['merge','--ff-only',tip],identity)
await host.harness().commit(async(tx:any)=>{(await tx.doc(Workspaces,ROOT)).items[id].landed=tip},ctx)
return {text:`Landed commits from #${id} as ${tip}.`,landed:tip}
})
}
return {Preparation,serial,get,ready,unavailable,land,discard,release,chain:(id:number,ctx:any)=>chain(host,id,ctx),
async create(tx:any,id:number,cwd:string,ancestors:string[]=[]) {
const identity={task:`workspace-${id}`,conversations:[String(id),...ancestors]}
const source=resolve(cwd,await git(host,cwd,['rev-parse','--show-toplevel'],identity))
const target=await git(host,cwd,['symbolic-ref','--quiet','--short','HEAD'],identity)
const base=await git(host,source,['rev-parse','HEAD'],identity)
const index=await tx.doc(Workspaces,ROOT)
if(index.items[id])throw new Error('Conversation already has a workspace')
const preparation=await tx.createTask(Preparation,{id},{conversationId:id,ownership:{kind:'conversation'},background:true})
const workspace={source,target,base,path:`/worktrees/${id}`,branch:`sausage/${id}`,state:'preparing',preparation}
index.items[id]=workspace
await configure(tx,id,{cwd:resolve(workspace.path,cwd.slice(source.length).replace(/^\//,''))})
return workspace
},
}
}
+7 -1
View File
@@ -32828,6 +32828,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next2.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -32893,7 +32894,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+252 -72
View File
@@ -306,12 +306,12 @@ function lazyStream(model3, setup, observer) {
return outer;
}
function lazyApi(load, capabilities) {
const api = {
const api2 = {
stream: (model3, context, options) => lazyStream(model3, async () => (await load()).stream(model3, context, options)),
streamSimple: (model3, context, options) => lazyStream(model3, async () => (await load()).streamSimple(model3, context, options))
};
if (capabilities?.fetchDeferred) {
api.fetchDeferred = (model3, handle, options) => lazyStream(model3, async () => {
api2.fetchDeferred = (model3, handle, options) => lazyStream(model3, async () => {
const implementation = await load();
if (!implementation.fetchDeferred)
throw new Error("API does not support deferred responses");
@@ -319,14 +319,14 @@ function lazyApi(load, capabilities) {
});
}
if (capabilities?.cancelDeferred) {
api.cancelDeferred = async (model3, handle, options) => {
api2.cancelDeferred = async (model3, handle, options) => {
const implementation = await load();
if (!implementation.cancelDeferred)
throw new Error("API cannot cancel deferred responses");
await implementation.cancelDeferred(model3, handle, options);
};
}
return api;
return api2;
}
var init_lazy = __esm({
"node_modules/@earendil-works/pi-ai/dist/api/lazy.js"() {
@@ -1400,8 +1400,8 @@ function createProvider(input) {
}
return provider3;
}
function hasApi(model3, api) {
return isModelType(model3, "chat") && model3.api === api;
function hasApi(model3, api2) {
return isModelType(model3, "chat") && model3.api === api2;
}
function calculateCost(model3, usage) {
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
@@ -33243,32 +33243,32 @@ function memberSchemas(service, implementation) {
}
function validateImplementation(service, implementation) {
const contract = service.contract;
const fail = (message) => {
const fail2 = (message) => {
throw invalid("implementation", `${contract.name}: ${message}`);
};
if (implementation === null || typeof implementation !== "object")
fail("factory must return an object");
fail2("factory must return an object");
let members;
if (contract.scope === "remote") {
const variant = implementation.variant;
if (typeof variant !== "string" || contract.description.variants[variant] === void 0)
fail(`unknown variant ${String(variant)}`);
fail2(`unknown variant ${String(variant)}`);
members = contract.description.variants[variant];
for (const key of Object.keys(implementation))
if (key !== "variant" && members[key] === void 0)
fail(`${key} is not in variant ${String(variant)}`);
fail2(`${key} is not in variant ${String(variant)}`);
} else {
members = contract.description.variants[LOCAL_VARIANT] ?? {};
}
for (const [name, description] of Object.entries(members)) {
const value2 = implementation[name];
if (description.kind === "method" && typeof value2 !== "function")
fail(`${name} must be a method`);
fail2(`${name} must be a method`);
if (description.kind === "state" && !isSource(value2) && !isReadableLike(value2)) {
fail(`${name} must be a cell, a derivation or a held object's state member`);
fail2(`${name} must be a cell, a derivation or a held object's state member`);
}
if (description.kind === "opaque" && value2 === void 0)
fail(`${name} is missing`);
fail2(`${name} is missing`);
}
}
var SelfImpl = class {
@@ -35787,9 +35787,9 @@ var noop3 = () => {
function deferred() {
let resolve2 = noop3;
let reject2 = noop3;
const promise = new Promise((done, fail) => {
const promise = new Promise((done, fail2) => {
resolve2 = done;
reject2 = fail;
reject2 = fail2;
});
promise.catch(noop3);
return { promise, resolve: resolve2, reject: reject2 };
@@ -46642,7 +46642,7 @@ async function run(runtime, input, call, tool, args, context) {
throw new Error(`Tool call ${call.id} has settled`);
};
let env;
const api = {
const api2 = {
taskId: runtime.taskId,
conversationId: runtime.conversationId,
callId: call.id,
@@ -46733,7 +46733,7 @@ async function run(runtime, input, call, tool, args, context) {
env = await runtime.env(context);
const startedAt = performance.now();
try {
result = await tool.execute(args, api, context);
result = await tool.execute(args, api2, context);
} finally {
durationMs = Math.round(performance.now() - startedAt);
}
@@ -50743,10 +50743,10 @@ __export(tools_exports, {
});
// node_modules/@earendil-works/pi-durable/dist/tools/env.js
function requireEnv(api) {
if (api.env === void 0)
function requireEnv(api2) {
if (api2.env === void 0)
throw new Error("No execution environment is configured");
return api.env;
return api2.env;
}
// node_modules/@earendil-works/pi-durable/dist/tools/bash.js
@@ -50775,19 +50775,19 @@ function validateTimeout(timeout) {
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
}
}
async function prepareExecution(command, options, api, context) {
async function prepareExecution(command, options, api2, context) {
const execution = {
command: options?.commandPrefix ? `${options.commandPrefix}
${command}` : command,
cwd: requireEnv(api).cwd,
cwd: requireEnv(api2).cwd,
env: {},
inheritEnv: true
};
await options?.prepare?.(execution, api, context);
await options?.prepare?.(execution, api2, context);
return execution;
}
async function runCommand(commands, execution, timeout, api, context) {
const env = requireEnv(api);
async function runCommand(commands, execution, timeout, api2, context) {
const env = requireEnv(api2);
let result;
for (const command of commands) {
result = await env.exec(command, {
@@ -50795,10 +50795,10 @@ async function runCommand(commands, execution, timeout, api, context) {
env: execution.env,
inheritEnv: execution.inheritEnv,
...timeout === void 0 ? {} : { timeout },
onOutput: (text2, _context, info) => api.output(text2, info.skipped),
onOutput: (text2, _context, info) => api2.output(text2, info.skipped),
spill: { afterBytes: DEFAULT_MAX_BYTES, afterLines: DEFAULT_MAX_LINES },
// An environment may then omit output outside the retained tail and report the omission.
...api.outputWindow === void 0 ? {} : { window: api.outputWindow }
...api2.outputWindow === void 0 ? {} : { window: api2.outputWindow }
}, context);
if (result.ok || result.error.code !== "spawn_error")
break;
@@ -50807,7 +50807,7 @@ async function runCommand(commands, execution, timeout, api, context) {
throw new Error("No command to run");
const spillPath = result.ok ? result.value.spillPath : result.error.spillPath;
if (spillPath !== void 0) {
api.diagnostic({ severity: "info", code: "full_output", message: `Full output: ${spillPath}` });
api2.diagnostic({ severity: "info", code: "full_output", message: `Full output: ${spillPath}` });
}
if (!result.ok) {
if (result.error.code === "aborted" && context.abortSignal?.aborted)
@@ -50819,7 +50819,7 @@ async function runCommand(commands, execution, timeout, api, context) {
throw result.error;
}
const exitCode = result.value.exitCode;
const retained = api.retainedOutput();
const retained = api2.retainedOutput();
const structuredOutput = {
output: retained.text,
truncated: retained.truncated,
@@ -50838,10 +50838,10 @@ function createBashTool(options) {
parameters: bashSchema,
structuredOutputSchema: shellStructuredOutputSchema,
outputLimits: { retain: "tail" },
async execute(args, api, context) {
async execute(args, api2, context) {
validateTimeout(args.timeout);
const execution = await prepareExecution(args.command, options, api, context);
return runCommand([execution.command], execution, args.timeout, api, context);
const execution = await prepareExecution(args.command, options, api2, context);
return runCommand([execution.command], execution, args.timeout, api2, context);
}
});
}
@@ -50855,13 +50855,13 @@ function createPowerShellTool(options) {
parameters: powershellSchema,
structuredOutputSchema: shellStructuredOutputSchema,
outputLimits: { retain: "tail" },
async execute(args, api, context) {
async execute(args, api2, context) {
validateTimeout(args.timeout);
const execution = await prepareExecution(args.command, options, api, context);
const execution = await prepareExecution(args.command, options, api2, context);
const script = `${UTF8_OUTPUT}
${execution.command}`;
const commands = programs.map((program) => [program, ...POWERSHELL_ARGS, script]);
return runCommand(commands, execution, args.timeout, api, context);
return runCommand(commands, execution, args.timeout, api2, context);
}
});
}
@@ -51735,9 +51735,9 @@ function createEditTool() {
description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.",
parameters: editSchema,
prepareArguments: prepareEditArguments,
async execute(args, api, context) {
async execute(args, api2, context) {
const { path: path3, edits } = validateEditInput(args);
const env = requireEnv(api);
const env = requireEnv(api2);
const absolutePath = await resolveToolPath(env, path3, context);
return withFileMutationQueue(env, absolutePath, async () => {
if (context.abortSignal?.aborted)
@@ -51993,9 +51993,9 @@ function createReadTool(options = {}) {
description: `Read the contents of a file. Supports text files and images (${formats2}). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`,
parameters: readSchema,
structuredOutputSchema: readStructuredOutputSchema,
async execute(args, api, context) {
async execute(args, api2, context) {
const { path: path3, offset, limit: limit2 } = args;
const env = requireEnv(api);
const env = requireEnv(api2);
const absolutePath = await resolveReadToolPath(env, path3, context);
const reader = getOrThrow(await env.openBinaryReader(absolutePath, void 0, context));
try {
@@ -52005,7 +52005,7 @@ function createReadTool(options = {}) {
size: before.size,
read: async (position, length) => getOrThrow(await reader.read(position, length, context))
});
const result = mimeType === void 0 ? await readText(reader, path3, offset, limit2, context) : await readImage(reader, before, path3, mimeType, options.images, await imageModel(api, context), context);
const result = mimeType === void 0 ? await readText(reader, path3, offset, limit2, context) : await readImage(reader, before, path3, mimeType, options.images, await imageModel(api2, context), context);
const after = getOrThrow(await reader.info(context));
const unchanged = after.size === before.size && after.mtimeMs === before.mtimeMs;
if (unchanged || mimeType === void 0 && after.size > before.size)
@@ -52060,9 +52060,9 @@ async function readImage(reader, info, path3, mimeType, processor, { vision, lim
const block = { type: "image", data: image.data, mimeType: image.mimeType };
return { output: [block], structuredOutput: block, diagnostics };
}
async function imageModel(api, context) {
const ref = (await api.agent(context)).model;
const model3 = ref === void 0 ? void 0 : api.models.getModel(ref.provider, ref.modelId);
async function imageModel(api2, context) {
const ref = (await api2.agent(context)).model;
const model3 = ref === void 0 ? void 0 : api2.models.getModel(ref.provider, ref.modelId);
const resize = model3?.inputLimits?.images?.resize;
return {
vision: model3?.input.includes("image") ?? true,
@@ -52160,9 +52160,9 @@ function createWriteTool() {
name: "write",
description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
parameters: writeSchema,
async execute(args, api, context) {
async execute(args, api2, context) {
const { path: path3, content } = args;
const env = requireEnv(api);
const env = requireEnv(api2);
const absolutePath = await resolveToolPath(env, path3, context);
return withFileMutationQueue(env, absolutePath, async () => {
if (context.abortSignal?.aborted)
@@ -52188,7 +52188,7 @@ var CodingTools = createCodingTools();
// environment.ts
var CodingTools2 = { ...CodingTools, tools: CodingTools.tools.map((tool) => ({
...tool,
execute: (args, api, context) => tool.execute(args, { ...api, env: api.env.forTool(api.callId, tool.name) }, context)
execute: (args, api2, context) => tool.execute(args, { ...api2, env: api2.env.forTool(api2.callId, tool.name) }, context)
})) };
var Started = defineDoc({
kind: "board.started",
@@ -53338,13 +53338,24 @@ async function startRequests(harness2, context) {
}
// input.ts
async function serve(harness2, context, afterSubmit) {
async function serve(harness2, context, afterSubmit, api2) {
const pending = /* @__PURE__ */ new Set();
const track2 = (work) => {
const task = work.catch((error) => Deno.core.ops.op_output(`${String(error)}
`)).finally(() => pending.delete(task));
pending.add(task);
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input === null || input.type === "stop") {
await harness2.waitForIdle(context);
await Promise.all(pending);
return;
}
if (input.type === "api") {
track2(api2.handle(input));
continue;
}
const conversation = await harness2.conversation(input.conversation, context);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
const submission2 = await conversation.submit({
@@ -53353,9 +53364,10 @@ async function serve(harness2, context, afterSubmit) {
requestId: input.requestId
}, context);
if (afterSubmit) {
const result = await submission2.wait(context);
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
track2(submission2.wait(context).then(async (result) => {
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
}));
}
}
}
@@ -53615,11 +53627,11 @@ function splitStringByTokenSize(text2, minTokenSize, maxTokenSize) {
}
return chunks.length > 0 ? chunks : [""];
}
function cloneMessage(message, api, provider3, modelId) {
function cloneMessage(message, api2, provider3, modelId) {
const cloned = structuredClone(message);
return {
...cloned,
api,
api: api2,
provider: provider3,
model: modelId,
timestamp: cloned.timestamp ?? Date.now(),
@@ -53639,11 +53651,11 @@ function createDeferredMessage(model3, handle) {
timestamp: Date.now()
};
}
function createErrorMessage(error, api, provider3, modelId) {
function createErrorMessage(error, api2, provider3, modelId) {
return {
role: "assistant",
content: [],
api,
api: api2,
provider: provider3,
model: modelId,
usage: DEFAULT_USAGE,
@@ -53752,7 +53764,7 @@ async function streamWithDeltas(stream2, message, minTokenSize, maxTokenSize, to
stream2.end(message);
}
function createFauxCore(options) {
const api = options.api ?? randomId(DEFAULT_API);
const api2 = options.api ?? randomId(DEFAULT_API);
const provider3 = options.provider ?? DEFAULT_PROVIDER;
const minTokenSize = Math.max(1, Math.min(options.tokenSize?.min ?? DEFAULT_MIN_TOKEN_SIZE, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE));
const maxTokenSize = Math.max(minTokenSize, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE);
@@ -53775,7 +53787,7 @@ function createFauxCore(options) {
const models2 = modelDefinitions.map((definition) => ({
id: definition.id,
name: definition.name ?? definition.id,
api,
api: api2,
provider: provider3,
baseUrl: DEFAULT_BASE_URL,
reasoning: definition.reasoning ?? false,
@@ -53787,7 +53799,7 @@ function createFauxCore(options) {
}));
const resolveResponse = async (step, context, streamOptions, requestModel) => {
const resolved = typeof step === "function" ? await step(context, streamOptions, state2, requestModel) : step;
return withUsageEstimate(cloneMessage(resolved, api, provider3, requestModel.id), context, streamOptions, promptCache);
return withUsageEstimate(cloneMessage(resolved, api2, provider3, requestModel.id), context, streamOptions, promptCache);
};
const stream2 = (requestModel, context, streamOptions) => {
const outer = createAssistantMessageEventStream();
@@ -53797,7 +53809,7 @@ function createFauxCore(options) {
try {
await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel);
if (!step) {
let message2 = createErrorMessage(new Error("No more faux responses queued"), api, provider3, requestModel.id);
let message2 = createErrorMessage(new Error("No more faux responses queued"), api2, provider3, requestModel.id);
message2 = withUsageEstimate(message2, context, streamOptions, promptCache);
outer.push({ type: "error", reason: "error", error: message2 });
outer.end(message2);
@@ -53826,7 +53838,7 @@ function createFauxCore(options) {
const message = await resolveResponse(step, context, streamOptions, requestModel);
await streamWithDeltas(outer, message, minTokenSize, maxTokenSize, tokensPerSecond, streamOptions?.signal);
} catch (error) {
const message = createErrorMessage(error, api, provider3, requestModel.id);
const message = createErrorMessage(error, api2, provider3, requestModel.id);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
}
@@ -53856,12 +53868,12 @@ function createFauxCore(options) {
try {
entry2.final = await resolveResponse(entry2.step, entry2.context, submissionOptions, entry2.model);
} catch (error) {
entry2.final = createErrorMessage(error, api, provider3, entry2.model.id);
entry2.final = createErrorMessage(error, api2, provider3, entry2.model.id);
}
}
await streamWithDeltas(outer, entry2.final, minTokenSize, maxTokenSize, tokensPerSecond, fetchOptions?.signal);
} catch (error) {
const message = createErrorMessage(error, api, provider3, requestModel.id);
const message = createErrorMessage(error, api2, provider3, requestModel.id);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
}
@@ -53882,7 +53894,7 @@ function createFauxCore(options) {
return models2.find((candidate) => candidate.id === requestedModelId);
}
return {
api,
api: api2,
provider: provider3,
models: models2,
stream: stream2,
@@ -54245,14 +54257,14 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
}], close = [async () => {
opened = false;
}];
const handle = (api) => new Proxy({}, { get: (_, key) => {
const handle = (api2) => new Proxy({}, { get: (_, key) => {
if (typeof key === "symbol" || key === "then") return void 0;
const value2 = providers.get(api.name);
if (!value2) throw new Error(`No provider for ${api.name}`);
const value2 = providers.get(api2.name);
if (!value2) throw new Error(`No provider for ${api2.name}`);
const member = value2[key];
return typeof member === "function" ? (...args) => {
const current = providers.get(api.name);
if (!current) throw new Error(`No provider for ${api.name}`);
const current = providers.get(api2.name);
if (!current) throw new Error(`No provider for ${api2.name}`);
return Reflect.apply(current[key], current, args);
} : member;
} });
@@ -54266,11 +54278,11 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
exec: (command, options) => exec(command, { ...options, extension: name }),
git,
chain: chain2,
implement: (api, value2) => {
staged.set(api.name, value2);
implement: (api2, value2) => {
staged.set(api2.name, value2);
},
need: handle,
want: (api) => providers.has(api.name) ? handle(api) : void 0,
want: (api2) => providers.has(api2.name) ? handle(api2) : void 0,
resource: (key, create) => {
const id = `${name}/${key}`;
if (!resources.has(id)) resources.set(id, create());
@@ -54341,6 +54353,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -54406,7 +54419,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state2 }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state2 }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
@@ -54415,6 +54433,166 @@ Last good: ${fallback}` : failed;
} };
}
// harness-api.ts
var Concluded = defineDoc({ kind: "conversations.concluded", version: 1, scope: "session", checkpointWhen: () => false, initial: () => ({ ids: [] }) });
var Started2 = defineDoc({ kind: "board.started", version: 1, scope: "conversation", history: "latest", fork: "initial", checkpointWhen: () => false, initial: () => ({}) });
var fail = (status, message) => {
throw Object.assign(new Error(message), { status });
};
var integer = (value2, fallback) => {
if (value2 === null && fallback !== void 0) return fallback;
const n = Number(value2);
if (!Number.isSafeInteger(n) || n < 0) fail(400, "Invalid cursor or conversation");
return n;
};
function harnessApi(harness2, models2, extensions2, context) {
const streams = /* @__PURE__ */ new Map();
const log = eventLog(() => harness2).eventLog;
const send = (id, value2) => Deno.core.ops.op_harness_reply(id, value2);
async function entries2(conversation, after) {
const found = [];
let cursor;
do {
const page3 = await conversation.entries({ minEntryId: after + 1, order: "ascending" }, 256, cursor, context);
found.push(...page3.items);
cursor = page3.next;
} while (cursor);
return found;
}
async function follow(input, read2, cursor) {
let active = true, queue2 = Promise.resolve();
const scan2 = () => queue2 = queue2.then(async () => {
if (!active) return;
for (; ; ) {
const page3 = await read2(cursor);
if (!active || !page3.length) return;
for (const entry2 of page3) {
cursor = entry2.id;
send(input.id, entry2);
}
}
});
const observation2 = harness2.observeCommits({ advance: (publication) => {
if (publication.changes.some((change) => change.type === "entry")) void scan2();
}, end: () => {
void stop2();
} });
const stop2 = async () => {
if (!active) return;
active = false;
observation2.detach();
await queue2;
send(input.id, null);
streams.delete(input.id);
};
streams.set(input.id, stop2);
send(input.id, { status: 200, stream: true });
void Deno.core.ops.op_harness_closed(input.id).then(stop2);
await scan2();
}
async function handle(input) {
const url = new URL(input.path, "http://host.sausage");
const path3 = url.pathname.slice("/harness".length);
const data = input.body ?? {};
const reply = (status, body) => {
send(input.id, { status, body });
send(input.id, null);
};
try {
if (input.method === "GET" && path3 === "/conversations") {
const records = await harness2.commit(async (tx) => {
const all = [];
let cursor;
do {
const page3 = await tx.scanConversations({}, 256, cursor);
all.push(...page3.items);
cursor = page3.next;
} while (cursor);
return all;
}, context);
const concluded = new Set((await harness2.snapshot(Concluded, context))?.ids ?? []);
const conversations = await Promise.all(records.map(async (record2) => {
const agent = await harness2.snapshot(AgentDoc, record2.id, context);
const metadata = await harness2.snapshot(Metadata, record2.id, context);
const live = await harness2.snapshot(LiveDoc, record2.id, context);
const started = await harness2.snapshot(Started2, record2.id, context);
return { ...record2, title: metadata?.title ?? "", agent, starter: started?.by ?? null, concluded: concluded.has(record2.id), running: !!live?.run };
}));
reply(200, { conversations });
return;
}
if (input.method === "GET" && path3 === "/events") {
const cursor = integer(url.searchParams.get("after"), 0);
await follow(input, async (after2) => log.recent({ since: after2, order: "ascending", limit: 256 }), cursor);
return;
}
if (input.method === "GET" && path3 === "/extensions") {
reply(200, extensions2.state());
return;
}
if (input.method === "GET" && path3 === "/families") {
let families = {};
try {
families = JSON.parse(await fs.read("/src/sausage/agents/models.json"));
} catch (error) {
if (error.code !== "not_found") throw error;
}
reply(200, { families });
return;
}
if (input.method === "GET" && path3 === "/models") {
reply(200, { models: models2.getAllModels().map(({ id, name, provider: provider3, contextWindow, maxTokens, reasoning, cost }) => ({ id, name, provider: provider3, contextWindow, maxTokens, reasoning, cost })) });
return;
}
const action = /^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path3);
if (input.method === "POST" && action) {
if (data.conversationId !== void 0 && (!Number.isSafeInteger(data.conversationId) || data.conversationId < 1)) fail(400, "Invalid conversation id");
const controller = new AbortController();
void Deno.core.ops.op_harness_closed(input.id).then(() => controller.abort());
reply(200, { result: await extensions2.invoke(decodeURIComponent(action[1]), decodeURIComponent(action[2]), data.args ?? {}, controller.signal, data.conversationId) });
return;
}
const match = /^\/conversations\/(\d+)\/(entries|stream|submit|stop)$/.exec(path3);
if (!match) fail(404, "Unknown harness route");
const conversation = await harness2.conversation(integer(match[1]), context);
if (!conversation) fail(404, "Unknown conversation");
const after = integer(url.searchParams.get("after"), 0);
if (input.method === "GET" && match[2] === "entries") {
reply(200, { entries: await entries2(conversation, after) });
return;
}
if (input.method === "GET" && match[2] === "stream") {
await follow(input, (cursor) => entries2(conversation, cursor), after);
return;
}
if (input.method === "POST" && match[2] === "submit") {
if (typeof data.requestId !== "string" || !data.requestId || typeof data.content !== "string" || !data.content.trim()) fail(400, "Expected content and requestId");
const submission2 = await conversation.submit({ type: "input", content: data.content, requestId: data.requestId }, context);
reply(202, { submissionId: submission2.id });
return;
}
if (input.method === "POST" && match[2] === "stop") {
for (; ; ) {
const own = (await harness2.inspect(context)).tasks.filter(({ record: record2 }) => record2.conversationId === conversation.id && !record2.owner && !record2.background);
if (!own.length) break;
for (const { record: record2 } of own) {
await harness2.abortTask(record2.id, context);
await harness2.waitForTask(record2.id, context);
}
}
reply(200, { ok: true });
return;
}
fail(404, "Unknown harness route");
} catch (error) {
reply(error.status ?? 500, { error: error.message });
}
}
return { handle, async close() {
await Promise.all([...streams.values()].map((stop2) => stop2()));
} };
}
// main.ts
var proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required");
@@ -54490,6 +54668,7 @@ if (globalThis.consoleEnabled) watch.start((event) => {
});
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
var extensions = await loadExtensions(harness, registry, storage, models, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]);
var api = harnessApi(harness, models, extensions, BACKGROUND_CONTEXT);
harness.resume();
await serve(harness, BACKGROUND_CONTEXT, globalThis.consoleEnabled ? async () => {
await harness.waitForIdle(BACKGROUND_CONTEXT);
@@ -54497,7 +54676,8 @@ await serve(harness, BACKGROUND_CONTEXT, globalThis.consoleEnabled ? async () =>
render(final.value);
await final.stop();
Deno.core.ops.op_output("\n");
} : void 0);
} : void 0, api);
await api.close();
await extensions.close();
await stopRequests();
await watch.stop();
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
/** Real dream triggers, message reports, and built-in receipts across a harness reopen. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'
import { Harness, createRegistry, defineExtension, defineTool } from '@earendil-works/pi-durable'
import { obj } from '@earendil-works/pi-schema'
import { loadExtensions } from './extensions'
import { openStorage } from './sqlite'
const assert=(ok:any,message:string)=>{if(!ok) throw new Error(message)}
const until=async(test:()=>any)=>{const deadline=Date.now()+20000;while(!await test()){if(Date.now()>deadline)throw new Error(`Dream deadline: ${JSON.stringify((await entries()).filter((e:any)=>['pi.tool-result','board.reply'].includes(e.kind)))}`);await new Promise(r=>setTimeout(r,0))}}
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider)
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath)
const harness=await Harness.open(storage,{models,registry,settings:{retry:{enabled:false},get extensions(){return registry.snapshot().installed()}}},context)
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'},cwd:'/src/demo'}})
const route=(transcript:any):any=>{
faux.appendResponses([route])
const last=transcript.messages.findLast((m:any)=>m.role!=='system')
const text=typeof last?.content==='string'?last.content:(last?.content??[]).filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('')
if(last?.role==='user'&&text.startsWith('CALL ')){const call=JSON.parse(text.slice(5));return fauxAssistantMessage([fauxToolCall(call.name,call.args)],{stopReason:'toolUse'})}
if(last?.role==='user'&&text.includes('Dream journal pass')) return fauxAssistantMessage([fauxToolCall('subagent',{action:'start',agent:'dream-pass',task:'Review supplied evidence',background:true,isolate:'none'})],{stopReason:'toolUse'})
if(last?.role==='user'&&text==='Review supplied evidence') return fauxAssistantMessage('Evidence #1: keep the review read-only.')
if(last?.role==='user'&&/^(Reply|Message|Note) from/.test(text)) return fauxAssistantMessage('Received')
if(last?.role==='toolResult'||text.includes('Evidence #1')) return fauxAssistantMessage('Dream journal: keep the review read-only. Evidence #1.')
return fauxAssistantMessage('Received')
}
faux.setResponses([route])
// The role requires the normal host read/bash tools. The scripted pass does not invoke them.
const baseTools=defineExtension({name:'dream-test-tools',tools:['read','bash'].map(name=>defineTool({name,description:'Unused fixture tool',parameters:obj({}),execute:async()=>{throw new Error('Unexpected fixture tool invocation')}}))})
const loader=await loadExtensions(harness,registry,storage,models,context,[baseTools])
assert(!loader.state().error,`Extension load: ${loader.state().error}`)
const base=`file:///run/sausage/extensions/${loader.state().good}/extensions`
const {BuiltInDoc,PauseDoc,ensureAutomation,fired,AUTOMATION_NAME,PAUSE_NAME}=await import(`${base}/dream/built-ins.ts`)
const {Next}=await import(`${base}/dream/server.ts`)
const {Automations}=await import(`${base}/automations/server.ts`)
const {ContextUsageDoc}=await import(`${base}/automations/triggers.ts`)
const {Fired}=await import(`${base}/automations/refs.ts`)
const {Messages}=await import(`${base}/messages/api.ts`)
const primary=(await harness.conversation(await globalThis.dreamSubagents.standing('ultimate',context),context))!
const entries=async()=> (await primary.context(context)).entries
const ledger=async()=> (await harness.snapshot(Automations,context))?.automations??{}
const requested=async()=> (await entries()).filter((e:any)=>e.kind==='dream.requested')
const operation=(args:any)=>globalThis.dreamAutomations.operate(args)
harness.resume()
try {
if(globalThis.dreamMode==='reopen') {
await ensureAutomation(globalThis.dreamHost)
const all=Object.values(await ledger()) as any[]
assert(all.length===1&&all[0].name===PAUSE_NAME&&!all[0].enabled,'Reopen must preserve deletion and pause')
assert((await requested()).length===2,'Reopen duplicated dream requests')
assert((await harness.snapshot(Next,root.id,context)).prepared.handoff==='Replacement handoff','Prepared handoff must survive reopen')
assert((await harness.snapshot(Messages,root.id,context)).messages.filter((m:any)=>m.re!==undefined&&m.text.includes('Dream journal')).length===1,'Reopen duplicated the dream report')
} else {
await until(async()=>Object.keys(await ledger()).length===2)
await ensureAutomation(globalThis.dreamHost)
const id=(await harness.snapshot(BuiltInDoc,root.id,context)).id
const pause=(await harness.snapshot(PauseDoc,root.id,context)).id
assert(Object.keys(await ledger()).length===2,'Repeated ensure duplicated built-ins')
assert((await ledger())[id].name===AUTOMATION_NAME&&(await ledger())[id].condition.above===70,'Compaction trigger')
assert((await ledger())[pause].condition.idleFor===8*3600_000&&(await ledger())[pause].condition.above===50,'Pause trigger')
await harness.commit(async(tx:any)=>Object.assign(await tx.doc(ContextUsageDoc,primary.id),{percent:71,tokens:710,window:1000}),context)
await until(async()=> (await requested()).filter((e:any)=>e.data.automation?.id===id).length===1)
await harness.commit((tx:any)=>globalThis.dreamHost.emit(tx,Fired,{id,fire:1}),context)
await harness.commit((tx:any)=>fired(globalThis.dreamHost,tx,id,1),context)
await harness.waitForIdle(context)
const deliveries=await harness.commit((tx:any)=>tx.scanTasks({kind:'dream.deliver'},100),context)
for(const task of deliveries.items) await harness.waitForTask(task.id,context)
assert((await requested()).length===1,'One fire must add exactly one dream.requested entry')
const request=(await storage.scanSubmissions({conversationId:primary.id},100,undefined,context)).items.find((s:any)=>s.requestId===`dream.requested:${id}:1`)
assert(request?.type==='write','The fire adds a passive write')
await globalThis.dreamService.operate({action:'start'})
await until(async()=> (await requested()).length===2)
for(const args of [{learned:['one'],handoff:'x'},{learned:['a','b','c'],handoff:'x'.repeat(12001)}]) {
let failed=false;try{await globalThis.dreamService.operate({action:'prepare',...args})}catch{failed=true}assert(failed,'Invalid preparation must fail')
}
await globalThis.dreamService.operate({action:'prepare',learned:['I learned one.','I keep two.','I propose three.'],handoff:'First handoff'})
await globalThis.dreamService.operate({action:'prepare',learned:['a','b','c'],handoff:'Replacement handoff'})
await until(async()=> (await entries()).filter((e:any)=>e.kind==='dream.prepared').length===2)
await (await primary.submit({type:'input',content:`CALL ${JSON.stringify({name:'branch',args:{repo:'/src/demo',prompt:'Dream journal pass'}})}`},context)).wait(context)
await until(async()=> (await entries()).some((e:any)=>e.kind==='board.reply'&&e.data.text.includes('Dream journal')))
await harness.waitForIdle(context)
const report=(await entries()).find((e:any)=>e.kind==='board.reply'&&e.data.text.includes('Dream journal'))
const branch=(await harness.conversation(report.data.from,context))!
const reports=(await branch.context(context)).entries.filter((e:any)=>e.kind==='subagents.report')
assert(reports.length===1&&reports[0].data.agent==='dream-pass'&&JSON.stringify(reports).includes('Evidence #1'),`The dream-pass must report its evidence: ${JSON.stringify((await branch.context(context)).entries.filter((e:any)=>['pi.tool-result','subagents.report'].includes(e.kind)))}`)
await operation({action:'pause',id:pause})
await harness.commit(async(tx:any)=>{delete (await tx.doc(PauseDoc,root.id)).id},context)
await ensureAutomation(globalThis.dreamHost)
assert((await harness.snapshot(PauseDoc,root.id,context)).id===pause&&!(await ledger())[pause].enabled,'A lost receipt must adopt the paused automation')
await operation({action:'delete',id})
await ensureAutomation(globalThis.dreamHost)
assert((await harness.snapshot(BuiltInDoc,root.id,context)).deleted&&Object.keys(await ledger()).length===1,'Deletion must persist')
}
} finally {await loader.close();await harness.close(context)}
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -146,6 +146,7 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
modules.sort((a,b)=>(a.module.order??0)-(b.module.order??0)||a.name.localeCompare(b.name));
for(const {name,module} of modules) {
const facet = scope(name,staged); next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key:string)=>typeof module.actions?.[key]==='function').map((key:string)=>[key,module.actions[key]]));
facet.extension = typeof module.default==='function'?await module.default(facet.host):module.default;
if(!facet.extension || facet.extension.name!==name)throw new Error(`Extension name must match ${name}`);
}
@@ -179,5 +180,10 @@ export async function loadExtensions(harness:any, registry:any, storage:any, mod
const rid = ops.op_watch([`${repo}/.git/refs/heads/main`,`${repo}/.git/packed-refs`,`${repo}/settings.json`]);
await enqueue();
const pending = (async()=> {while(active){await ops.op_watch_next(rid);if(active)await enqueue();}})();
return {reload:enqueue, state:()=>({...state}), close:async()=>{active=false;Deno.core.close(rid);await pending;await queue;await dispose(facets);}};
return {reload:enqueue, state:()=>({...state}), async invoke(name:string,key:string,args:any,signal:AbortSignal,conversationId?:number) {
const facet = facets.find(f=>f.extension.name===name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error('Unknown user action'),{status:404});
return action(args,{host:facet.host,signal,conversationId,source:'ui'});
}, close:async()=>{active=false;Deno.core.close(rid);await pending;await queue;await dispose(facets);}};
}
+16 -4
View File
@@ -20477,13 +20477,24 @@ async function openStorage(path) {
}
// input.ts
async function serve(harness2, context, afterSubmit) {
async function serve(harness2, context, afterSubmit, api) {
const pending = /* @__PURE__ */ new Set();
const track2 = (work) => {
const task = work.catch((error) => Deno.core.ops.op_output(`${String(error)}
`)).finally(() => pending.delete(task));
pending.add(task);
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input === null || input.type === "stop") {
await harness2.waitForIdle(context);
await Promise.all(pending);
return;
}
if (input.type === "api") {
track2(api.handle(input));
continue;
}
const conversation = await harness2.conversation(input.conversation, context);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
const submission2 = await conversation.submit({
@@ -20492,9 +20503,10 @@ async function serve(harness2, context, afterSubmit) {
requestId: input.requestId
}, context);
if (afterSubmit) {
const result = await submission2.wait(context);
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
track2(submission2.wait(context).then(async (result) => {
await afterSubmit();
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
}));
}
}
}
+322
View File
@@ -0,0 +1,322 @@
/** The seven Ultimate goal cases use overlaid documents and the loaded server modules. */
import type { ConversationId, GenerationHooks, Tx } from '@earendil-works/pi-durable'
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'
import { expect, it } from './goal-questions-test-support'
const { reminderTask } = await import(`${globalThis.goalBase}/goal/reminder.ts`)
const {default:goalExtension,act,actions,end,Goals,openGoal,start,TRIES} = await import(`${globalThis.goalBase}/goal/server.ts`)
import type { Host } from './extensions.ts'
const ULTIMATE = 5 as ConversationId
const screenshot = { type: 'image', data: 'aGk=', mimeType: 'image/png' }
/** Like the harness's document overlay, a proxy at every level: structuredClone refuses it. */
const overlay = <T extends object>(value: T): T =>
new Proxy(value, {
get: (target, key) => {
const found = Reflect.get(target, key) as unknown
return found && typeof found === 'object' ? overlay(found) : found
},
})
/** A transaction over overlaid documents and a few entries of @ultimate's conversation; @ultimate is conversation 5. */
function fakeTx(
newer: { id: number; conversationId: number; kind?: string; model: object[] }[] = [],
) {
const docs = new Map<string, object>()
const entries = [
...newer,
{ id: 30, conversationId: ULTIMATE, model: [{ role: 'user', content: 'no images' }] },
{
id: 20,
conversationId: ULTIMATE,
model: [{ role: 'toolResult', content: [{ type: 'text', text: 'shot' }, screenshot] }],
},
{ id: 10, conversationId: 9, model: [{ role: 'user', content: [screenshot] }] },
]
const tasks: { name: string; input: { kind: string; content: string; to: number } }[] = []
return {
tasks,
createTask: async ({ definition }: { definition: { name: string } }, input: never) => {
tasks.push({ name: definition.name, input })
},
doc: async (
{ definition: def }: { definition: { kind: string; initial: () => object } },
id: number,
) => {
const key = `${def.kind}:${id}`
if (!docs.has(key))
docs.set(
key,
def.kind === 'ultimate.standing-agents' ? { ultimate: ULTIMATE } : def.initial(),
)
return overlay(docs.get(key)!)
},
entry: async (id: number) => entries.find((entry) => entry.id === id),
scanEntries: async ({ conversationId }: { conversationId: number }) => ({
items: entries.filter((entry) => entry.conversationId === conversationId),
}),
} as unknown as Tx & { tasks: typeof tasks }
}
type Reminder = {
id: string
name: string
enabled: boolean
subscribers?: (number | string)[]
key?: string
}
/** A host over `tx` with an automations ledger, and `sync()`, which runs the queued reminder tasks. */
function fakeHost(tx: ReturnType<typeof fakeTx>) {
const ledger = new Map<string, Reminder>()
let next = 1
const service = {
// As the real service: a repeated key returns the automation it made.
create: async (args: Omit<Reminder, 'id' | 'enabled'>) => {
const made = [...ledger.values()].find((item) => args.key && item.key === args.key)
if (made) return made
const id = `A${next++}`
ledger.set(id, { id, ...args, enabled: true })
return ledger.get(id)
},
delete: async (id: string) => void ledger.delete(id),
pause: async (id: string) => void (ledger.get(id)!.enabled = false),
resume: async (id: string) => void (ledger.get(id)!.enabled = true),
operate: async (args: any) => args.action === 'list' ? structuredClone([...ledger.values()]) : (service as any)[args.action](args.action === 'create' ? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'action')) : args.id),
}
const host = {
harness: () => ({ commit: (fn: (tx: Tx) => unknown) => fn(tx) }),
need: () => service,
want: () => service,
doc: () => undefined,
invoke: async () => structuredClone([...ledger.values()]),
} as unknown as Host
const sync = async () => {
const runtime = { commit: (fn: () => unknown) => fn() }
for (const task of tx.tasks.splice(0).filter((item) => item.name === 'goal.reminder'))
await reminderTask(host, Goals).definition.phases.sync(
{ input: task.input } as never,
runtime as never,
BACKGROUND_CONTEXT,
)
}
return { host, ledger, service, sync }
}
it('keeps one goal open at a time and ends it once', async () => {
const tx = fakeTx()
await expect(start(tx, ' ', 0)).rejects.toThrow('nonempty')
expect(await start(tx, ' Ship the sidebar ', 0)).toMatchObject({
id: 1,
text: 'Ship the sidebar',
})
await expect(start(tx, 'Another', 0)).rejects.toThrow('Goal 1 is still open')
expect(await end(tx, { at: 1, by: 'user', outcome: 'ended' })).toMatchObject({
ended: { by: 'user' },
})
await expect(end(tx, { at: 2, by: 'user', outcome: 'ended' })).rejects.toThrow('No goal')
expect((await start(tx, 'Next', 3)).id).toBe(2)
})
it('blocks a criterion after repeated failed tries and finishes with the outcome', async () => {
const tx = fakeTx()
await expect(act(tx, ULTIMATE, { action: 'met', n: 1 }, 0)).rejects.toThrow('No hands-off goal')
await start(tx, 'Goal', 0)
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Tests pass'] }, 0)
await expect(act(tx, ULTIMATE, { action: 'met', n: 3 }, 0)).rejects.toThrow('No criterion 3')
for (let i = 1; i < TRIES; i++) await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
expect((await openGoal(tx))!.criteria[0]).toMatchObject({ status: 'open', tries: TRIES - 1 })
const last = await act(tx, ULTIMATE, { action: 'failed', n: 1 }, 0)
expect(last.answer).toContain('blocked')
expect((await openGoal(tx))!.criteria[0].status).toBe('blocked')
// Rewording keeps what was learned about an unchanged criterion.
await act(tx, ULTIMATE, { action: 'criteria', criteria: ['Loads in 1s', 'Lint passes'] }, 0)
expect((await openGoal(tx))!.criteria.map((item) => item.status)).toEqual(['blocked', 'open'])
await expect(act(tx, ULTIMATE, { action: 'finish', summary: 'x' }, 0)).rejects.toThrow('outcome')
const done = await act(
tx,
ULTIMATE,
{ action: 'finish', outcome: 'blocked', summary: 'Stuck' },
4,
)
expect(done.ended).toMatchObject({
ended: { by: 'ultimate', outcome: 'blocked', summary: 'Stuck' },
})
expect(await openGoal(tx)).toBeUndefined()
})
it('records decisions with the screenshots of its own conversation only', async () => {
const tx = fakeTx()
await start(tx, 'Goal', 0)
await expect(
act(tx, ULTIMATE, { action: 'decide', question: 'Go?', picked: 'Yes' }, 0),
).rejects.toThrow('reason')
await act(
tx,
ULTIMATE,
{
action: 'decide',
question: 'Approve?',
picked: 'Yes',
reason: 'Meets 1',
conversation: 7,
images: 'latest',
},
1,
)
await expect(
act(tx, ULTIMATE, { action: 'decide', question: 'q', picked: 'p', reason: 'r', images: 10 }, 2),
).rejects.toThrow('has no images')
expect((await openGoal(tx))!.decisions).toEqual([
{ at: 1, question: 'Approve?', picked: 'Yes', reason: 'Meets 1', conversation: 7, images: 20 },
])
})
it('never takes an earlier turn\'s screenshot for images: "latest"', async () => {
const tx = fakeTx([{ id: 40, kind: 'pi.user', conversationId: ULTIMATE, model: [] }])
await start(tx, 'Goal', 0)
const decide = { action: 'decide', question: 'q', picked: 'p', reason: 'r' } as const
await expect(act(tx, ULTIMATE, { ...decide, images: 'latest' }, 1)).rejects.toThrow(
'no images in this turn',
)
await act(tx, ULTIMATE, { ...decide, images: 20 }, 2)
expect((await openGoal(tx))!.decisions).toMatchObject([{ images: 20 }])
})
it("notes the open goal on @ultimate's runs only, and lets only @ultimate keep it", async () => {
const tx = fakeTx()
const extension = goalExtension({
doc: () => undefined,
need: () => undefined,
want: () => undefined,
} as unknown as Host)
const hooks = extension.hooks![0].handlers as GenerationHooks
const before = (conversationId: number, kind = 'pi.user') =>
hooks.beforeInput({ kind }, { tx, conversationId } as never, BACKGROUND_CONTEXT)
expect(await before(ULTIMATE)).toBeUndefined()
await start(tx, 'Make it fast', 0)
expect(await before(9)).toBeUndefined()
const note = (await before(ULTIMATE))!.entries![0]
expect(note.kind).toBe('goal.note')
expect(note.model![0].content).toContain(
'Hands-off goal 1, granted by the user, who is away: Make it fast',
)
const tool = extension.tools!.find((candidate) => candidate.name === 'goal')!
const run = (conversationId: number, args: object) =>
tool.execute(
args as never,
{ conversationId, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
undefined as never,
)
await expect(run(9, { action: 'met', n: 1 })).rejects.toThrow('Only @ultimate')
expect(await run(ULTIMATE, { action: 'criteria', criteria: ['Fast'] })).toEqual({
content: [{ type: 'text', text: 'Goal 1 has 1 criteria.' }],
})
expect((await tx.doc(Goals, 1 as ConversationId)).goals[0].criteria).toHaveLength(1)
// Finishing ends the goal and creates the report's delivery in the same commit; the note stops.
await run(ULTIMATE, { action: 'finish', outcome: 'met', summary: 'Done' })
expect(tx.tasks).toEqual([
{ name: 'goal.deliver', input: expect.objectContaining({ to: ULTIMATE, kind: 'goal.report' }) },
{ name: 'goal.reminder', input: { goal: 1 } },
])
expect(await before(ULTIMATE)).toBeUndefined()
})
it('keeps a reminder while the goal is open: paused while waiting on the user, gone once ended', async () => {
const tx = fakeTx()
const { host, ledger, service, sync } = fakeHost(tx)
const extension = goalExtension(host)
const tool = extension.tools!.find((candidate) => candidate.name === 'goal')!
const run = (args: object) =>
tool.execute(
args as never,
{ conversationId: ULTIMATE, commit: (fn: (tx: Tx) => unknown) => fn(tx) } as never,
undefined as never,
)
await actions.start({ text: 'Make it fast' }, { host } as never)
// A crash after creating, before linking, left one; and the goal was paused meanwhile. The task run adopts it by
// its key instead of making a second one, and pauses it.
await service.create({
name: 'Goal reminder · goal 1',
subscribers: ['@ultimate'],
key: 'goal.reminder:1',
})
;(await openGoal(tx))!.waiting = { reason: 'Which account?', at: 0 }
await sync()
expect([...ledger.values()]).toEqual([
{
id: 'A1',
name: 'Goal reminder · goal 1',
subscribers: ['@ultimate'],
key: 'goal.reminder:1',
enabled: false,
},
])
expect((await openGoal(tx))!.reminder).toBe('A1')
await run({ action: 'resume' })
await sync()
expect(ledger.get('A1')!.enabled).toBe(true)
// The user renamed it: it stays linked by its id.
ledger.get('A1')!.name = 'Night check'
// Its fire tells @ultimate what to check; other inputs do not.
const hooks = extension.hooks![0].handlers as GenerationHooks
const note = async (input: object) =>
(await hooks.beforeInput(input as never, { tx, conversationId: ULTIMATE }, BACKGROUND_CONTEXT))!
.entries![0].model![0].content as string
expect(await note({ kind: 'automations.fired', data: { id: 'A1' } })).toContain(
'This is the goal reminder',
)
expect(await note({ kind: 'pi.user' })).not.toContain('This is the goal reminder')
expect(await note({ kind: 'pi.user', model: [{ role: 'user', content: '[automation A1 fired] Night check' }] })).toContain('This is the goal reminder')
expect(await note({ kind: 'pi.user', model: [{ role: 'user', content: '[automation A2 fired] Other check' }] })).not.toContain('This is the goal reminder')
await expect(run({ action: 'pause' })).rejects.toThrow('reason')
await expect(run({ action: 'resume' })).rejects.toThrow('not paused')
await run({ action: 'pause', reason: 'Which account to deploy to?' })
await sync()
expect(ledger.get('A1')!.enabled).toBe(false)
expect((await openGoal(tx))!.waiting).toMatchObject({ reason: 'Which account to deploy to?' })
expect(await note({ kind: 'pi.user' })).toContain('Waiting on the user')
await run({ action: 'resume' })
await sync()
expect(ledger.get('A1')!.enabled).toBe(true)
expect((await openGoal(tx))!.waiting).toBeUndefined()
await run({ action: 'finish', outcome: 'met', summary: 'Done' })
await sync()
expect(ledger.size).toBe(0)
// A start and end whose tasks run only after both committed make nothing.
await actions.start({ text: 'Again' }, { host } as never)
await actions.end(null, { host } as never)
await sync()
expect(ledger.size).toBe(0)
})
it('grants nothing through a start card still queued after the user ended its goal', async () => {
const tx = fakeTx()
const { host } = fakeHost(tx)
const extension = goalExtension(host)
await actions.start({ text: 'Make it fast' }, { host } as never)
await actions.end(null, { host } as never)
const [started, report] = tx.tasks
.filter((task) => task.name === 'goal.deliver')
.map((task) => task.input)
expect(started).toMatchObject({
kind: 'goal.started',
content: 'The user started hands-off goal 1.',
})
expect(report).toMatchObject({ kind: 'goal.report' })
expect(report.content).toContain('ask them as usual')
// The queued start runs now: no goal is open, so no note says to decide for the user.
const hooks = extension.hooks![0].handlers as GenerationHooks
expect(
await hooks.beforeInput(
{ kind: 'goal.started' },
{ tx, conversationId: ULTIMATE },
BACKGROUND_CONTEXT,
),
).toBeUndefined()
})
+37
View File
@@ -0,0 +1,37 @@
/** Real-loader fixture for the question-card cases. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import { createModels } from '@earendil-works/pi-ai/models'
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'
import { Harness, createRegistry } from '@earendil-works/pi-durable'
import { openStorage } from './sqlite'
import { loadExtensions } from './extensions'
import { assert, until } from './goal-questions-test-support'
let next=0
export async function fixture(clock: any = {}) {
const models=createModels(), faux=fauxProvider({tokensPerSecond:100000}); models.setProvider(faux.provider)
const registry=createRegistry(), storage=await openStorage(`${globalThis.storagePath}.questions-${next++}`)
const harness=await Harness.open(storage,{models,registry,...clock,settings:{get extensions(){return registry.snapshot().installed()}}},context)
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'}}})
const loader=await loadExtensions(harness,registry,storage,models,context)
assert(!loader.state().error,loader.state().error)
const base=`file:///run/sausage/extensions/${loader.state().good}/extensions`
const question=await import(/* @vite-ignore */ `${base}/questions/server.ts`)
const {Messages}=await import(/* @vite-ignore */ `${base}/messages/api.ts`)
const {Standing}=await import(/* @vite-ignore */ `${base}/subagents/api.ts`)
const observed:string[]=[];let heard:any
const route=async(transcript:any)=>{
faux.appendResponses([route]);const last=transcript.messages.at(-1),text=typeof last.content==='string'?last.content:last.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n')
for(const m of transcript.messages) if(m.role==='user') observed.push(typeof m.content==='string'?m.content:m.content.filter((p:any)=>p.type==='text').map((p:any)=>p.text).join('\n'))
await heard?.(text)
return last.role==='user'&&text.startsWith('SEND ')?fauxAssistantMessage([fauxToolCall('message',JSON.parse(text.slice(5)))],{stopReason:'toolUse'}):fauxAssistantMessage('Sent.')
}
faux.setResponses([route]);harness.resume()
const log=async()=>await harness.snapshot(Messages,root.id,context)
const settled=async()=>{await until(async()=>(await harness.commit((tx:any)=>tx.scanTasks({},1000),context)).items.every((t:any)=>t.state.status==='terminal'));await harness.waitForIdle(context)}
const entries=async(id:number)=>(await harness.commit((tx:any)=>tx.scanEntries({conversationId:id},1000),context)).items.toSorted((a:any,b:any)=>a.id-b.id)
const type=async(id:number,content:string)=>{const c=await harness.conversation(id,context);await(await c.submit({type:'input',content},context)).wait(context)}
const ultimate=async()=>{const c=await harness.createConversation({ownership:{kind:'ownerless'}},context);await c.configure({model:{provider:'faux',modelId:'faux-1'}},context);await harness.commit(async(tx:any)=>{(await tx.doc(Standing,root.id)).ultimate=c.id},context);return c.id}
const ask=async(id:number,args:any)=>{await type(id,`SEND ${JSON.stringify({to:'user',...args})}`);await settled();const result=(await entries(id)).findLast((e:any)=>e.kind==='pi.tool-result')?.model?.[0];return {error:result?.isError?result.content.map((p:any)=>p.text).join(''):undefined,message:(await log()).messages.findLast((m:any)=>m.from===id&&m.to==='user')}}
const action=(conversationId?:number)=>({host:globalThis.testHost,conversationId,signal:new AbortController().signal})
return {base,faux,loader,host:globalThis.testHost,opened:{harness,root,close:async()=>{await loader.close();await harness.close(context);await storage.close()}},ultimate,type,ask,answer:(args:any)=>question.actions.answer(args,action()),dismiss:(messageId:number)=>question.actions.dismiss({messageId},action()),log,entries,observed,settled,hear:(fn:any)=>heard=fn}
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
/** Minimal assertions for the imported Ultimate cases, without Node or Vitest. */
export const cases: {name:string;run:()=>any}[]=[]
export const it=(name:string,run:()=>any)=>cases.push({name,run})
export const describe=(_name:string,run:()=>any)=>run()
export const cleanups: (()=>any)[]=[]
export const afterEach=(run:()=>any)=>cleanups.push(run)
expect.objectContaining=(value:any)=>({__object:true,value})
expect.stringContaining=(value:string)=>({__string:true,value})
export const assert=(value:any,message:string)=>{if(!value)throw new Error(message)}
const same=(a:any,b:any):boolean=>b?.__object || b?.__string ? subset(a,b) : b?.__contains ? b.values.every((v:any)=>a.includes(v)) : Array.isArray(b) ? Array.isArray(a)&&a.length===b.length&&b.every((v:any,i:number)=>same(a[i],v)) : b&&typeof b==='object' ? a&&Object.keys(a).length===Object.keys(b).length&&Object.entries(b).every(([k,v])=>same(a[k],v)) : a===b
const subset=(a:any,b:any):boolean=>b?.__object ? subset(a,b.value) : b?.__string ? a.includes(b.value) : b && typeof b==='object' ? Object.entries(b).every(([k,v])=>subset(a?.[k],v)) : a===b
export function expect(value:any,negate=false):any {
const check=(ok:boolean)=>assert(negate?!ok:ok,`Expected ${String(value)}${negate?' not':''} to match`)
const result:any={
toBe:(b:any)=>check(value===b),toEqual:(b:any)=>check(same(value,b)),toHaveLength:(n:number)=>check(value.length===n),
toMatchObject:(b:any)=>check(subset(value,b)),toMatch:(b:RegExp)=>check(b.test(value)),toContain:(b:any)=>check(value.includes(b)),
toBeUndefined:()=>check(value===undefined),toBeDefined:()=>check(value!==undefined),toBeGreaterThanOrEqual:(b:number)=>check(value>=b),
toThrow:(b:any)=>{let error;try{value()}catch(e){error=e}check(error!==undefined && (b instanceof RegExp?b.test(String(error)):String(error).includes(b)))},
}
Object.defineProperty(result,'not',{get:()=>expect(value,!negate)})
Object.defineProperty(result,'rejects',{get:()=>({toThrow:async(b:string)=>{let error;try{await value}catch(e){error=e}check(error!==undefined&&String(error).includes(b))}})})
return result
}
expect.arrayContaining=(values:any[])=>({__contains:true,values})
export async function until(condition:()=>any) {
const deadline=Date.now()+5000
while(!await condition()) {assert(Date.now()<deadline,'Condition deadline');await new Promise(resolve=>setTimeout(resolve,0))}
}
export const setImmediate=(callback:any)=>setTimeout(callback,0)
+10
View File
@@ -0,0 +1,10 @@
/** Ultimate goal and question-card cases execute in the world loader. */
import {fixture} from './goal-questions-fixture'
import {cases,cleanups} from './goal-questions-test-support'
const initial=await fixture()
globalThis.goalBase=initial.base
await initial.opened.close()
await import('./goal-cases')
await import('./questions-cases')
await import('./goal-reminder-cases')
for(const test of cases){try{await test.run();Deno.core.ops.op_output(`PASS ${test.name}\n`)}finally{for(const cleanup of cleanups) await cleanup()}}
+32
View File
@@ -0,0 +1,32 @@
/** A goal reminder fires once through the real automation scheduler and injected clock. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import {fixture} from './goal-questions-fixture'
import {it,assert,until} from './goal-questions-test-support'
it('fires one goal reminder through automations',async()=>{
let now=Date.now();const timers=new Set<any>()
const f=await fixture({now:()=>now,setTimer:(callback:any,delay:number)=>{
if(delay<1000){const id=setTimeout(callback,delay);return()=>clearTimeout(id)}
const timer={at:now+delay,callback};timers.add(timer);return()=>timers.delete(timer)
}})
try {
const id=await f.ultimate();const {harness}=f.opened
const goal=await import(/* @vite-ignore */ `${f.base}/goal/server.ts`)
const {Automations}=await import(/* @vite-ignore */ `${f.base}/automations/server.ts`)
await goal.actions.start({text:'Ship the goal'},{host:f.host})
const get=()=>harness.snapshot(goal.Goals,f.opened.root.id,context)
await until(async()=>Boolean((await get()).goals[0].reminder))
const reminder=(await get()).goals[0].reminder
const ledger=()=>harness.snapshot(Automations,context)
await until(async()=>{const a=(await ledger()).automations[reminder];return (await harness.getTask(a.scheduler,context)).state.status==='sleeping'})
const deadline=(await ledger()).automations[reminder].nextAt
assert(deadline-(await ledger()).automations[reminder].createdAt===1800000,'The reminder waits 30 minutes')
await until(()=>[...timers].some(timer=>timer.at===deadline))
now=deadline
for(const timer of [...timers])if(timer.at<=now){timers.delete(timer);timer.callback()}
await until(async()=>(await ledger()).automations[reminder].fires===1)
await until(async()=>(await f.entries(id)).some((e:any)=>e.kind==='goal.note'&&e.model[0].content.includes('This is the goal reminder')))
assert((await ledger()).automations[reminder].fires===1,'One reminder fire')
await goal.actions.end(null,{host:f.host})
await until(async()=>!(await ledger()).automations[reminder])
} finally {await f.opened.close()}
})
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
// World HTTP clients exercise the live chat API, including disconnects.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
import { fixture } from './goal-questions-fixture';
import { assert, until } from './goal-questions-test-support';
import { harnessApi } from './harness-api';
import { serve } from './input';
const f=await fixture();
const harness=f.opened.harness;
const api=harnessApi(harness,f.host.models(),f.loader,context);
const serving=serve(harness,context,undefined,api);
const curl=async(path:string,body?:any)=>{
const result=await f.host.exec(['/bin/curl.wasm',...(body===undefined?[]:['--json',JSON.stringify(body)]),`http://host.sausage/harness${path}`],{cwd:'/'});
assert(result.code===0,`curl failed: ${result.stderr}`);
return JSON.parse(result.stdout);
};
const id=await f.ultimate();
const question=await f.ask(id,{text:'Choose.',questions:[{text:'Name',kind:'text',ref:'1'}]});
assert(question.message,'Question fixture');
const listed=await curl('/conversations');
assert(listed.conversations.some((c:any)=>c.id===id && c.agent && !c.running),'Conversation list');
const initial=await curl(`/conversations/${id}/entries`);
const after=initial.entries.at(-1).id;
const answer=await curl('/extensions/questions/actions/answer',{args:{messageId:question.message.id,answers:[{question:'Name',answer:'API choice'}]}});
assert(!answer.error,JSON.stringify(answer));
await f.settled();
assert(JSON.stringify(await f.entries(id)).includes('API choice'),'Question answer reaches asker');
assert((await curl('/models')).models.length>0,'Models');
assert((await curl('/families')).families.default,'Families');
assert((await curl('/extensions')).good,'Extension status');
assert(Array.isArray((await curl('/credential-requests')).requests),'Credential requests');
assert((await curl('/unknown')).error,'Unknown route');
assert((await curl('/extensions/questions/actions/answerText',{})).error,'Only user actions are callable');
assert((await curl(`/conversations/${id}/entries?after=bad`)).error,'Invalid cursor');
assert((await curl(`/conversations/${id}/submit`,{content:'bad'})).error,'Missing request id');
const controller=new AbortController();let received='';
const stream=f.host.exec(['/bin/harness-api-client.wasm',`/harness/conversations/${id}/stream?after=${after}`],{cwd:'/',signal:controller.signal,onOutput:(text:string)=>{received+=text;}}).catch(()=>{});
let release!:()=>void;
const held=new Promise<void>(resolve=>release=resolve);
f.hear(async(text:string)=>{if(text==='HTTP input')await held;});
const submitted=await curl(`/conversations/${id}/submit`,{content:'HTTP input',requestId:'http-input'});
assert(submitted.submissionId,'Submission acknowledgement');
const duplicate=await curl(`/conversations/${id}/submit`,{content:'HTTP input',requestId:'http-input'});
assert(duplicate.submissionId===submitted.submissionId,'Idempotent submission');
await until(()=>received.includes('HTTP input'));
const during=await curl('/conversations');
assert(during.conversations.find((c:any)=>c.id===id).running,'Stream works during a turn');
release();await f.settled();
await until(()=>received.includes('pi.assistant'));
controller.abort();await stream;
const updated=await curl(`/conversations/${id}/entries?after=${after}`);
assert(updated.entries.every((e:any)=>e.id>after),'Exclusive cursor');
assert(updated.entries.filter((e:any)=>e.kind==='pi.user'&&JSON.stringify(e).includes('HTTP input')).length===1,'No second input');
let stopping=false;
f.faux.setResponses([async (_transcript:any,options:any)=>{
stopping=true;
await new Promise(resolve=>options.signal.addEventListener('abort',resolve,{once:true}));
return fauxAssistantMessage('Interrupted');
}]);
await curl(`/conversations/${id}/submit`,{content:'Stop this turn',requestId:'stop-input'});
await until(()=>stopping);
assert((await curl(`/conversations/${id}/stop`,{})).ok,'Stop');
await f.settled();
assert(!(await curl('/conversations')).conversations.find((c:any)=>c.id===id).running,'Stop ends active turn');
const eventsController=new AbortController();let events='';
const eventsStream=f.host.exec(['/bin/harness-api-client.wasm','/harness/events?after=0'],{cwd:'/',signal:eventsController.signal,onOutput:(text:string)=>{events+=text;}}).catch(()=>{});
const respond=(transcript:any):any=>{
f.faux.appendResponses([respond]);
const last=transcript.messages.at(-1);
const text=typeof last.content==='string'?last.content:JSON.stringify(last.content);
return last.role==='user'&&text.includes('DELEGATE')?fauxAssistantMessage([fauxToolCall('subagent',{action:'start',agent:'coordinator',task:'facts'})],{stopReason:'toolUse'}):fauxAssistantMessage('Child answer');
};
f.faux.setResponses([respond]);
await f.type(f.opened.root.id,'DELEGATE');await f.settled();
await until(()=>events.includes('subagents.started'));
eventsController.abort();await eventsStream;
let shutdownData='';
const shutdownStream=f.host.exec(['/bin/harness-api-client.wasm','/harness/events?after=0'],{cwd:'/',onOutput:(text:string)=>{shutdownData+=text;}});
await until(()=>shutdownData.includes('subagents.started'));
await f.host.fs.write('/harness-test-done','ok');
await serving;
await api.close();
assert((await shutdownStream).code===0,'Harness shutdown ends streams');
await f.opened.close();
+110
View File
@@ -0,0 +1,110 @@
// The world chat API runs in the same harness as turns and extension actions.
import { AgentDoc, LiveDoc, defineDoc } from '@earendil-works/pi-durable';
import { Metadata, fs } from './extensions';
import { eventLog } from './events';
const Concluded = defineDoc({kind:'conversations.concluded',version:1,scope:'session',checkpointWhen:()=>false,initial:()=>({ids:[]})});
const Started = defineDoc({kind:'board.started',version:1,scope:'conversation',history:'latest',fork:'initial',checkpointWhen:()=>false,initial:()=>({})});
const fail = (status:number,message:string):never => { throw Object.assign(new Error(message),{status}); };
const integer = (value:any, fallback?:number) => {
if (value === null && fallback !== undefined) return fallback;
const n=Number(value);
if (!Number.isSafeInteger(n) || n<0) fail(400,'Invalid cursor or conversation');
return n;
};
export function harnessApi(harness:any, models:any, extensions:any, context:any) {
const streams = new Map<string,()=>Promise<void>>();
const log = eventLog(()=>harness).eventLog;
const send = (id:string,value:any) => Deno.core.ops.op_harness_reply(id,value);
async function entries(conversation:any,after:number) {
const found:any[]=[];
let cursor:any;
do {
const page=await conversation.entries({minEntryId:after+1,order:'ascending'},256,cursor,context);
found.push(...page.items); cursor=page.next;
} while(cursor);
return found;
}
async function follow(input:any, read:(after:number)=>Promise<any[]>, cursor:number) {
// Observe before the snapshot. Serialize scans to close the snapshot/subscription race.
let active=true, queue=Promise.resolve();
const scan=()=>queue=queue.then(async()=>{
if (!active) return;
for (;;) {
const page=await read(cursor);
if(!active || !page.length) return;
for (const entry of page) {
cursor=entry.id; send(input.id,entry);
}
}
});
const observation=harness.observeCommits({advance:(publication:any)=>{if(publication.changes.some((change:any)=>change.type==='entry'))void scan();},end:()=>{void stop();}});
const stop=async()=>{if(!active)return;active=false;observation.detach();await queue;send(input.id,null);streams.delete(input.id);};
streams.set(input.id,stop);
send(input.id,{status:200,stream:true});
void Deno.core.ops.op_harness_closed(input.id).then(stop);
await scan();
}
async function handle(input:any) {
const url=new URL(input.path,'http://host.sausage');
const path=url.pathname.slice('/harness'.length);
const data=input.body??{};
const reply=(status:number,body:any)=>{send(input.id,{status,body});send(input.id,null);};
try {
if(input.method==='GET' && path==='/conversations') {
const records=await harness.commit(async(tx:any)=>{
const all:any[]=[];let cursor:any;
do {const page=await tx.scanConversations({},256,cursor);all.push(...page.items);cursor=page.next;}while(cursor);
return all;
},context);
const concluded=new Set((await harness.snapshot(Concluded,context))?.ids??[]);
const conversations=await Promise.all(records.map(async(record:any)=>{
const agent=await harness.snapshot(AgentDoc,record.id,context);
const metadata=await harness.snapshot(Metadata,record.id,context);
const live=await harness.snapshot(LiveDoc,record.id,context);
const started:any=await harness.snapshot(Started,record.id,context);
return {...record,title:metadata?.title??'',agent,starter:started?.by??null,concluded:concluded.has(record.id),running:!!live?.run};
}));
reply(200,{conversations});return;
}
if(input.method==='GET' && path==='/events') {
const cursor=integer(url.searchParams.get('after'),0);
await follow(input,async(after)=>log.recent({since:after,order:'ascending',limit:256}),cursor);return;
}
if(input.method==='GET' && path==='/extensions') {reply(200,extensions.state());return;}
if(input.method==='GET' && path==='/families') {
let families={};try{families=JSON.parse(await fs.read('/src/sausage/agents/models.json'));}catch(error){if((error as any).code!=='not_found')throw error;}
reply(200,{families});return;
}
if(input.method==='GET' && path==='/models') {reply(200,{models:models.getAllModels().map(({id,name,provider,contextWindow,maxTokens,reasoning,cost}:any)=>({id,name,provider,contextWindow,maxTokens,reasoning,cost}))});return;}
const action=/^\/extensions\/([^/]+)\/actions\/([^/]+)$/.exec(path);
if(input.method==='POST' && action) {
if(data.conversationId!==undefined && (!Number.isSafeInteger(data.conversationId)||data.conversationId<1)) fail(400,'Invalid conversation id');
const controller=new AbortController();
void Deno.core.ops.op_harness_closed(input.id).then(()=>controller.abort());
reply(200,{result:await extensions.invoke(decodeURIComponent(action[1]),decodeURIComponent(action[2]),data.args??{},controller.signal,data.conversationId)});return;
}
const match=/^\/conversations\/(\d+)\/(entries|stream|submit|stop)$/.exec(path);
if(!match) fail(404,'Unknown harness route');
const conversation=await harness.conversation(integer(match![1]),context);
if(!conversation) fail(404,'Unknown conversation');
const after=integer(url.searchParams.get('after'),0);
if(input.method==='GET' && match![2]==='entries') {reply(200,{entries:await entries(conversation,after)});return;}
if(input.method==='GET' && match![2]==='stream') {await follow(input,(cursor)=>entries(conversation,cursor),after);return;}
if(input.method==='POST' && match![2]==='submit') {
if(typeof data.requestId!=='string'||!data.requestId||typeof data.content!=='string'||!data.content.trim()) fail(400,'Expected content and requestId');
const submission=await conversation.submit({type:'input',content:data.content,requestId:data.requestId},context);
reply(202,{submissionId:submission.id});return;
}
if(input.method==='POST' && match![2]==='stop') {
for (;;) {
const own=(await harness.inspect(context)).tasks.filter(({record}:any)=>record.conversationId===conversation.id&&!record.owner&&!record.background);
if(!own.length) break;
for(const {record} of own) {await harness.abortTask(record.id,context);await harness.waitForTask(record.id,context);}
}
reply(200,{ok:true});return;
}
fail(404,'Unknown harness route');
} catch(error) {reply((error as any).status??500,{error:(error as Error).message});}
}
return {handle,async close(){await Promise.all([...streams.values()].map(stop=>stop()));}};
}
+15 -4
View File
@@ -1,20 +1,31 @@
// Channel submissions target any conversation. EOF and Stop close the harness cleanly.
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>) {
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>, api?: any) {
const pending = new Set<Promise<void>>();
const track = (work: Promise<void>) => {
const task = work.catch(error => Deno.core.ops.op_output(`${String(error)}\n`)).finally(() => pending.delete(task));
pending.add(task);
};
while (true) {
const input = await Deno.core.ops.op_input();
if (input === null || input.type === 'stop') {
await harness.waitForIdle(context);
await Promise.all(pending);
return;
}
if (input.type === 'api') {
track(api.handle(input));
continue;
}
const conversation = await harness.conversation(input.conversation, context);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
const submission = await conversation.submit({
type: 'input', content: input.content, requestId: input.requestId,
}, context);
if (afterSubmit) {
const result = await submission.wait(context);
await afterSubmit();
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
track(submission.wait(context).then(async (result: any) => {
await afterSubmit();
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
}));
}
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { openStorage } from './sqlite';
import { environment, conversationChains } from './environment';
import { CredentialRequests, startRequests } from './credentials';
import { serve } from './input';
import { harnessApi } from './harness-api';
import { loadExtensions } from './extensions';
const proxy = Deno.core.ops.op_model_proxy();
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
@@ -67,6 +68,7 @@ for (const entry of watch.value.entries) {
if (globalThis.consoleEnabled) watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
const stopRequests = await startRequests(harness, context);
const extensions = await loadExtensions(harness, registry, storage, models, context, [CodingTools, CredentialRequests]);
const api = harnessApi(harness, models, extensions, context);
harness.resume();
await serve(harness, context, globalThis.consoleEnabled ? async () => {
await harness.waitForIdle(context);
@@ -74,7 +76,8 @@ await serve(harness, context, globalThis.consoleEnabled ? async () => {
render(final.value);
await final.stop();
Deno.core.ops.op_output('\n');
} : undefined);
} : undefined, api);
await api.close();
await extensions.close();
await stopRequests();
await watch.stop();
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+95 -56
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness, registry, storage, models, context, built
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
@@ -31684,7 +31690,7 @@ function answerFor(rules, transcript) {
const last = transcript.messages.findLast((message) => message.role !== "system");
const rule = rules.find((rule2) => rule2.role === role && (!rule2.match.kind || rule2.match.kind === last?.role) && textOf(last?.content).includes(rule2.match.text));
if (!rule) throw new Error(`No scripted rule for ${role}: ${JSON.stringify(last)}`);
return { rule: rule.id, answer: rule.answer };
return { rule: rule.id, answer: typeof rule.answer === "function" ? rule.answer(transcript) : rule.answer };
}
function scriptedModel(rules, report2) {
const faux = fauxProvider({ tokensPerSecond: 1e5 });
@@ -31707,6 +31713,11 @@ function testReplay() {
const first = answerFor(rules, { messages: [system, input] });
const replay = answerFor(JSON.parse(JSON.stringify(rules)), { messages: [system, { role: "assistant", content: [] }, input] });
if (JSON.stringify(first) !== JSON.stringify(replay)) throw new Error("Scripted replay changed the answer");
const dynamic = [{ id: "land", role: "worker", match: { text: "build" }, answer: (transcript) => ({ tools: [{ name: "land", args: { id: /Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1] } }] }) }];
const history = { role: "toolResult", content: "Started subagent 42." };
const selected = answerFor(dynamic, { messages: [system, history, input] });
const restarted = answerFor(dynamic, { messages: [system, { role: "assistant", content: [] }, history, input] });
if (JSON.stringify(selected) !== JSON.stringify(restarted) || selected.answer.tools?.[0].args.id !== "42") throw new Error("Dynamic tool identity changed on replay");
const toolRules = [{ id: "worker-done", role: "worker", match: { text: "committed" }, answer: { text: "done" } }];
if (answerFor(toolRules, { messages: [system, { role: "toolResult", content: [{ type: "text", text: "committed" }] }] }).rule !== "worker-done") throw new Error("Tool result rule did not match");
}
@@ -31717,14 +31728,17 @@ var assert = (ok, message) => {
};
testReplay();
if (globalThis.p3Mode !== "unit") {
const workerId = (transcript) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
const rules = [
{ id: "ultimate-branch", role: "ultimate", match: { kind: "user", text: "launch branch" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", prompt: "branch probe" } }] } },
{ id: "ultimate-branch", role: "ultimate", match: { kind: "user", text: "launch scenario" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", isolated: true, prompt: "begin" } }] } },
{ id: "ultimate-probe", role: "ultimate", match: { kind: "user", text: "launch branch" }, answer: { tools: [{ name: "branch", args: { repo: "/src/demo", prompt: "branch probe" } }] } },
{ id: "coordinator-probe", role: "coordinator", match: { kind: "user", text: "branch probe" }, answer: { text: "branch complete" } },
{ id: "ultimate-receipt", role: "ultimate", match: { text: "" }, answer: { text: "Receipt noted." } },
{ id: "coordinator-echo", role: "coordinator", match: { kind: "user", text: "Receipt noted." }, answer: { text: "Acknowledged." } },
{ id: "start-worker", role: "coordinator", match: { kind: "user", text: "begin" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "worker", isolate: "none", task: "build feature", background: true } }] } },
{ id: "start-worker", role: "coordinator", match: { kind: "user", text: "begin" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "worker", isolate: "worktree", task: "build feature", background: true } }] } },
{ id: "start-reviewer", role: "coordinator", match: { kind: "user", text: "worker complete" }, answer: { tools: [{ name: "subagent", args: { action: "start", agent: "reviewer", isolate: "none", task: "review feature", background: true } }] } },
{ id: "coordinator-done", role: "coordinator", match: { kind: "user", text: "approve" }, answer: { text: "scenario complete" } },
{ id: "land-worker", role: "coordinator", match: { kind: "user", text: "approve" }, answer: (transcript) => ({ tools: [{ name: "subagent", args: { action: "land", id: workerId(transcript) } }] }) },
{ id: "submit", role: "coordinator", match: { text: "Landed commits" }, answer: { tools: [{ name: "workspace", args: { action: "submit", divergence: "None" } }] } },
{ id: "coordinator-wait", role: "coordinator", match: { text: "" }, answer: { text: "Wait for child report." } },
{ id: "worker-write", role: "worker", match: { kind: "user", text: "build feature" }, answer: { tools: [{ name: "write-feature", args: {} }] } },
{ id: "worker-commit", role: "worker", match: { text: "feature written" }, answer: { tools: [{ name: "commit-feature", args: {} }] } },
@@ -31740,30 +31754,50 @@ if (globalThis.p3Mode !== "unit") {
return registry.snapshot().installed();
} } }, BACKGROUND_CONTEXT);
const root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, instructions: "P3 role:ultimate", cwd: "/src/demo" } });
const git2 = async (...args) => {
const result = await exec(["/bin/git.wasm", ...args], { cwd: "/src/demo", env: [["GIT_AUTHOR_NAME", "Test"], ["GIT_AUTHOR_EMAIL", "test@example.com"], ["GIT_COMMITTER_NAME", "Test"], ["GIT_COMMITTER_EMAIL", "test@example.com"]] });
if (globalThis.p3Mode === "kill-forward") {
const install = registry.install.bind(registry);
registry.install = (extension) => install(extension.name !== "workspaces" ? extension : { ...extension, tasks: extension.tasks.map((task) => task.definition.name !== "workspaces.lander" ? task : { definition: { ...task.definition, phases: { ...task.definition.phases, land: async (record2, runtime, ctx) => {
const commit = runtime.commit.bind(runtime);
return task.definition.phases.land(record2, { ...runtime, commit: async (...args) => {
const result = await commit(...args);
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT);
if (index.queue.some((entry2) => entry2.landed)) {
await fs.write("/forward-recorded.tmp", JSON.stringify(index.queue));
await fs.rename("/forward-recorded.tmp", "/forward-recorded");
await awaitWithContext(new Promise(() => {
}), ctx);
}
return result;
} }, ctx);
} } } }) });
}
const git2 = async (cwd, ...args) => {
const result = await exec(["/bin/git.wasm", ...args], { cwd, env: [["GIT_AUTHOR_NAME", "Test"], ["GIT_AUTHOR_EMAIL", "test@example.com"], ["GIT_COMMITTER_NAME", "Test"], ["GIT_COMMITTER_EMAIL", "test@example.com"]] });
assert(result.code === 0, `World git ${args}: ${JSON.stringify(result)}`);
return result;
};
const tools = defineExtension({ name: "p3-tools", tools: [
defineTool({ name: "write-feature", description: "Write the demo feature", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => {
defineTool({ name: "write-feature", description: "Write the isolated feature", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => {
const cwd = (await api.agent(ctx)).cwd;
assert(cwd.startsWith("/worktrees/") && await fs.read(`${cwd}/README.md`) === "P3 demo\n", "Worker must start in a prepared checkout");
if (globalThis.p3Mode === "kill-worker") {
await fs.write("/worker-running", "yes");
await awaitWithContext(new Promise(() => {
}), ctx);
}
await fs.write("/src/demo/feature.txt", "P3 feature\n");
await fs.write(`${cwd}/feature.txt`, "P3 feature\n");
return { output: [{ type: "text", text: "feature written" }] };
} }),
defineTool({ name: "commit-feature", description: "Commit the demo feature", parameters: obj({}), replay: "safe", execute: async () => {
await git2("add", "feature.txt");
if ((await git2("status", "--porcelain")).stdout.trim()) await git2("commit", "--no-gpg-sign", "-m", "P3 feature");
defineTool({ name: "commit-feature", description: "Commit the isolated feature", parameters: obj({}), replay: "safe", execute: async (_args, api, ctx) => {
const cwd = (await api.agent(ctx)).cwd;
await git2(cwd, "add", "feature.txt");
if ((await git2(cwd, "status", "--porcelain")).stdout.trim()) await git2(cwd, "commit", "--no-gpg-sign", "-m", "P3 feature");
return { output: [{ type: "text", text: "feature committed" }] };
} }),
defineTool({ name: "review-feature", description: "Verify the committed demo feature", parameters: obj({}), replay: "safe", execute: async () => {
assert(await fs.read("/src/demo/feature.txt") === "P3 feature\n", "Reviewer lost feature");
const result = await git2("show", "HEAD:feature.txt");
assert(result.stdout === "P3 feature\n", "Reviewer did not read the committed feature");
defineTool({ name: "review-feature", description: "Verify the worker commit before landing", parameters: obj({}), replay: "safe", execute: async (_args, _api, ctx) => {
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, ctx);
const worker = Object.values(index.items).find((item) => item.source.startsWith("/worktrees/"));
assert(worker && (await git2(worker.path, "show", "HEAD:feature.txt")).stdout === "P3 feature\n", "Reviewer must approve the worker commit");
return { output: [{ type: "text", text: "feature verified" }] };
} })
] });
@@ -31771,54 +31805,59 @@ if (globalThis.p3Mode !== "unit") {
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
try {
if (globalThis.p3Mode === "load-all") {
assert((await root.agent(BACKGROUND_CONTEXT)).tools.some((tool) => tool.name === "branch"), "Every extension must load, including messages");
} else if (globalThis.p3Mode === "branch" || globalThis.p3Mode === "branch-resume") {
assert((await root.agent(BACKGROUND_CONTEXT)).tools.some((tool) => tool.name === "branch"), "Every extension must load");
} else {
const ultimate = await harness.conversation(await globalThis.p3Subagents.standing("ultimate", BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
harness.resume();
if (globalThis.p3Mode === "branch") await (await ultimate.submit({ type: "input", content: "launch branch", requestId: "p3-branch" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
const replies = async () => (await ultimate.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "board.reply");
const deadline = Date.now() + 3e4;
while ((await replies()).length === 0) {
if (Date.now() > deadline) throw new Error(`No branch reply: ${JSON.stringify((await ultimate.context(BACKGROUND_CONTEXT)).entries)}`);
await new Promise((resolve2) => setTimeout(resolve2, 0));
const probe = globalThis.p3Mode.startsWith("branch");
if (globalThis.p3Mode === "branch" || globalThis.p3Mode.startsWith("kill-")) {
await (await ultimate.submit({ type: "input", content: probe ? "launch branch" : "launch scenario", requestId: "p3-launch" }, BACKGROUND_CONTEXT)).wait(BACKGROUND_CONTEXT);
const entries3 = (await ultimate.context(BACKGROUND_CONTEXT)).entries;
assert(!JSON.stringify(entries3).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries3)}`);
}
await harness.waitForIdle(BACKGROUND_CONTEXT);
while (Deno.core.ops.op_sql("all", "SELECT id FROM tasks WHERE json_extract(kind, '$') IN ('board.route', 'board.delivery') AND status NOT IN ('terminal', 'faulted')", []).length) {
if (Date.now() > deadline) throw new Error("Branch routing did not settle");
await new Promise((resolve2) => setTimeout(resolve2, 0));
}
const received = await replies();
assert(received.length === 1 && received[0].data.text === "branch complete" && !received[0].data.failed, `Expected one coordinator answer: ${JSON.stringify(received)}`);
const branch = await harness.conversation(received[0].data.from, BACKGROUND_CONTEXT);
assert((await branch.agent(BACKGROUND_CONTEXT)).instructions === "P3 role:coordinator", "Branch must use the coordinator definition");
assert((await conversationChains(harness, BACKGROUND_CONTEXT)(branch.id)).includes(String(ultimate.id)), "D63 chain must include ultimate");
const faults = Deno.core.ops.op_sql("all", "SELECT id, record FROM tasks WHERE status = 'faulted'", []);
assert(faults.length === 0, `Faulted branch tasks: ${JSON.stringify(faults)}`);
} else {
if (globalThis.p3Mode === "kill-worker") {
const coordinator2 = await globalThis.p3Models.start({ agent: "coordinator", cwd: "/src/demo" }, BACKGROUND_CONTEXT);
await fs.write("/coordinator", String(coordinator2.id));
await coordinator2.submit({ type: "input", content: "begin", requestId: "p3-begin" }, BACKGROUND_CONTEXT);
}
harness.resume();
if (globalThis.p3Mode === "kill-worker") await new Promise(() => {
if (globalThis.p3Mode.startsWith("kill-")) await new Promise(() => {
});
const coordinator = await harness.conversation(Number(await fs.read("/coordinator")), BACKGROUND_CONTEXT);
const deadline = Date.now() + 3e4;
while (!JSON.stringify((await coordinator.context(BACKGROUND_CONTEXT)).entries).includes("scenario complete")) {
if (Date.now() > deadline) throw new Error(`P3 did not finish: ${JSON.stringify((await coordinator.context(BACKGROUND_CONTEXT)).entries)}`);
await new Promise((resolve2) => setTimeout(resolve2, 0));
const deadline = Date.now() + 6e4;
const wait = async (predicate, message) => {
while (!await predicate()) {
assert(Date.now() < deadline, `${message}: ${JSON.stringify(Deno.core.ops.op_sql("all", "SELECT id, kind, status, record FROM tasks WHERE status != 'terminal'", []))}`);
await new Promise((resolve2) => setTimeout(resolve2, 0));
}
};
const replies = async () => (await ultimate.context(BACKGROUND_CONTEXT)).entries.filter((entry2) => entry2.kind === "board.reply");
await wait(async () => (await replies()).length > 0, "No branch reply");
const received = await replies();
const coordinator = await harness.conversation(received[0].data.from, BACKGROUND_CONTEXT);
assert((await coordinator.agent(BACKGROUND_CONTEXT)).instructions === "P3 role:coordinator", "Branch must use coordinator definition");
assert((await conversationChains(harness, BACKGROUND_CONTEXT)(coordinator.id)).includes(String(ultimate.id)), "D63 must include ultimate");
if (!probe) {
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT)).results).length === 1, "No landing");
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT)).queue.length === 0, "Lander did not report");
}
await harness.waitForIdle(BACKGROUND_CONTEXT);
await wait(async () => Deno.core.ops.op_sql("all", "SELECT id FROM tasks WHERE status NOT IN ('terminal', 'faulted')", []).length === 0, "Background tasks did not settle");
const entries2 = (await coordinator.context(BACKGROUND_CONTEXT)).entries;
const reports = entries2.filter((entry2) => entry2.kind === "subagents.report");
assert(reports.length === 2, `Expected two child reports: ${JSON.stringify(entries2)}`);
assert(new Set(reports.map((report2) => report2.data.sender)).size === 2, "Expected one report from each child");
for (const answer2 of ["worker complete", "approve"]) assert(reports.filter((report2) => JSON.stringify(report2).includes(answer2)).length === 1, `Expected exactly one report for ${answer2}`);
assert(JSON.stringify(entries2).includes("scenario complete"), "Coordinator did not finish");
const faults = Deno.core.ops.op_sql("all", "SELECT id FROM tasks WHERE status = 'faulted'", []);
if (probe) assert(received.length === 1 && received[0].data.text === "branch complete" && !received[0].data.failed, "Expected one branch answer");
else {
const reports = entries2.filter((entry2) => entry2.kind === "subagents.report");
assert(reports.length === 2 && new Set(reports.map((entry2) => entry2.data.sender)).size === 2, `Expected one report per child: ${JSON.stringify(reports)}`);
for (const answer2 of ["worker complete", "approve"]) assert(reports.filter((entry2) => JSON.stringify(entry2).includes(answer2)).length === 1, `Duplicate child answer: ${answer2}`);
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, BACKGROUND_CONTEXT);
const worker = Object.values(index.items).find((item) => item.source.startsWith("/worktrees/"));
assert(worker?.state === "removed", "Worker checkout must be released");
assert(!await fs.stat(worker.path).then(() => true, () => false), "Released checkout must not exist");
assert(Object.values(index.results).every((result) => result.outcome === "landed"), `Landing failed: ${JSON.stringify(index)}`);
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: "event.workspaces.landed" }] })).length === 1, "Expected one landed event");
const ultimateEntries = (await ultimate.context(BACKGROUND_CONTEXT)).entries;
const landed = Object.values(index.results)[0];
const cards = ultimateEntries.filter((entry2) => entry2.kind === "board.message" && entry2.data.text.startsWith("[lander] Submission") && entry2.data.text.includes(" landed as "));
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
const repliesNow = await replies();
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry2) => entry2.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
assert(entries2.filter((entry2) => entry2.kind === "workspaces.lander-report").length === 1, "Expected one lander report");
}
const faults = Deno.core.ops.op_sql("all", "SELECT id, record FROM tasks WHERE status = 'faulted'", []);
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
await fs.write("/p3-idle", "yes");
}
} finally {
await loader.close();
+83 -65
View File
@@ -1,4 +1,4 @@
/** P3 exit scaffold: real world git, scripted coordination, and worker SIGKILL recovery. */
/** P3 exit contract: one branch, two child answers, and one landing across three crash boundaries. */
import { BACKGROUND_CONTEXT as context, awaitWithContext } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { Harness, createRegistry, defineExtension, defineTool } from '@earendil-works/pi-durable';
@@ -10,14 +10,17 @@ import { scriptedModel, testReplay, type Rule } from './scripted-model';
const assert = (ok: any, message: string) => { if (!ok) throw new Error(message); };
testReplay();
if (globalThis.p3Mode !== 'unit') {
const workerId = (transcript: any) => Number(/Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1]);
const rules: Rule[] = [
{ id: 'ultimate-branch', role: 'ultimate', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
{ id: 'ultimate-branch', role: 'ultimate', match: { kind: 'user', text: 'launch scenario' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', isolated: true, prompt: 'begin' } }] } },
{ id: 'ultimate-probe', role: 'ultimate', match: { kind: 'user', text: 'launch branch' }, answer: { tools: [{ name: 'branch', args: { repo: '/src/demo', prompt: 'branch probe' } }] } },
{ id: 'coordinator-probe', role: 'coordinator', match: { kind: 'user', text: 'branch probe' }, answer: { text: 'branch complete' } },
{ id: 'ultimate-receipt', role: 'ultimate', match: { text: '' }, answer: { text: 'Receipt noted.' } },
{ id: 'coordinator-echo', role: 'coordinator', match: { kind: 'user', text: 'Receipt noted.' }, answer: { text: 'Acknowledged.' } },
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'none', task: 'build feature', background: true } }] } },
{ id: 'start-worker', role: 'coordinator', match: { kind: 'user', text: 'begin' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'worker', isolate: 'worktree', task: 'build feature', background: true } }] } },
{ id: 'start-reviewer', role: 'coordinator', match: { kind: 'user', text: 'worker complete' }, answer: { tools: [{ name: 'subagent', args: { action: 'start', agent: 'reviewer', isolate: 'none', task: 'review feature', background: true } }] } },
{ id: 'coordinator-done', role: 'coordinator', match: { kind: 'user', text: 'approve' }, answer: { text: 'scenario complete' } },
{ id: 'land-worker', role: 'coordinator', match: { kind: 'user', text: 'approve' }, answer: transcript => ({ tools: [{ name: 'subagent', args: { action: 'land', id: workerId(transcript) } }] }) },
{ id: 'submit', role: 'coordinator', match: { text: 'Landed commits' }, answer: { tools: [{ name: 'workspace', args: { action: 'submit', divergence: 'None' } }] } },
{ id: 'coordinator-wait', role: 'coordinator', match: { text: '' }, answer: { text: 'Wait for child report.' } },
{ id: 'worker-write', role: 'worker', match: { kind: 'user', text: 'build feature' }, answer: { tools: [{ name: 'write-feature', args: {} }] } },
{ id: 'worker-commit', role: 'worker', match: { text: 'feature written' }, answer: { tools: [{ name: 'commit-feature', args: {} }] } },
@@ -30,30 +33,49 @@ if (globalThis.p3Mode !== 'unit') {
const storage = await openStorage(globalThis.storagePath), registry = createRegistry();
const harness = await Harness.open(storage, { models, registry, settings: { retry: { enabled: false }, get extensions() { return registry.snapshot().installed(); } } }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, instructions: 'P3 role:ultimate', cwd: '/src/demo' } });
const git = async (...args: string[]) => {
const result = await exec(['/bin/git.wasm', ...args], { cwd: '/src/demo', env: [['GIT_AUTHOR_NAME', 'Test'], ['GIT_AUTHOR_EMAIL', 'test@example.com'], ['GIT_COMMITTER_NAME', 'Test'], ['GIT_COMMITTER_EMAIL', 'test@example.com']] });
// Stop after the durable lander commit records landed.sha, before its event/report commit.
if (globalThis.p3Mode === 'kill-forward') {
const install = registry.install.bind(registry);
registry.install = (extension: any) => install(extension.name !== 'workspaces' ? extension : { ...extension, tasks: extension.tasks.map((task: any) => task.definition.name !== 'workspaces.lander' ? task : { definition: { ...task.definition, phases: { ...task.definition.phases, land: async (record: any, runtime: any, ctx: any) => {
const commit = runtime.commit.bind(runtime);
return task.definition.phases.land(record, { ...runtime, commit: async (...args: any[]) => {
const result = await commit(...args);
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context);
if (index.queue.some((entry: any) => entry.landed)) {
await fs.write('/forward-recorded.tmp', JSON.stringify(index.queue));
await fs.rename('/forward-recorded.tmp', '/forward-recorded');
await awaitWithContext(new Promise(() => {}), ctx);
}
return result;
} }, ctx);
} } } }) });
}
const git = async (cwd: string, ...args: string[]) => {
const result = await exec(['/bin/git.wasm', ...args], { cwd, env: [['GIT_AUTHOR_NAME', 'Test'], ['GIT_AUTHOR_EMAIL', 'test@example.com'], ['GIT_COMMITTER_NAME', 'Test'], ['GIT_COMMITTER_EMAIL', 'test@example.com']] });
assert(result.code === 0, `World git ${args}: ${JSON.stringify(result)}`);
return result;
};
const tools = defineExtension({ name: 'p3-tools', tools: [
defineTool({ name: 'write-feature', description: 'Write the demo feature', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
defineTool({ name: 'write-feature', description: 'Write the isolated feature', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {
const cwd = (await api.agent(ctx)).cwd;
assert(cwd.startsWith('/worktrees/') && await fs.read(`${cwd}/README.md`) === 'P3 demo\n', 'Worker must start in a prepared checkout');
if (globalThis.p3Mode === 'kill-worker') {
await fs.write('/worker-running', 'yes');
await awaitWithContext(new Promise(() => {}), ctx);
}
await fs.write('/src/demo/feature.txt', 'P3 feature\n');
await fs.write(`${cwd}/feature.txt`, 'P3 feature\n');
return { output: [{ type: 'text', text: 'feature written' }] };
} }),
defineTool({ name: 'commit-feature', description: 'Commit the demo feature', parameters: obj({}), replay: 'safe', execute: async () => {
await git('add', 'feature.txt');
// A replay after a committed tool side effect must not create a second commit.
if ((await git('status', '--porcelain')).stdout.trim()) await git('commit', '--no-gpg-sign', '-m', 'P3 feature');
defineTool({ name: 'commit-feature', description: 'Commit the isolated feature', parameters: obj({}), replay: 'safe', execute: async (_args, api, ctx) => {
const cwd = (await api.agent(ctx)).cwd;
await git(cwd, 'add', 'feature.txt');
if ((await git(cwd, 'status', '--porcelain')).stdout.trim()) await git(cwd, 'commit', '--no-gpg-sign', '-m', 'P3 feature');
return { output: [{ type: 'text', text: 'feature committed' }] };
} }),
defineTool({ name: 'review-feature', description: 'Verify the committed demo feature', parameters: obj({}), replay: 'safe', execute: async () => {
assert(await fs.read('/src/demo/feature.txt') === 'P3 feature\n', 'Reviewer lost feature');
const result = await git('show', 'HEAD:feature.txt');
assert(result.stdout === 'P3 feature\n', 'Reviewer did not read the committed feature');
defineTool({ name: 'review-feature', description: 'Verify the worker commit before landing', parameters: obj({}), replay: 'safe', execute: async (_args, _api, ctx) => {
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, ctx);
const worker = Object.values(index.items).find((item: any) => item.source.startsWith('/worktrees/')) as any;
assert(worker && (await git(worker.path, 'show', 'HEAD:feature.txt')).stdout === 'P3 feature\n', 'Reviewer must approve the worker commit');
return { output: [{ type: 'text', text: 'feature verified' }] };
} }),
] });
@@ -61,62 +83,58 @@ if (globalThis.p3Mode !== 'unit') {
assert(!loader.state().error, `Extension load: ${loader.state().error}`);
try {
if (globalThis.p3Mode === 'load-all') {
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load, including messages');
} else if (globalThis.p3Mode === 'branch' || globalThis.p3Mode === 'branch-resume') {
assert((await root.agent(context)).tools.some((tool: any) => tool.name === 'branch'), 'Every extension must load');
} else {
const ultimate = (await harness.conversation(await globalThis.p3Subagents.standing('ultimate', context), context))!;
harness.resume();
if (globalThis.p3Mode === 'branch') await (await ultimate.submit({ type: 'input', content: 'launch branch', requestId: 'p3-branch' }, context)).wait(context);
const probe = globalThis.p3Mode.startsWith('branch');
if (globalThis.p3Mode === 'branch' || globalThis.p3Mode.startsWith('kill-')) {
await (await ultimate.submit({ type: 'input', content: probe ? 'launch branch' : 'launch scenario', requestId: 'p3-launch' }, context)).wait(context);
const entries = (await ultimate.context(context)).entries;
assert(!JSON.stringify(entries).includes('"isError":true'), `Branch tool failed: ${JSON.stringify(entries)}`);
}
if (globalThis.p3Mode.startsWith('kill-')) await new Promise(() => {});
const deadline = Date.now() + 60000;
const wait = async (predicate: () => Promise<boolean>, message: string) => {
while (!await predicate()) {
assert(Date.now() < deadline, `${message}: ${JSON.stringify(Deno.core.ops.op_sql('all', "SELECT id, kind, status, record FROM tasks WHERE status != 'terminal'", []))}`);
await new Promise(resolve => setTimeout(resolve, 0));
}
};
const replies = async () => (await ultimate.context(context)).entries.filter((entry: any) => entry.kind === 'board.reply');
const deadline = Date.now() + 30000;
while ((await replies()).length === 0) {
if (Date.now() > deadline) throw new Error(`No branch reply: ${JSON.stringify((await ultimate.context(context)).entries)}`);
await new Promise(resolve => setTimeout(resolve, 0));
await wait(async () => (await replies()).length > 0, 'No branch reply');
const received = await replies();
const coordinator = (await harness.conversation(received[0].data.from, context))!;
assert((await coordinator.agent(context)).instructions === 'P3 role:coordinator', 'Branch must use coordinator definition');
assert((await conversationChains(harness, context)(coordinator.id)).includes(String(ultimate.id)), 'D63 must include ultimate');
if (!probe) {
await wait(async () => Object.keys((await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).results).length === 1, 'No landing');
await wait(async () => (await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context)).queue.length === 0, 'Lander did not report');
}
await harness.waitForIdle(context);
// Routes are background tasks. Idle alone does not prove that they settled without a fault.
while (Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE json_extract(kind, '$') IN ('board.route', 'board.delivery') AND status NOT IN ('terminal', 'faulted')", []).length) {
if (Date.now() > deadline) throw new Error('Branch routing did not settle');
await new Promise(resolve => setTimeout(resolve, 0));
await wait(async () => Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE status NOT IN ('terminal', 'faulted')", []).length === 0, 'Background tasks did not settle');
const entries = (await coordinator.context(context)).entries;
if (probe) assert(received.length === 1 && received[0].data.text === 'branch complete' && !received[0].data.failed, 'Expected one branch answer');
else {
const reports = entries.filter((entry: any) => entry.kind === 'subagents.report');
assert(reports.length === 2 && new Set(reports.map((entry: any) => entry.data.sender)).size === 2, `Expected one report per child: ${JSON.stringify(reports)}`);
for (const answer of ['worker complete', 'approve']) assert(reports.filter((entry: any) => JSON.stringify(entry).includes(answer)).length === 1, `Duplicate child answer: ${answer}`);
const index = await harness.snapshot(globalThis.p3Workspaces.doc, root.id, context);
const worker = Object.values(index.items).find((item: any) => item.source.startsWith('/worktrees/')) as any;
assert(worker?.state === 'removed', 'Worker checkout must be released');
assert(!await fs.stat(worker.path).then(() => true, () => false), 'Released checkout must not exist');
assert(Object.values(index.results).every((result: any) => result.outcome === 'landed'), `Landing failed: ${JSON.stringify(index)}`);
assert((await globalThis.p3Host.eventLog.recent({ events: [{ kind: 'event.workspaces.landed' }] })).length === 1, 'Expected one landed event');
const ultimateEntries = (await ultimate.context(context)).entries;
const landed = Object.values(index.results)[0] as any;
const cards = ultimateEntries.filter((entry: any) => entry.kind === 'board.message' && entry.data.text.startsWith('[lander] Submission') && entry.data.text.includes(' landed as '));
assert(cards.length === 1 && cards[0].data.from === coordinator.id && cards[0].data.text.includes(landed.sha), `Expected one landing card through messages: ${JSON.stringify(cards)}`);
const repliesNow = await replies();
assert(repliesNow.length === 1 && !repliesNow[0].data.failed && new Set(repliesNow.map((entry: any) => entry.data.id)).size === repliesNow.length, `Duplicate board.reply: ${JSON.stringify(repliesNow)}`);
assert(entries.filter((entry: any) => entry.kind === 'workspaces.lander-report').length === 1, 'Expected one lander report');
}
const received = await replies();
assert(received.length === 1 && received[0].data.text === 'branch complete' && !received[0].data.failed, `Expected one coordinator answer: ${JSON.stringify(received)}`);
const branch = (await harness.conversation(received[0].data.from, context))!;
assert((await branch.agent(context)).instructions === 'P3 role:coordinator', 'Branch must use the coordinator definition');
assert((await conversationChains(harness, context)(branch.id)).includes(String(ultimate.id)), 'D63 chain must include ultimate');
const faults = Deno.core.ops.op_sql('all', "SELECT id, record FROM tasks WHERE status = 'faulted'", []);
assert(faults.length === 0, `Faulted branch tasks: ${JSON.stringify(faults)}`);
} else {
if (globalThis.p3Mode === 'kill-worker') {
const coordinator = await globalThis.p3Models.start({ agent: 'coordinator', cwd: '/src/demo' }, context);
await fs.write('/coordinator', String(coordinator.id));
await coordinator.submit({ type: 'input', content: 'begin', requestId: 'p3-begin' }, context);
}
harness.resume();
if (globalThis.p3Mode === 'kill-worker') await new Promise(() => {});
const coordinator = (await harness.conversation(Number(await fs.read('/coordinator')), context))!;
// waitForIdle does not wait for background children. Poll the durable outcome first.
const deadline = Date.now() + 30000;
while (!JSON.stringify((await coordinator.context(context)).entries).includes('scenario complete')) {
if (Date.now() > deadline) throw new Error(`P3 did not finish: ${JSON.stringify((await coordinator.context(context)).entries)}`);
await new Promise(resolve => setTimeout(resolve, 0));
}
await harness.waitForIdle(context);
const entries = (await coordinator.context(context)).entries;
const reports = entries.filter((entry: any) => entry.kind === 'subagents.report');
assert(reports.length === 2, `Expected two child reports: ${JSON.stringify(entries)}`);
assert(new Set(reports.map((report: any) => report.data.sender)).size === 2, 'Expected one report from each child');
for (const answer of ['worker complete', 'approve']) assert(reports.filter((report: any) => JSON.stringify(report).includes(answer)).length === 1, `Expected exactly one report for ${answer}`);
assert(JSON.stringify(entries).includes('scenario complete'), 'Coordinator did not finish');
const faults = Deno.core.ops.op_sql('all', "SELECT id FROM tasks WHERE status = 'faulted'", []);
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
await fs.write('/p3-idle', 'yes');
assert(faults.length === 0, `Faulted tasks: ${JSON.stringify(faults)}`);
}
} finally { await loader.close(); await harness.close(context); }
}
// branch_round_trip covers @ultimate's branch call, one board.reply, D63, and an idle reopen.
// The worker SIGKILL scaffold remains separate until the T52 scenario connects the landing path.
// PENDING T52: worker isolate:worktree, subagent land, workspace submit, and worktree release.
// Kill point 2 (ignored until T52): SIGKILL while the lander check waits for /check-release.
// Restart, release the marker, and run to idle.
// Kill point 3 (ignored until T52): SIGKILL after the target fast-forward log entry.
// Restart and run to idle. Assert one target commit, workspaces.landed event, and landing card.
File diff suppressed because it is too large Load Diff
+86
View File
@@ -0,0 +1,86 @@
/** P4 fixture provider for the real console: all project work uses the installed bash tool. */
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
const main = `fn greeting(name: &str) -> String { format!("hello {name}") }
fn main() {
let Some(name) = std::env::args().nth(1) else { std::process::exit(7) };
println!("{}", serde_json::json!({"greeting": greeting(&name)}));
}
#[test]
fn greeting_test() { assert_eq!(greeting("world"), "hello world", "P4_expected_failure"); }
`;
const write = (path: string, text: string) => `cat > ${path} <<'P4_FILE'\n${text}\nP4_FILE\n`;
const phases = [
`set -e
mkdir -p /src/demo-cli/src /src/demo-node /tmp
cd /src/demo-cli
if test -d target; then cargo clean; fi
${write('Cargo.toml', '[package]\nname="demo-cli"\nversion="0.1.0"\nedition="2024"\n[workspace]\n')}
${write('src/main.rs', main.replace('"hello world", "P4_expected_failure"', '"hello wrong", "P4_expected_failure"'))}
cargo add serde_json
cargo fmt
set +e
cargo test -- --nocapture > /tmp/p4-fail.stdout 2> /tmp/p4-fail.stderr
code=$?
set -e
cat /tmp/p4-fail.stdout /tmp/p4-fail.stderr
test "$code" = 101
case "$(cat /tmp/p4-fail.stderr)" in *P4_expected_failure*) ;; *) exit 1 ;; esac
printf 'P4_PHASE_0_OK\n'`,
`set -e
cd /src/demo-cli
${write('src/main.rs', main)}
cargo fmt
cargo test -- --nocapture
cargo build --release
test -f target/wasm32-wasip2/release/demo-cli.wasm
sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm world' > /tmp/p4-cli.json
case "$(cat /tmp/p4-cli.json)" in *'"greeting":"hello world"'*) ;; *) exit 1 ;; esac
set +e
sh -c '/src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm'
code=$?
set -e
test "$code" = 7
printf 'P4_PHASE_1_OK\n'`,
`set -e
${write('/src/demo-node/package.json', '{"scripts":{"test":"node test.cjs"}}')}
${write('/src/demo-node/test.cjs', 'const fs = require("node:fs");\nrequire("node:assert/strict").equal(2 + 2, 4);\nfs.writeFileSync("result.txt", "P4 npm test passed\\n");')}
id=$(box create --ttl 600)
printf '%s\n' "$id" > /tmp/p4-box-id
trap 'box rm "$id"' EXIT
box put "$id" /src/demo-node /work
box exec "$id" --timeout 120 -- sh -c 'cd /work && npm test'
box get "$id" /work/result.txt /src/demo-node/result.txt
test "$(cat /src/demo-node/result.txt)" = 'P4 npm test passed'
box rm "$id"
trap - EXIT
printf 'P4_PHASE_2_OK\n'`,
`set -e
undo list --conversation 1 > /tmp/p4-undo.json
box ls > /tmp/p4-boxes
printf 'P4_PHASE_3_OK\n'`,
];
export default function p4(host: any) {
// Replace the console's transport before resume. No real model request can occur.
const faux = fauxProvider({ provider: 'openai', models: [{ id: 'gpt-6.1-sol' }], tokensPerSecond: 1000000 });
const respond = (transcript: any): any => {
faux.appendResponses([respond]);
const messages = transcript.messages;
const input = messages.findLast((message: any) => message.role === 'user');
const text = (message: any) => typeof message.content === 'string' ? message.content : (message.content ?? []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n');
const round = /P4 start (\d+)/.exec(text(input))?.[1];
if (!round) throw new Error(`Unexpected P4 input: ${JSON.stringify(input)}`);
const last = messages.findLast((message: any) => message.role !== 'system');
let phase = 0;
if (last.role !== 'user') {
const prior = /p4-\d+-(\d+)/.exec(last.toolCallId)?.[1];
if (last.isError || prior === undefined || !text(last).includes(`P4_PHASE_${prior}_OK`)) throw new Error(`P4 bash failed: ${JSON.stringify(last)}`);
phase = Number(prior) + 1;
}
if (phase === phases.length) return fauxAssistantMessage(`P4_COMPLETE_${round}`);
return fauxAssistantMessage([fauxToolCall('bash', { command: phases[phase], timeout: 300 }, { id: `p4-${round}-${phase}` })], { stopReason: 'toolUse' });
};
faux.setResponses([respond]);
host.models().setProvider(faux.provider);
return { name: 'p4' };
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js" },
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js && esbuild extensions-test.ts --bundle --format=esm --platform=browser --outfile=extensions-test-bundle.js && esbuild events-test.ts --bundle --format=esm --platform=browser --outfile=events-test-bundle.js && esbuild subagents-test.ts --bundle --format=esm --platform=browser --outfile=subagents-test-bundle.js && esbuild core-ext-test.ts --bundle --format=esm --platform=browser --outfile=core-ext-test-bundle.js && esbuild plan-todos-test.ts --bundle --format=esm --platform=browser --outfile=plan-todos-test-bundle.js && esbuild p2-test.ts --bundle --format=esm --platform=browser --outfile=p2-test-bundle.js && esbuild chain-test.ts --bundle --format=esm --platform=browser --outfile=chain-test-bundle.js && esbuild undo-harness-test.ts --bundle --format=esm --platform=browser --outfile=undo-harness-test-bundle.js && esbuild messages-test.ts --bundle --format=esm --platform=browser --outfile=messages-test-bundle.js && esbuild p3-test.ts --bundle --format=esm --platform=browser --outfile=p3-test-bundle.js && esbuild automations-test.ts --bundle --format=esm --platform=browser --outfile=automations-test-bundle.js && esbuild watchdog-test.ts --bundle --format=esm --platform=browser --outfile=watchdog-test-bundle.js && esbuild workspaces-test.ts --bundle --format=esm --platform=browser --outfile=workspaces-test-bundle.js && esbuild goal-questions-test.ts --bundle --format=esm --platform=browser --outfile=goal-questions-test-bundle.js && esbuild dream-test.ts --bundle --format=esm --platform=browser --outfile=dream-test-bundle.js && esbuild p4-test.ts --bundle --format=esm --platform=browser --outfile=p4-test-bundle.js && esbuild harness-api-test.ts --bundle --format=esm --platform=browser --outfile=harness-api-test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
+7 -1
View File
@@ -31602,6 +31602,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -31667,7 +31668,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+338
View File
@@ -0,0 +1,338 @@
/** Ultimate server question cases, with a real world fixture. */
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context'
import {expect,it,describe,afterEach} from './goal-questions-test-support'
import {fixture as openFixture} from './goal-questions-fixture'
const {answerText,userActions} = await import(`${globalThis.goalBase}/questions/server.ts`)
const {checkQuestions,gist} = await import(`${globalThis.goalBase}/messages/questions.ts`)
const hosts:any[]=[]
afterEach(async()=>{for(const h of hosts.splice(0)) await h.close()})
async function fixture(){const f=await openFixture();hosts.push(f.opened);return f}
const decisions = [
{
text: 'Scope',
kind: 'choice',
ref: '1',
options: [
{ label: 'all of it', key: 'a', recommended: true },
{ label: 'the format only', key: 'b' },
],
},
{
text: 'Suites',
kind: 'multi',
ref: '2',
options: [
{ label: 'unit', key: 'a', recommended: true },
{ label: 'e2e', key: 'b' },
{ label: 'lint', key: 'c', recommended: true },
],
},
{ text: 'Land it?', kind: 'confirm', ref: '3' },
{ text: 'How finished is it?', kind: 'scale', ref: '4', scale: { min: 1, max: 5 } },
{ text: 'Name', kind: 'text', ref: '5' },
]
describe('questions on a message', () => {
it('are stored as the tool validated them, only on reply-expected messages to the user', async () => {
const f = await fixture()
const id = await f.ultimate()
const sent = await f.ask(id, { text: 'Decide these.', questions: decisions })
expect(sent.error).toBeUndefined()
expect(sent.message).toMatchObject({ state: 'delivered', questions: decisions })
const oneWay = await f.ask(id, { text: 'FYI', reply: false, questions: decisions })
expect(oneWay.error).toContain('reply-expected message to the user')
expect(oneWay.message?.text).toBe('Decide these.')
// To a conversation, never: questions are the user's card.
const other = await f.opened.harness.createConversation(
{ ownership: { kind: 'ownerless' } },
context,
)
const toConversation = await f.ask(id, { to: `#${other.id}`, text: 'Hi', questions: decisions })
expect(toConversation.error).toContain('reply-expected message to the user')
expect((await f.log()).messages.filter((m) => m.questions)).toHaveLength(1)
})
it('refuses questions the card could not answer, naming the question', async () => {
const f = await fixture()
const id = await f.ultimate()
const refused = async (questions: unknown, reason: string) => {
const { error } = await f.ask(id, { text: 'Decide.', questions })
expect(error).toContain(reason)
}
await refused([], 'at least one question')
await refused(
[{ text: 'One?', kind: 'choice', options: [{ label: 'only' }] }],
'question 1 needs at least two options',
)
await refused(
[{ text: 'One?', kind: 'choice', ref: '3', options: [{ label: 'a' }, { label: ' a ' }] }],
'question 3 repeats an option label',
)
await refused(
[
{
text: 'One?',
kind: 'choice',
options: [
{ label: 'x', key: 'a' },
{ label: 'y', key: 'a' },
],
},
],
'repeats an option key',
)
await refused(
[{ text: 'One?', kind: 'choice', options: [{ label: 'x' }, { label: 'Something else' }] }],
'offers "Something else…" itself',
)
await refused([{ text: ' ', kind: 'confirm' }], 'question 1 has no text')
await refused(
[{ text: 'One?', kind: 'choice', options: [{ label: 'x' }, { label: ' ' }] }],
'has an option without a label',
)
await refused(
[{ text: 'Which?', kind: 'multi', options: [{ label: 'unit, fast' }, { label: 'e2e' }] }],
'its labels hold no ", "',
)
await refused([{ text: 'Rate', kind: 'scale' }], 'needs scale')
await refused([{ text: 'Rate', kind: 'scale', scale: { min: 1, max: 20 } }], 'at most 10 steps')
await refused(
[
{ text: 'Same', kind: 'confirm', ref: '1' },
{ text: 'Same', kind: 'confirm', ref: '2' },
],
"repeats another question's text",
)
await refused(
[
{ text: 'A', kind: 'confirm', ref: '1' },
{ text: 'B', kind: 'confirm', ref: '1' },
],
"repeats another question's ref",
)
// A schema miss is the tool's own error.
await refused([{ text: 'One?', kind: 'ranking' }], 'kind')
expect((await f.log()).messages.filter((m) => m.to === 'user')).toHaveLength(0)
})
it('answers in the message numbering for every kind, with a note after the answers', async () => {
const f = await fixture()
const id = await f.ultimate()
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
expect(userActions).toEqual(['answer', 'dismiss'])
await expect(
f.answer({ messageId: message!.id, answers: [{ question: 'Unknown', answer: 'Yes' }] }),
).rejects.toThrow('Unknown question')
await expect(
f.answer({
messageId: message!.id,
answers: [
{ question: 'Scope', answer: 'all of it' },
{ question: 'Scope', answer: 'the format only' },
],
}),
).rejects.toThrow('Unknown question')
await expect(f.answer({ messageId: message!.id, answers: [] })).rejects.toThrow(
'Invalid answers',
)
const results = await Promise.allSettled([
f.answer({
messageId: message!.id,
answers: [
{ question: 'Scope', answer: 'the format only' },
{ question: 'Suites', answer: 'unit, lint' },
{ question: 'Land it?', answer: 'Yes' },
{ question: 'How finished is it?', answer: '4' },
{ question: 'Name', answer: 'Questions' },
],
note: 'Also rename the tab.',
}),
f.answer({ messageId: message!.id, answers: [{ question: 'Scope', answer: 'all of it' }] }),
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
await f.settled()
const reply = (await f.log()).messages.find((m) => m.re === message!.id)!
expect(reply.text).toBe(
'1b. Scope: the format only\n\n2a,c. Suites: unit, lint\n\n3: Yes\n\n4: 4\n\n5: Questions\n\nNote: Also rename the tab.',
)
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('answered')
if (!f.observed.some((text) => text.includes('2a,c. Suites: unit, lint')))
throw new Error(`Answer absent from model input: ${JSON.stringify({observed:f.observed,entries:await f.entries(id)})}`)
await expect(
f.answer({ messageId: message!.id, answers: [{ question: 'Scope', answer: 'all of it' }] }),
).rejects.toThrow('These questions are no longer open')
})
it('sends a note on its own, and free text for a message without questions', async () => {
const f = await fixture()
const id = await f.ultimate()
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
await expect(f.answer({ messageId: message!.id, answers: [], note: ' ' })).rejects.toThrow(
'Invalid answers',
)
await f.answer({ messageId: message!.id, answers: [], note: 'Later, after the demo.' })
await f.settled()
expect((await f.log()).messages.find((m) => m.re === message!.id)?.text).toBe(
'Later, after the demo.',
)
const plain = (await f.ask(id, { text: 'What should the tab be called?' })).message!
expect(plain.questions).toBeUndefined()
await expect(f.answer({ messageId: plain.id, text: ' ' })).rejects.toThrow('Invalid answers')
await expect(f.answer({ messageId: plain.id, text: 'x', note: 'y' })).rejects.toThrow(
'Invalid answers',
)
const { id: reply } = await f.answer({ messageId: plain.id, text: ' Questions ' })
expect((await f.log()).messages.find((m) => m.id === reply)).toMatchObject({
from: 'user',
re: plain.id,
text: 'Questions',
})
await expect(f.answer({ messageId: plain.id, text: 'Again' })).rejects.toThrow(
'These questions are no longer open',
)
})
it("keeps @ultimate's open message open until its card answers it, naming it by its questions", async () => {
const f = await fixture()
const id = await f.ultimate()
const { message } = await f.ask(id, { text: 'Decide these.', questions: decisions })
await f.type(
f.opened.root.id,
`SEND ${JSON.stringify({ to: `#${id}`, text: 'Unrelated update' })}`,
)
await f.settled()
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('delivered')
await f.type(id, 'Something else entirely')
const typed = await f.entries(id)
const note = typed.findLast((entry) => entry.kind === 'board.open')!
expect(note.data).toEqual({ ids: [message!.id] })
expect(note.model?.[0]?.content).toMatch(
new RegExp(
`^Open questions to the user.*\\n- message ${message!.id}: questions 1\\. Scope; 2\\. Suites; 3\\. Land it\\?; 4\\. How finished is it\\?; 5\\. Name \\(asked just now\\)\\n`,
's',
),
)
expect(typed[typed.indexOf(note) + 1]).toMatchObject({ kind: 'pi.user' })
await f.answer({ messageId: message!.id, answers: [{ question: 'Land it?', answer: 'No' }] })
expect((await f.log()).messages.find((m) => m.id === message!.id)?.state).toBe('answered')
await f.settled()
const count = (await f.entries(id)).filter((entry) => entry.kind === 'board.open').length
await f.type(id, 'Thanks')
expect((await f.entries(id)).filter((entry) => entry.kind === 'board.open')).toHaveLength(count)
})
it('dismisses one open question with a one-way note that starts no run, leaving the others open', async () => {
const f = await fixture()
const id = await f.ultimate()
const first = (await f.ask(id, { text: 'Keep it running?', questions: [decisions[2]] }))
.message!
const second = (await f.ask(id, { text: 'Which layout?' })).message!
const before = (await f.entries(id)).filter((entry) => entry.kind === 'pi.assistant').length
const { id: note } = await f.dismiss(first.id)
await f.settled()
const log = (await f.log()).messages
expect(log.find((m) => m.id === first.id)?.state).toBe('dismissed')
expect(log.find((m) => m.id === second.id)?.state).toBe('delivered')
expect(log.find((m) => m.id === note)).toMatchObject({
from: 'user',
to: id,
re: first.id,
reply: false,
state: 'delivered',
text: expect.stringContaining('(message ' + first.id + '): questions 3. Land it?'),
})
const after = await f.entries(id)
expect(after.findLast((entry) => entry.kind === 'board.message')?.data).toMatchObject({
id: note,
delivery: 'note',
})
expect(after.filter((entry) => entry.kind === 'pi.assistant')).toHaveLength(before)
await expect(f.dismiss(first.id)).rejects.toThrow('These questions are no longer open')
await expect(f.answer({ messageId: first.id, text: 'Yes' })).rejects.toThrow(
'These questions are no longer open',
)
})
})
// The messages.actions.settle case awaits the later actions port. T49 exposes no settlement API.
describe('answer text', () => {
it('names options by letter, several by letters, typed and rated answers by number, and unnumbered ones by text', () => {
expect(
answerText(
[
{ question: 'Scope', answer: 'all of it' },
{ question: 'Suites', answer: 'e2e' },
{ question: 'Name', answer: 'only the keyboard one' },
],
decisions,
),
).toBe('1a. Scope: all of it\n\n2b. Suites: e2e\n\n5: only the keyboard one')
// A typed "Something else…" names no option: the number alone.
expect(answerText([{ question: 'Scope', answer: 'neither' }], decisions)).toBe('1: neither')
expect(
answerText(
[{ question: 'Land it?', answer: 'Yes' }],
[{ text: 'Land it?', kind: 'confirm' }],
),
).toBe('Land it?: Yes')
expect(answerText([], decisions, 'Just a thought.')).toBe('Just a thought.')
})
it('drops fields that do not apply to a kind and trims what it keeps', () => {
expect(
checkQuestions([
{
text: ' Land it? ',
kind: 'confirm',
options: [{ label: 'x' }, { label: 'y' }],
placeholder: 'no',
},
{
text: 'Rate',
kind: 'scale',
scale: { min: 1, max: 3, low: ' low ', high: '' },
placeholder: 'no',
},
{ text: 'Name', kind: 'text', placeholder: ' e.g. Questions ' },
{
text: 'Pick',
kind: 'choice',
options: [
{ label: ' a ', detail: ' why ', recommended: false, preview: ' ' },
{ label: 'b', key: 'b', recommended: true },
],
},
]),
).toEqual([
{ text: 'Land it?', kind: 'confirm' },
{ text: 'Rate', kind: 'scale', scale: { min: 1, max: 3, low: 'low' } },
{ text: 'Name', kind: 'text', placeholder: 'e.g. Questions' },
{
text: 'Pick',
kind: 'choice',
options: [
{ label: 'a', detail: 'why' },
{ label: 'b', key: 'b', recommended: true },
],
},
])
// Only a whole "Other" / "Something else" is the card's own option; a label that starts so is a real one.
expect(
checkQuestions([
{
text: 'Scope',
kind: 'choice',
options: [{ label: 'This repo' }, { label: 'Other repos too' }],
},
])[0].options!.map((o) => o.label),
).toEqual(['This repo', 'Other repos too'])
})
it('names a message by its questions in the open-questions note, else by its asking line', () => {
expect(gist({ text: 'Decide.', questions: decisions })).toBe(
'questions 1. Scope; 2. Suites; 3. Land it?; 4. How finished is it?; 5. Name',
)
expect(gist({ text: 'Done.\n\nShould I submit it?' })).toBe('Should I submit it?')
})
})
+7 -2
View File
@@ -1,7 +1,7 @@
/** Stateless role/input rules for deterministic faux-model replay in integration tests. */
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
export type Answer = { text?: string; tools?: { name: string; args: any }[] };
export type Rule = { id: string; role: string; match: { kind?: string; text: string }; answer: Answer };
export type Rule = { id: string; role: string; match: { kind?: string; text: string }; answer: Answer | ((transcript: any) => Answer) };
export const textOf = (content: any): string => typeof content === 'string' ? content : (content ?? []).filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n');
export function answerFor(rules: Rule[], transcript: any) {
const system = JSON.stringify(transcript.messages.filter((message: any) => message.role === 'system'));
@@ -9,7 +9,7 @@ export function answerFor(rules: Rule[], transcript: any) {
const last = transcript.messages.findLast((message: any) => message.role !== 'system');
const rule = rules.find(rule => rule.role === role && (!rule.match.kind || rule.match.kind === last?.role) && textOf(last?.content).includes(rule.match.text));
if (!rule) throw new Error(`No scripted rule for ${role}: ${JSON.stringify(last)}`);
return { rule: rule.id, answer: rule.answer };
return { rule: rule.id, answer: typeof rule.answer === 'function' ? rule.answer(transcript) : rule.answer };
}
export function scriptedModel(rules: Rule[], report: (rule: string) => void) {
const faux = fauxProvider({ tokensPerSecond: 100000 });
@@ -33,6 +33,11 @@ export function testReplay() {
const first = answerFor(rules, { messages: [system, input] });
const replay = answerFor(JSON.parse(JSON.stringify(rules)), { messages: [system, { role: 'assistant', content: [] }, input] });
if (JSON.stringify(first) !== JSON.stringify(replay)) throw new Error('Scripted replay changed the answer');
const dynamic: Rule[] = [{ id: 'land', role: 'worker', match: { text: 'build' }, answer: transcript => ({ tools: [{ name: 'land', args: { id: /Started subagent (\d+)/.exec(JSON.stringify(transcript.messages))?.[1] } }] }) }];
const history = { role: 'toolResult', content: 'Started subagent 42.' };
const selected = answerFor(dynamic, { messages: [system, history, input] });
const restarted = answerFor(dynamic, { messages: [system, { role: 'assistant', content: [] }, history, input] });
if (JSON.stringify(selected) !== JSON.stringify(restarted) || selected.answer.tools?.[0].args.id !== '42') throw new Error('Dynamic tool identity changed on replay');
const toolRules: Rule[] = [{ id: 'worker-done', role: 'worker', match: { text: 'committed' }, answer: { text: 'done' } }];
if (answerFor(toolRules, { messages: [system, { role: 'toolResult', content: [{ type: 'text', text: 'committed' }] }] }).rule !== 'worker-done') throw new Error('Tool result rule did not match');
}
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
+7 -1
View File
@@ -30662,6 +30662,7 @@ async function loadExtensions(harness2, registry2, storage2, models2, context, b
for (const { name, module } of modules) {
const facet = scope(name, staged);
next.push(facet);
facet.actions = Object.fromEntries((module.userActions ?? []).filter((key) => typeof module.actions?.[key] === "function").map((key) => [key, module.actions[key]]));
facet.extension = typeof module.default === "function" ? await module.default(facet.host) : module.default;
if (!facet.extension || facet.extension.name !== name) throw new Error(`Extension name must match ${name}`);
}
@@ -30727,7 +30728,12 @@ Last good: ${fallback}` : failed;
if (active) await enqueue2();
}
})();
return { reload: enqueue2, state: () => ({ ...state }), close: async () => {
return { reload: enqueue2, state: () => ({ ...state }), async invoke(name, key, args, signal, conversationId) {
const facet = facets.find((f) => f.extension.name === name);
const action = facet?.actions?.[key];
if (!action) throw Object.assign(new Error("Unknown user action"), { status: 404 });
return action(args, { host: facet.host, signal, conversationId, source: "ui" });
}, close: async () => {
active = false;
Deno.core.close(rid);
await pending;
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
/** Scripted acceptance cases run through the world extension loader and real guest Git. */
import { BACKGROUND_CONTEXT as context, awaitWithContext } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
import { Harness, createRegistry, defineExtension, defineTool } from '@earendil-works/pi-durable';
import { obj, str } from '@earendil-works/pi-schema';
import { loadExtensions, fs } from './extensions';
import { openStorage } from './sqlite';
const assert=(value:any,message:string)=>{if(!value)throw new Error(message)};
const models=createModels(),faux=fauxProvider({tokensPerSecond:100000});models.setProvider(faux.provider);
const registry=createRegistry(),storage=await openStorage(globalThis.storagePath);
const harness=await Harness.open(storage,{models,registry,settings:{get extensions(){return registry.snapshot().installed()}}},context);
const root=await harness.root(context,{agent:{model:{provider:'faux',modelId:'faux-1'},cwd:'/project'}});
if(globalThis.workspacesMode==='kill') {
const install=registry.install.bind(registry);
registry.install=(extension:any)=>install(extension.name!=='workspaces'?extension:{...extension,tasks:extension.tasks.map((task:any)=>task.definition.name!=='workspaces.lander'?task:{definition:{...task.definition,phases:{...task.definition.phases,land:async(record:any,runtime:any,ctx:any)=>{
const commit=runtime.commit.bind(runtime);
await taskDefinition(record,runtime,ctx);
async function taskDefinition(record:any,rt:any,c:any){
await task.definition.phases.land(record,{...rt,commit:async(...args:any[])=>{
await commit(...args);
const index=await harness.snapshot(globalThis.workspaceService.doc,root.id,context);
if(index.queue.some((entry:any)=>entry.landed)) {
await fs.write('/push-ready','yes');await awaitWithContext(new Promise(()=>{}),c);
}
}},c);
}
}}}})});
}
const probe=defineExtension({name:'guest',tools:[defineTool({name:'implement',description:'Write and commit in the prepared checkout',parameters:obj({name:str()}),replay:'safe',execute:async(args:any,api:any,ctx:any)=>{
const cwd=(await api.agent(ctx)).cwd;
assert(cwd.startsWith('/worktrees/'),'First tool needs isolated cwd');
assert((await fs.stat(`${cwd}/base.txt`)).kind==='file','Checkout must exist before first tool');
const workspace=await globalThis.workspaceService.get(api.conversationId,ctx);
assert(workspace.state==='ready','Preparation must finish');
await fs.write(`${cwd}/${args.name}`,'feature');
for(const args of [['add','.'],['-c','user.name=Test','-c','user.email=test@example.com','commit','--no-gpg-sign','-m','feature']])await globalThis.workspaceHost.git(cwd,...args);
return {output:[{type:'text',text:'committed'}]};
}})]});
const route=(transcript:any):any=>{
faux.appendResponses([route]);
const last=transcript.messages.findLast((message:any)=>message.role!=='system');
const text=typeof last.content==='string'?last.content:last.content.filter((part:any)=>part.type==='text').map((part:any)=>part.text).join('');
if(last.role==='user'&&text.startsWith('CALL ')){const {tool,args}=JSON.parse(text.slice(5));return fauxAssistantMessage([fauxToolCall(tool,args)],{stopReason:'toolUse'});}
if(last.role==='user'&&text==='IMPLEMENT')return fauxAssistantMessage([fauxToolCall('implement',{name:'feature.txt'})],{stopReason:'toolUse'});
if(last.role==='user'&&text==='BROKEN')return fauxAssistantMessage([fauxToolCall('implement',{name:'broken.txt'})],{stopReason:'toolUse'});
return fauxAssistantMessage('Done');
};faux.setResponses([route]);
const loader=await loadExtensions(harness,registry,storage,models,context,[probe]);
assert(!loader.state().error,`Load: ${loader.state().error}`);
const service=globalThis.workspaceService,host=globalThis.workspaceHost;
const call=async(tool:string,args:any)=>{
await(await root.submit({type:'input',content:`CALL ${JSON.stringify({tool,args})}`},context)).wait(context);
const result=(await root.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
assert(!result?.isError,`Tool error: ${JSON.stringify(result)}`);
return result;
};
const submit=async(id:number)=>{
const {Started}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/messages/addresses.ts`);
await harness.commit(async(tx:any)=>{(await tx.doc(Started,id)).by=root.id},context);
const child=await harness.conversation(id,context);
await(await child.submit({type:'input',content:`CALL ${JSON.stringify({tool:'workspace',args:{action:'submit',divergence:'None'}})}`},context)).wait(context);
const result=(await child.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
assert(!result?.isError,`Workspace tool: ${JSON.stringify(result)}`);
return JSON.parse(result.content.filter((part:any)=>part.type==='text').map((part:any)=>part.text).join(''));
};
const children=async()=>{
const {Subagents}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/subagents/api.ts`);
return Object.values((await harness.snapshot(Subagents,root.id,context)).agents) as any[];
};
const waitQueue=async()=>{
const deadline=Date.now()+30000;
while((await harness.snapshot(service.doc,root.id,context)).queue.length) {
if(Date.now()>deadline)throw new Error(`Lander deadline: ${JSON.stringify(await harness.snapshot(service.doc,root.id,context))}`);
await new Promise(r=>setTimeout(r,0));
}
await harness.waitForIdle(context);
};
const reports=async(id:number)=>(await(await harness.conversation(id,context)).context(context)).entries.filter((entry:any)=>entry.kind==='workspaces.lander-report');
const notes=async()=>{
const {Messages}=await import(`file:///run/sausage/extensions/${await fs.read('/src/sausage/.git/sausage-last-good')}/extensions/messages/api.ts`);
const deadline=Date.now()+30000;
for(;;) {
const notes=((await harness.snapshot(Messages,root.id,context)).messages as any[]).filter(message=>message.text.startsWith('[lander] Submission')&&message.text.includes(' landed as '));
if(!notes.some(note=>note.state==='sent'))return notes;
assert(Date.now()<deadline,`Landing note delivery: ${JSON.stringify(notes)}`);
await new Promise(r=>setTimeout(r,0));
}
};
harness.resume();
try {
if(globalThis.workspacesMode==='resume') {
await waitQueue();
const index=await harness.snapshot(service.doc,root.id,context);
const result=Object.values(index.results)[0] as any;
assert(result?.outcome==='landed',`Recovery result ${JSON.stringify(index)}`);
const [child]=await children();
const report=await reports(child.conversationId);
assert(report.length===1,'Restart must report once');
assert(report[0].data.sha===result.sha,'Report must retain landed.sha');
const delivered=await notes();
assert(delivered.length===1&&delivered[0].state==='delivered'&&delivered[0].text.includes(result.sha),`Restart must notify the starter once with landed.sha: ${JSON.stringify(delivered)}`);
assert(await host.git('/remote.git','rev-parse','main')===result.sha,`Push must retain landed.sha: ${JSON.stringify(result)}`);
assert((await host.git('/project','log','--format=%s')).split('\n').filter((line:string)=>line.startsWith('Merge from')).length===1,'Restart must not merge again');
assert((await host.eventLog.recent({events:[{kind:'event.workspaces.landed'}]})).length===1,'Restart must emit once');
} else {
await call('subagent',{action:'start',agent:'worker',task:globalThis.workspacesMode==='normal'?'BROKEN':'IMPLEMENT'});
if(globalThis.workspacesMode==='normal') {
const [broken]=await children();
const before=globalThis.workspaceExecutions.length;
assert(!await service.unavailable(broken.conversationId,context),'Ready VFS gate');
assert(globalThis.workspaceExecutions.length===before,'Ready gate must not spawn Git');
const checkout=await service.get(broken.conversationId,context);
await fs.rename(`${checkout.path}/.git`,`${checkout.path}/.git.saved`);
assert((await service.unavailable(broken.conversationId,context)).includes('missing or unregistered'),'Gate must reject missing registration');
const blocked=await harness.conversation(broken.conversationId,context);
await(await blocked.submit({type:'input',content:`CALL ${JSON.stringify({tool:'implement',args:{name:'guard.txt'}})}`},context)).wait(context);
const blockedResult=(await blocked.context(context)).entries.filter((entry:any)=>entry.kind==='pi.tool-result').at(-1)?.model?.[0] as any;
assert(blockedResult.isError&&JSON.stringify(blockedResult).includes('missing or unregistered'),'beforeTool must block the guest tool');
assert(!await fs.stat(`${checkout.path}/guard.txt`).then(()=>true,(error:any)=>{if(error.code==='not_found')return false;throw error}),'Blocked tool must not write');
await fs.rename(`${checkout.path}/.git.saved`,`${checkout.path}/.git`);
assert(globalThis.workspaceExecutions.length===before,'Failed VFS gate must not spawn Git');
const failed=await submit(broken.conversationId);
await waitQueue();
const result=(await harness.snapshot(service.doc,root.id,context)).results[failed.id];
assert(result.outcome==='failed'&&result.reason.includes('Check sh'),`Check must fail: ${JSON.stringify(result)}`);
assert((await reports(broken.conversationId)).length===1,'Failed check must return to submitter');
const failedWorkspace=await service.get(broken.conversationId,context);
assert((await service.release([broken.conversationId],context)).length===1,'Release must retain unlanded work');
await call('subagent',{action:'discard',id:broken.conversationId});
assert(await host.git('/project','rev-parse',failedWorkspace.branch),'Discard must retain its branch');
await call('subagent',{action:'start',agent:'worker',task:'IMPLEMENT'});
const child=(await children()).at(-1);
await call('subagent',{action:'land',id:child.conversationId});
assert(await fs.read('/project/feature.txt')==='feature','Parent fast-forward');
assert((await service.get(child.conversationId,context)).state==='removed','Land must release checkout');
await call('subagent',{action:'start',agent:'worker',task:'BROKEN'});
}
const submitter=(await children()).at(-1).conversationId;
const submission=await submit(submitter);
if(globalThis.workspacesMode==='kill')await new Promise(()=>{});
await waitQueue();
const result=(await harness.snapshot(service.doc,root.id,context)).results[submission.id];
// feature.txt came from synchronous landing, so this submission passes its real shell check too.
assert(result.outcome==='landed',`Landing failed: ${JSON.stringify(result)}`);
assert(await host.git('/remote.git','rev-parse','main')===result.sha,`Local bare push: ${JSON.stringify(result)}`);
assert((await reports(submitter)).length===1,'Submission report');
const delivered=await notes();
assert(delivered.length===1&&delivered[0].state==='delivered'&&delivered[0].from===submitter&&delivered[0].to===root.id&&delivered[0].reply===false&&delivered[0].text.includes(result.sha),`Landing note must use the messages API: ${JSON.stringify(delivered)}`);
const commands=globalThis.workspaceExecutions.filter((entry:any)=>typeof entry.command==='string'||entry.command[1]==='fetch'||entry.command[1]==='push');
const push=commands.findLast((entry:any)=>entry.command[1]==='push');
assert(push,'Real push must run');
assert(JSON.stringify(push.options.conversations)===JSON.stringify([String(submitter),String(root.id)]),'Push must use the exact submitter chain');
for(const entry of commands) {
assert(entry.options.task!==undefined,'Lander exec needs its task tag');
if(typeof entry.command==='string')assert(entry.options.timeout===600,'Checks need a bounded default timeout');
assert(entry.options.conversations[1]===String(root.id),'Lander exec needs the submitter ancestor');
assert(entry.options.conversations[0]!==String(root.id),'Lander exec needs the submitter first');
}
}
}finally{await loader.close();await harness.close(context)}
+36 -2
View File
@@ -4,8 +4,8 @@
The `wasi:http` hook supplies the caller from the process table.
It must not accept caller identity from headers, environment variables, or JSON.
Reads are open to every caller.
JSON routes return 200 on success.
Errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Credential JSON routes return 200 on success.
Credential errors use `{"error":"kind","message":"kind"}` with static, secret-free text.
Missing routes return 404. Invalid parameters return 400. Authorization failures return 403.
Conflicts return 409. The filing budget returns 429.
@@ -65,6 +65,40 @@ Explicit requests, automatic SSH requests, and new ceremonies share a per-proces
The budget permits 32 attempts per 60-second window.
Reads and granted SSH operations do not spend that budget.
## Harness routes in the world
The WASI transport dispatches buffered and streaming responses through one route table.
Harness routes use `http://host.sausage/harness/` and the console's input channel.
The host listener does not expose these routes.
The V8 harness answers queries and invokes actions without a second storage reader.
- `GET /harness/conversations`: `{conversations:[...]}` with records, titles, agents, starters, concluded state, and running state.
Records retain their owner and parent references.
- `GET /harness/conversations/{id}/entries?after=0`: `{entries:[...]}` in ascending entry order.
- `GET /harness/conversations/{id}/stream?after=0`: newline-delimited entry records, then new entries as commits arrive.
- `POST /harness/conversations/{id}/submit`: `{content:"text",requestId:"stable-id"}`.
The response is 202 with `{submissionId:number}`.
Duplicate request IDs use pi-durable's existing idempotent admission.
- `POST /harness/conversations/{id}/stop`: abort the conversation's active turns, including their owned work.
The response is `{ok:true}`. Queued inputs remain.
- `GET /harness/events?after=0`: newline-delimited T43 event records in ascending event order.
- `POST /harness/extensions/{name}/actions/{action}`: `{args:{...},conversationId?:number}`.
Only names in the extension's `userActions` export are callable.
The response is `{result:...}`. Disconnect aborts the action's signal.
- `GET /harness/models`: `{models:[...]}` with public catalog fields, without provider authentication data.
- `GET /harness/families`: `{families:{...}}` from the world's model definitions.
- `GET /harness/extensions`: the loaded extension status, also visible through `/proc/extensions`.
- `GET /harness/credential-requests`: `{requests:[...]}` with pending conversation requests and host approval links.
Approval stays on the host page.
The `after` cursor is an exclusive entry ID. Streams replay retained records before live records.
Commit observation starts before replay, so a concurrent commit cannot create a gap.
Clients resume from the last record ID after a disconnect.
Disconnect closes the observation. Harness shutdown closes all streams.
Responses use JSON, except streams use `application/x-ndjson`.
Invalid inputs return 400. Missing routes, conversations, and actions return 404.
An unavailable harness returns 503. Action failures return 500 with the action's error message.
## Decisions
- `POST /cred/requests/{id}/challenge`: `{"decision":"approve","overrides":{...}}`.
+99
View File
@@ -0,0 +1,99 @@
//! Route harness requests into its V8 inbox and preserve incremental NDJSON replies.
use bytes::Bytes;
use http::{Request, Response};
use http_body_util::{BodyExt, Full};
use serde_json::{Value, json};
use std::{
pin::Pin,
task::{Context, Poll},
};
use tokio::sync::mpsc;
struct Body(mpsc::UnboundedReceiver<Value>);
impl http_body::Body for Body {
type Data = Bytes;
type Error = wasmtime_wasi_http::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<http_body::Frame<Bytes>, Self::Error>>> {
self.0.poll_recv(cx).map(|value| {
value.map(|value| {
let mut bytes = serde_json::to_vec(&value).expect("harness JSON");
bytes.push(b'\n');
Ok(http_body::Frame::data(Bytes::from(bytes)))
})
})
}
}
fn json_response(status: u16, value: Value) -> Response<wasmtime_wasi_http::WasiBody> {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(
Full::new(Bytes::from(value.to_string()))
.map_err(|never| match never {})
.boxed_unsync(),
)
.unwrap()
}
pub(super) async fn handle(
api: &super::Api,
caller: crate::cred::Caller,
mut request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
if request.method() == http::Method::GET
&& request.uri().path() == "/harness/credential-requests"
{
*request.uri_mut() = "/cred/requests?state=pending".parse().unwrap();
let response = super::handle(api, caller, request).await;
let mut value: Value = serde_json::from_slice(response.body()).expect("credential JSON");
if let Some(requests) = value.as_array_mut() {
for request in requests {
request["approvalUrl"] = json!(format!(
"{}/approve/{}",
api.approval_origin,
request["id"].as_str().unwrap()
));
}
value = json!({"requests":value});
}
return json_response(response.status().as_u16(), value);
}
let sender = api.harness.lock().unwrap().clone();
let Some(sender) = sender else {
return json_response(503, json!({"error":"harness_unavailable"}));
};
let body = if request.body().is_empty() {
Value::Null
} else {
match serde_json::from_slice(request.body()) {
Ok(body) => body,
Err(_) => return json_response(400, json!({"error":"invalid_json"})),
}
};
let (reply, mut receiver) = mpsc::unbounded_channel();
let message = crate::harness::input::Input::Api {
id: uuid::Uuid::new_v4().to_string(),
method: request.method().to_string(),
path: request.uri().path_and_query().unwrap().to_string(),
body,
reply,
};
if sender.send(message).is_err() {
return json_response(503, json!({"error":"harness_unavailable"}));
}
let Some(first) = receiver.recv().await else {
return json_response(503, json!({"error":"harness_stopped"}));
};
let status = first["status"].as_u64().unwrap_or(500) as u16;
if first["stream"] == true {
Response::builder()
.status(status)
.header("content-type", "application/x-ndjson")
.body(Body(receiver).boxed_unsync())
.unwrap()
} else {
json_response(status, first["body"].clone())
}
}
+45 -3
View File
@@ -2,7 +2,39 @@
//! Host-owned pages use passkeys and typed proofs for every authorization ceremony.
mod auth;
mod cargo;
pub(crate) use cargo::stream;
mod harness;
/// Dispatch body-producing routes before the buffered credential API.
pub(crate) async fn dispatch(
api: &Api,
caller: Caller,
request: Request<Bytes>,
) -> Response<wasmtime_wasi_http::WasiBody> {
use http_body_util::{BodyExt, Full};
enum Handler {
Cargo,
Harness,
}
let routes = [("/cargo", Handler::Cargo), ("/harness/", Handler::Harness)];
for (path, handler) in routes {
if (path.ends_with('/') && request.uri().path().starts_with(path))
|| request.uri().path() == path
{
match handler {
Handler::Cargo if request.method() == http::Method::POST => {
return cargo::stream(api, request);
}
Handler::Harness => return harness::handle(api, caller, request).await,
_ => break,
}
}
}
handle(api, caller, request).await.map(|body| {
Full::new(body)
.map_err(|never| match never {})
.boxed_unsync()
})
}
mod pages;
mod server;
mod ssh;
@@ -22,6 +54,8 @@ use std::{
pub struct Api {
pub cred: Arc<Cred>,
harness: Mutex<Option<crate::harness::input::Sender>>,
approval_origin: String,
pub(crate) builder: std::sync::OnceLock<Arc<crate::builder::Builder>>,
script: cred::quickjs::QuickJs,
pub passkeys: Mutex<Passkeys<rusqlite::Connection>>,
@@ -38,11 +72,13 @@ impl Api {
origin: &str,
) -> anyhow::Result<Self> {
let db = rusqlite::Connection::open(host_dir.join("cred.sqlite"))?;
Ok(Self::new(
let mut api = Self::new(
cred,
Passkeys::new(db, rp_id, origin)?,
EntryKey::open(&host_dir.join("entry.key"))?,
))
);
api.approval_origin = origin.trim_end_matches('/').to_owned();
Ok(api)
}
/// Passkeys owns the RP configuration. Its database must remain host-only.
pub fn new(
@@ -52,6 +88,8 @@ impl Api {
) -> Self {
Self {
cred: cred.into(),
harness: Mutex::new(None),
approval_origin: String::new(),
builder: Default::default(),
script: cred::quickjs::QuickJs::default(),
passkeys: Mutex::new(passkeys),
@@ -60,6 +98,10 @@ impl Api {
journal: Mutex::new(None),
}
}
/// Attach the same inbox that console submissions use.
pub fn attach_harness(&self, sender: crate::harness::input::Sender) {
*self.harness.lock().unwrap() = Some(sender);
}
pub(crate) fn attach_journal(&self, journal: Option<Arc<sausage_vfs::journal::Journal>>) {
*self.journal.lock().unwrap() = journal;
}
+9 -1
View File
@@ -120,6 +120,8 @@ pub async fn run_with_userland(
crate::process::default_script(),
Some(api.clone()),
)?;
let (sender, receiver) = crate::harness::input::channel();
api.attach_harness(sender.clone());
let listener = Listener::start_with_api(api, options)?;
runtime
.network_policy()
@@ -129,7 +131,13 @@ pub async fn run_with_userland(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(crate::harness::run_console(world, Arc::new(runtime), cred))
.block_on(crate::harness::run_console_with_input(
world,
Arc::new(runtime),
cred,
sender,
receiver,
))
})
.await
.map_err(anyhow::Error::from)
+2 -2
View File
@@ -63,6 +63,6 @@ It does not depend on a user-profile path or a host HOME file.
Development commands retain the global Cargo configuration and its kache wrapper.
The sandbox uses a private Cargo home and selects kache through `--config build.rustc-wrapper`.
For `cargo add`, the network phase performs a dry run to populate the registry index.
The offline command makes the actual manifest change inside the namespaces.
For `cargo add`, the network phase updates the private source copy and fetches the new dependencies, including transitive dependencies.
The command then repeats offline inside the namespaces before source publication.
The disk cap applies after each completed command, including compiler failures.
+10 -1
View File
@@ -134,7 +134,8 @@ impl Builder {
};
let mut fetch = Command::new("cargo");
if action == "add" {
fetch.args(&request.argv).arg("--dry-run");
// Resolve the new manifest before fetch, including transitive dependencies.
fetch.args(&request.argv);
} else {
fetch.arg("fetch");
}
@@ -144,6 +145,14 @@ impl Builder {
} else {
run(fetch, tx, deadline).await?.0
};
if code == 0 && action == "add" {
let mut fetch = Command::new("cargo");
fetch
.arg("fetch")
.current_dir(&cwd)
.env("CARGO_HOME", &registry);
code = run(fetch, tx, deadline).await?.0;
}
let mut executables = Vec::new();
if code == 0 {
let tools = base.join("tools");
+42
View File
@@ -0,0 +1,42 @@
//! HTTP requests share the harness inbox. Replies never leave the V8 thread as JS values.
use deno_core::{OpState, op2};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use tokio::sync::mpsc::UnboundedSender;
pub(super) struct Reply {
pub sender: UnboundedSender<Value>,
pub finished: std::sync::Arc<tokio::sync::Notify>,
}
pub(super) type Replies = HashMap<String, Reply>;
#[op2]
pub fn op_harness_reply(
state: &mut OpState,
#[string] id: String,
#[serde] value: serde_json::Value,
) {
let replies = state.borrow_mut::<Replies>();
if value.is_null() {
if let Some(reply) = replies.remove(&id) {
reply.finished.notify_one();
}
} else if let Some(reply) = replies.get(&id) {
let _ = reply.sender.send(value);
}
}
#[op2]
pub async fn op_harness_closed(state: Rc<RefCell<OpState>>, #[string] id: String) {
let reply = state
.borrow()
.borrow::<Replies>()
.get(&id)
.map(|reply| (reply.sender.clone(), reply.finished.clone()));
if let Some((sender, finished)) = reply {
tokio::select! {
_ = sender.closed() => {},
_ = finished.notified() => {},
}
}
state.borrow_mut().borrow_mut::<Replies>().remove(&id);
}
+22 -2
View File
@@ -15,6 +15,14 @@ pub enum Input {
#[serde(rename = "requestId")]
request_id: String,
},
Api {
id: String,
method: String,
path: String,
body: Value,
#[serde(skip)]
reply: mpsc::UnboundedSender<Value>,
},
Stop,
}
@@ -31,8 +39,20 @@ pub(super) struct Inbox(pub tokio::sync::Mutex<Receiver>);
#[serde]
pub async fn op_input(state: Rc<RefCell<OpState>>) -> Result<serde_json::Value, JsErrorBox> {
let inbox = state.borrow().borrow::<Rc<Inbox>>().clone();
serde_json::to_value(inbox.0.lock().await.recv().await)
.map_err(|error| JsErrorBox::generic(error.to_string()))
let input = inbox.0.lock().await.recv().await;
if let Some(Input::Api { id, reply, .. }) = &input {
state
.borrow_mut()
.borrow_mut::<super::http::Replies>()
.insert(
id.clone(),
super::http::Reply {
sender: reply.clone(),
finished: Default::default(),
},
);
}
serde_json::to_value(input).map_err(|error| JsErrorBox::generic(error.to_string()))
}
pub(super) fn console(sender: Sender) {
+18
View File
@@ -1,6 +1,7 @@
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod credentials;
mod environment;
mod http;
pub mod input;
pub(crate) mod loader;
use crate::{exec::Exec, world::World};
@@ -88,6 +89,8 @@ deno_core::extension!(
environment::op_exec,
environment::op_exec_next,
input::op_input,
http::op_harness_reply,
http::op_harness_closed,
environment::op_watch,
environment::op_watch_next,
loader::op_modules,
@@ -181,6 +184,10 @@ pub async fn run_module_with_input(
..Default::default()
});
runtime.op_state().borrow_mut().put(loader);
runtime
.op_state()
.borrow_mut()
.put(http::Replies::default());
runtime
.op_state()
.borrow_mut()
@@ -252,6 +259,17 @@ pub async fn run_console(
cred: Arc<crate::cred::store::Cred>,
) -> anyhow::Result<()> {
let (sender, receiver) = input::channel();
run_console_with_input(world, exec, cred, sender, receiver).await
}
/// Run the console with the inbox shared by the host HTTP API.
pub async fn run_console_with_input(
world: World,
exec: Arc<dyn Exec>,
cred: Arc<crate::cred::store::Cred>,
sender: input::Sender,
receiver: input::Receiver,
) -> anyhow::Result<()> {
input::console(sender.clone());
let signals = tokio::spawn(input::stop_signal(sender)?);
let source = concat!(
+4 -7
View File
@@ -46,13 +46,10 @@ impl WasiHttpHooks for Hooks {
{
let bytes = body.collect().await?.to_bytes();
let request = Request::from_parts(parts, bytes);
if request.method() == http::Method::POST && request.uri().path() == "/cargo" {
return Ok((
crate::api::stream(&router, request),
Box::new(async { Ok(()) }) as Io,
));
}
return Ok(response(crate::api::handle(&router, caller, request).await));
return Ok((
crate::api::dispatch(&router, caller, request).await,
Box::new(async { Ok(()) }) as Io,
));
}
if !matches!(parts.uri.scheme_str(), Some("http" | "https")) {
while let Some(frame) = body.frame().await {
+131
View File
@@ -0,0 +1,131 @@
//! World-loaded dream built-ins and messages survive a harness reopen.
use sausage::{harness::run_module, world::World};
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/dream-test-bundle.js");
fn git(repo: &Path, args: &[&str]) -> anyhow::Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.output()?;
anyhow::ensure!(
output.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok(String::from_utf8(output.stdout)?.trim().into())
}
fn copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
std::fs::copy(entry.path(), target)?;
}
}
Ok(())
}
fn prepare(dir: &Path) -> anyhow::Result<()> {
World::open(dir)?;
let disk = dir.join("world");
let repo = disk.join("src/sausage");
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
anyhow::ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd-init build failed"
);
}
anyhow::ensure!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success(),
"world git build failed"
);
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
copy_tree(&source.join("extensions"), &repo.join("extensions"))?;
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.dreamHost=host; globalThis.dreamService=host.need({name:'dream'}); globalThis.dreamSubagents=host.need({name:'subagents'}); globalThis.dreamAutomations=host.need({name:'automations'}); return {name:'probe'}; }",
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"},"fast":{"model":"faux/faux-1"}}"#,
)?;
for role in ["ultimate", "coordinator", "worker", "reviewer"] {
let denial = if role == "worker" || role == "reviewer" {
"denyTools: [subagent]\n"
} else {
""
};
std::fs::write(
repo.join(format!("agents/{role}.md")),
format!(
"---\nname: {role}\ndescription: P3 {role}\nisolate: none\n{denial}{}---\nP3 role:{role}\n",
if role == "ultimate" {
"resident: true\n"
} else {
""
}
),
)?;
}
std::fs::copy(
source.join("agents/dream-pass.md"),
repo.join("agents/dream-pass.md"),
)?;
let demo = disk.join("src/demo");
std::fs::create_dir_all(&demo)?;
std::fs::write(demo.join("README.md"), "P3 demo\n")?;
for repo in [&repo, &demo] {
git(repo, &["init", "-b", "main"])?;
git(repo, &["add", "."])?;
git(repo, &["commit", "--no-gpg-sign", "-m", "P3 fixture"])?;
}
Ok(())
}
async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> {
let world = World::open(dir)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
run_module(
world,
exec,
&format!("globalThis.dreamMode={mode:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn builtins_fire_and_report_across_reopen() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
prepare(dir.path())?;
run(dir.path(), "first").await?;
run(dir.path(), "reopen").await
}
+126
View File
@@ -0,0 +1,126 @@
//! Goal and question-card server cases execute through the world loader.
use sausage::{harness::run_module, world::World};
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/goal-questions-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
let disk = dir.join("world");
let repo = disk.join("src/sausage");
if repo.join(".git").exists() {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
"extensions/core",
"agents",
] {
std::fs::create_dir_all(repo.join(directory))?;
}
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
] {
for entry in std::fs::read_dir(source.join(directory))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?;
}
}
}
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
)?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
std::fs::write(
repo.join("agents/scout.md"),
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
)?;
std::fs::write(
repo.join("agents/ultimate.md"),
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
)?;
std::fs::write(
repo.join("agents/coordinator.md"),
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
)?;
for args in [
vec!["init", "-b", "main"],
vec!["add", "."],
vec!["commit", "--no-gpg-sign", "-m", "messages fixture"],
] {
assert!(
Command::new("git")
.args(args)
.current_dir(&repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()?
.success()
);
}
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn goal_questions() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let dir = dir.path();
let world = World::open(dir)?;
prepare(dir)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
run_module(world, exec, BUNDLE).await
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "harness-api-client"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[dependencies]
wasi = "0.14"
+36
View File
@@ -0,0 +1,36 @@
//! A world HTTP client that forwards each response chunk without curl's body buffer.
use std::io::Write;
use wasi::http::{outgoing_handler, types::*};
fn main() {
let request = OutgoingRequest::new(Fields::new());
request.set_method(&Method::Get).unwrap();
request.set_scheme(Some(&Scheme::Http)).unwrap();
request.set_authority(Some("host.sausage")).unwrap();
request
.set_path_with_query(Some(&std::env::args().nth(1).unwrap()))
.unwrap();
let outgoing = request.body().unwrap();
let future = outgoing_handler::handle(request, None).unwrap();
OutgoingBody::finish(outgoing, None).unwrap();
future.subscribe().block();
let response = future.get().unwrap().unwrap().unwrap();
assert_eq!(response.status(), 200);
let incoming = response.consume().unwrap();
let stream = incoming.stream().unwrap();
let mut output = std::io::stdout().lock();
loop {
stream.subscribe().block();
match stream.read(65536) {
Ok(bytes) => {
output.write_all(&bytes).unwrap();
output.flush().unwrap();
}
Err(wasi::io::streams::StreamError::Closed) => break,
Err(error) => panic!("stream: {error:?}"),
}
}
drop(stream);
let trailers = IncomingBody::finish(incoming);
trailers.subscribe().block();
trailers.get().unwrap().unwrap().unwrap();
}
+190
View File
@@ -0,0 +1,190 @@
//! World HTTP clients read and drive the live V8 harness through WASI HTTP.
use sausage::{harness::run_module_with_input, world::World};
use std::sync::Arc;
use std::{path::Path, process::Command};
const BUNDLE: &str = include_str!("../js/harness-api-test-bundle.js");
static PREPARE: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn prepare(dir: &Path) -> anyhow::Result<()> {
let _guard = PREPARE.lock().unwrap();
let disk = dir.join("world");
let repo = disk.join("src/sausage");
if repo.join(".git").exists() {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args([
"build",
"--release",
"-p",
"git",
"-p",
"curl",
"--manifest-path"
])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
assert!(
Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(source.join("host/tests/harness-api-client/Cargo.toml"))
.arg("--target-dir")
.arg(target)
.args(["-j", "2", "--offline"])
.status()?
.success()
);
std::fs::create_dir_all(disk.join("bin"))?;
std::fs::copy(
target.join("wasm32-wasip2/release/harness-api-client.wasm"),
disk.join("bin/harness-api-client.wasm"),
)?;
std::fs::copy(
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
std::fs::copy(
target.join("wasm32-wasip2/release/curl.wasm"),
disk.join("bin/curl.wasm"),
)?;
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
"extensions/core",
"agents",
] {
std::fs::create_dir_all(repo.join(directory))?;
}
for directory in [
"extensions/subagents",
"extensions/messages",
"extensions/lineage",
"extensions/models",
"extensions/goal",
"extensions/questions",
"extensions/automations",
] {
for entry in std::fs::read_dir(source.join(directory))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), repo.join(directory).join(entry.file_name()))?;
}
}
}
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.testHost = host; globalThis.subagentService = host.need({name:'subagents'}); globalThis.messageService = host.need({name:'messages'}); globalThis.lineageService = host.need({name:'lineage'}); host.implement({name:'conversations'}, {summaryModel: async () => globalThis.summaryRef, concludedIn: async () => new Set(), generatedTitle: async () => {}, setStanding: async () => {}, setAutomaticSummaries: async () => {}}); return {name:'probe'}; }",
)?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
std::fs::write(
repo.join("agents/scout.md"),
"---\nname: scout\ndescription: Search\nresident: true\ndenyTools: [subagent]\nisolate: none\n---\nScout instructions\n",
)?;
std::fs::write(
repo.join("agents/ultimate.md"),
"---\nname: ultimate\ndescription: Primary\nresident: true\nisolate: none\n---\nPrimary agent\n",
)?;
std::fs::write(
repo.join("agents/coordinator.md"),
"---\nname: coordinator\ndescription: Coordinate\nisolate: none\n---\nCoordinator agent\n",
)?;
for args in [
vec!["init", "-b", "main"],
vec!["add", "."],
vec!["commit", "--no-gpg-sign", "-m", "messages fixture"],
] {
assert!(
Command::new("git")
.args(args)
.current_dir(&repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()?
.success()
);
}
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn harness_api_world_client() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let dir = dir.path();
let world = World::open(dir)?;
prepare(dir)?;
let cred = Arc::new(sausage::cred::Cred::open(&world.host_dir)?);
let api = Arc::new(sausage::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?);
let (sender, receiver) = sausage::harness::input::channel();
api.attach_harness(sender.clone());
let exec = sausage::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?;
let root = world.root.clone();
let stop = async move {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
if root.stat("/harness-test-done", true).await.is_ok() {
break;
}
anyhow::ensure!(std::time::Instant::now() < deadline, "API test deadline");
tokio::task::yield_now().await;
}
sender.send(sausage::harness::input::Input::Stop)?;
Ok::<_, anyhow::Error>(())
};
tokio::try_join!(
run_module_with_input(world, Arc::new(exec), cred, receiver, BUNDLE),
stop
)?;
Ok(())
}
+72 -24
View File
@@ -1,4 +1,4 @@
//! P3 exit scaffold with a worker SIGKILL and pending lander crash boundaries.
//! P3 exit scenario across worker, check, and recorded fast-forward SIGKILL boundaries.
use sausage::{harness::run_module, world::World};
use std::{
path::Path,
@@ -69,6 +69,21 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
target.join("wasm32-wasip2/release/git.wasm"),
disk.join("bin/git.wasm"),
)?;
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
anyhow::ensure!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", target)
.current_dir(&userland)
.status()?
.success(),
"brush build failed"
);
}
std::fs::copy(
target.join("wasm32-wasip2/release/brush.wasm"),
disk.join("bin/sh.wasm"),
)?;
if all_extensions {
copy_tree(&source.join("extensions"), &repo.join("extensions"))?;
} else {
@@ -82,7 +97,7 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.p3Models=host.need({name:'models'}); globalThis.p3Subagents=host.need({name:'subagents'}); return {name:'probe'}; }",
"export default host => { globalThis.p3Models=host.need({name:'models'}); globalThis.p3Subagents=host.need({name:'subagents'}); globalThis.p3Workspaces=host.need({name:'workspaces'}); globalThis.p3Host=host; return {name:'probe'}; }",
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
@@ -107,11 +122,17 @@ fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> {
),
)?;
}
std::fs::write(
repo.join("settings.json"),
r#"{"lander":{"checks":["sh -c ': > /check-running; until test -f /check-release; do :; done; test -f feature.txt'"]}}"#,
)?;
let demo = disk.join("src/demo");
std::fs::create_dir_all(&demo)?;
std::fs::write(demo.join("README.md"), "P3 demo\n")?;
for repo in [&repo, &demo] {
git(repo, &["init", "-b", "main"])?;
git(repo, &["config", "user.name", "Test"])?;
git(repo, &["config", "user.email", "test@example.com"])?;
git(repo, &["add", "."])?;
git(repo, &["commit", "--no-gpg-sign", "-m", "P3 fixture"])?;
}
@@ -156,10 +177,12 @@ async fn restart_child() -> anyhow::Result<()> {
}
Ok(())
}
#[test]
fn worker_sigkill_recovery() -> anyhow::Result<()> {
fn recovery(point: &str) -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
prepare(dir.path(), false)?;
prepare(dir.path(), true)?;
if point != "check" {
std::fs::write(dir.path().join("world/check-release"), "yes")?;
}
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
@@ -167,16 +190,22 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
.env("P3_MODE", mode)
.spawn()
};
let mut child = spawn("kill-worker")?;
let deadline = Instant::now() + Duration::from_secs(45);
while !dir.path().join("world/worker-running").exists() {
let mut child = spawn(&format!("kill-{point}"))?;
let marker = match point {
"worker" => "worker-running",
"check" => "check-running",
"forward" => "forward-recorded",
_ => unreachable!(),
};
let deadline = Instant::now() + Duration::from_secs(120);
while !dir.path().join("world").join(marker).exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("Worker child exited before marker: {status}");
anyhow::bail!("P3 child exited before {point} marker: {status}");
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Worker did not reach kill point 1");
anyhow::bail!("P3 did not reach {point} marker");
}
std::thread::yield_now();
}
@@ -184,10 +213,25 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
let killed = child.wait()?;
use std::os::unix::process::ExitStatusExt;
assert_eq!(killed.signal(), Some(9));
// A second reopen proves that idle recovery does not duplicate child reports or commits.
let demo = dir.path().join("world/src/demo");
assert_eq!(
git(&demo, &["rev-list", "--count", "main"])?,
if point == "forward" { "3" } else { "1" }
);
if point == "forward" {
let recorded: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(
dir.path().join("world/forward-recorded"),
)?)?;
assert_eq!(
recorded[0]["landed"]["sha"].as_str(),
Some(git(&demo, &["rev-parse", "main"])?.as_str())
);
}
std::fs::write(dir.path().join("world/check-release"), "yes")?;
// A second reopen proves that idle recovery does not duplicate reports, cards, events, or commits.
for _ in 0..2 {
let mut child = spawn("resume")?;
let deadline = Instant::now() + Duration::from_secs(45);
let deadline = Instant::now() + Duration::from_secs(120);
loop {
if let Some(status) = child.try_wait()? {
anyhow::ensure!(status.success(), "P3 recovery failed: {status}");
@@ -201,8 +245,14 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
std::thread::yield_now();
}
let demo = dir.path().join("world/src/demo");
assert_eq!(git(&demo, &["rev-list", "--count", "HEAD"])?, "2");
assert_eq!(git(&demo, &["log", "-1", "--format=%s"])?, "P3 feature");
assert_eq!(git(&demo, &["rev-list", "--count", "main"])?, "3");
assert_eq!(
git(&demo, &["log", "--format=%s", "main"])?
.lines()
.filter(|line| *line == "P3 feature")
.count(),
1
);
assert_eq!(git(&demo, &["status", "--porcelain"])?, "");
assert_eq!(
std::fs::read_to_string(demo.join("feature.txt"))?,
@@ -212,16 +262,14 @@ fn worker_sigkill_recovery() -> anyhow::Result<()> {
Ok(())
}
#[test]
#[ignore = "Pending T52: kill point 2 needs the lander check and marker-file gate"]
fn lander_check_sigkill_recovery() {
// Wait for the check-start marker, SIGKILL, reopen, release /check-release, and run to idle.
// Assert one landed event/card and no duplicate board reply (T49).
panic!("Pending T52 lander check");
fn worker_sigkill_recovery() -> anyhow::Result<()> {
recovery("worker")
}
#[test]
#[ignore = "Pending T52: kill point 3 needs the target fast-forward log entry"]
fn target_fast_forward_sigkill_recovery() {
// Detect the durable fast-forward log entry, SIGKILL, reopen, and run to idle.
// Assert one target commit, one landed event/card, no faulted tasks, and worktree release.
panic!("Pending T52 target fast-forward");
fn lander_check_sigkill_recovery() -> anyhow::Result<()> {
recovery("check")
}
#[test]
fn target_fast_forward_sigkill_recovery() -> anyhow::Result<()> {
recovery("forward")
}
+277
View File
@@ -0,0 +1,277 @@
//! P4 exit: the real console agent builds Rust and Node projects, then inspects undo history.
use anyhow::{Context, ensure};
use sausage::world::World;
use sausage_vfs::journal::Entry;
use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
fn checked(command: &mut Command) -> anyhow::Result<()> {
ensure!(command.status()?.success(), "command failed: {command:?}");
Ok(())
}
fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let userland = source.join("userland");
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR")?);
let bundle = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p4-bundle");
let revision = Command::new("git")
.current_dir(&source)
.args(["rev-parse", "HEAD"])
.output()?;
ensure!(revision.status.success(), "fixture revision");
if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) {
if !userland.join("cwd-init/init.o").exists() {
checked(Command::new("nu").arg(userland.join("cwd-init/build.nu")))?;
}
checked(Command::new("cargo").current_dir(&userland).args([
"build",
"--release",
"--locked",
"-p",
"git",
"-p",
"world-cargo",
"-p",
"box-cli",
"-p",
"undo",
"-p",
"sausage-coreutils",
]))?;
checked(Command::new("nu").arg(userland.join("brush/build.nu")))?;
fs::create_dir_all(bundle.join("bin"))?;
fs::create_dir_all(bundle.join("src"))?;
for (binary, name) in [
("brush", "sh"),
("brush", "bash"),
("git", "git"),
("cargo", "cargo"),
("box", "box"),
("undo", "undo"),
("cat", "cat"),
("mkdir", "mkdir"),
] {
fs::copy(
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
bundle.join(format!("bin/{name}.wasm")),
)?;
}
fs::write(bundle.join("revision"), revision.stdout)?;
}
World::open_with_userland(dir, Some(&bundle))?;
let repo = dir.join("world/src/sausage");
fs::create_dir_all(repo.join("extensions/p4"))?;
fs::write(
repo.join("extensions/p4/server.ts"),
include_str!("../js/p4-test-bundle.js"),
)?;
for args in [
vec!["init", "-b", "main"],
vec!["add", "."],
vec!["commit", "--no-gpg-sign", "-m", "P4 fixture"],
] {
checked(
Command::new("git")
.current_dir(&repo)
.args(args)
.env("GIT_AUTHOR_NAME", "P4 test")
.env("GIT_AUTHOR_EMAIL", "p4@example.com")
.env("GIT_COMMITTER_NAME", "P4 test")
.env("GIT_COMMITTER_EMAIL", "p4@example.com"),
)?;
}
Ok(bundle)
}
struct Running(Child);
impl Drop for Running {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn round(dir: &Path, bundle: &Path, number: usize) -> anyhow::Result<()> {
let stdout = dir.join(format!("round-{number}.stdout"));
let stderr = dir.join(format!("round-{number}.stderr"));
let mut child = Running(
Command::new(env!("CARGO_BIN_EXE_sausage"))
.arg("run")
.arg(dir)
.arg("--userland")
.arg(bundle)
.args(["--api-listen", "127.0.0.1:0"])
.env("PI_PROXY_BASE_URL", "https://proxy.invalid")
.env("PI_PROXY_API_KEY", "faux-token")
.env_remove("CREDENTIALS_DIRECTORY")
.stdin(Stdio::piped())
.stdout(fs::File::create(&stdout)?)
.stderr(fs::File::create(&stderr)?)
.spawn()?,
);
writeln!(
child.0.stdin.take().context("console stdin")?,
"P4 start {number}"
)?;
let deadline = Instant::now() + Duration::from_secs(600);
loop {
if fs::read_to_string(&stdout)?.contains(&format!("P4_COMPLETE_{number}")) {
break;
}
if let Some(status) = child.0.try_wait()? {
anyhow::bail!("console exited: {status}");
}
ensure!(
Instant::now() < deadline,
"P4 round {number} exceeded its deadline"
);
std::thread::yield_now();
}
checked(Command::new("kill").args(["-TERM", &child.0.id().to_string()]))?;
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(status) = child.0.try_wait()? {
ensure!(status.success(), "console shutdown: {status}");
break;
}
ensure!(
Instant::now() < deadline,
"console shutdown exceeded its deadline"
);
std::thread::yield_now();
}
Ok(())
}
fn boxes_removed(dir: &Path, bundle: &Path) -> anyhow::Result<()> {
let path = dir.join("world/tmp/p4-box-id");
if !path.exists() {
return Ok(());
}
let id = fs::read_to_string(path)?;
let id = id.trim();
ensure!(
id.starts_with("tc-") && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
"invalid box ID"
);
// The agent's EXIT trap is primary cleanup. Recover its box even if the console fails.
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
.arg("sh")
.arg(dir)
.arg("--userland")
.arg(bundle)
.arg("-c")
.arg("box ls")
.env_remove("CREDENTIALS_DIRECTORY")
.output()?;
ensure!(
output.status.success(),
"box ls: {}",
String::from_utf8_lossy(&output.stderr)
);
if String::from_utf8_lossy(&output.stdout).contains(id) {
checked(
Command::new(env!("CARGO_BIN_EXE_sausage"))
.arg("sh")
.arg(dir)
.arg("--userland")
.arg(bundle)
.arg("-c")
.arg(format!("box rm {id}"))
.env_remove("CREDENTIALS_DIRECTORY"),
)?;
anyhow::bail!("Agent left {id} active. Test cleanup removed it");
}
println!("P4 box absent from service inventory: {id}");
Ok(())
}
fn cache_hits(dir: &Path) -> anyhow::Result<u64> {
let workspace = fs::read_dir(dir.join("host/builds"))?
.next()
.context("builder workspace")??
.path();
let output = Command::new("kache")
.arg("stats")
.env("KACHE_CACHE_DIR", workspace.join("kache"))
.output()?;
ensure!(
output.status.success(),
"kache: {}",
String::from_utf8_lossy(&output.stderr)
);
let text = String::from_utf8(output.stdout)?;
println!("P4 PRIVATE CACHE:\n{text}");
let words: Vec<_> = text.split_whitespace().collect();
words
.windows(2)
.find_map(|pair| {
if pair[1] == "of" {
pair[0].replace(',', "").parse().ok()
} else {
None
}
})
.context("cache hit count")
}
#[test]
fn agent_builds_rust_and_node_twice() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let bundle = prepare(dir.path())?;
let mut first_hits = 0;
for number in 1..=2 {
let outcome = round(dir.path(), &bundle, number);
let cleanup = boxes_removed(dir.path(), &bundle);
if outcome.is_err() {
for stream in ["stdout", "stderr"] {
eprintln!(
"P4 round {number} {stream}:\n{}",
fs::read_to_string(dir.path().join(format!("round-{number}.{stream}")))?
);
}
}
outcome?;
cleanup?;
let world = dir.path().join("world");
ensure!(
world
.join("src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm")
.is_file(),
"missing world artifact"
);
ensure!(
fs::read_to_string(world.join("src/demo-cli/Cargo.toml"))?.contains("serde_json"),
"cargo add did not reach world"
);
ensure!(
fs::read_to_string(world.join("src/demo-node/result.txt"))? == "P4 npm test passed\n",
"Node result missing"
);
let entries: Vec<Entry> =
serde_json::from_slice(&fs::read(world.join("tmp/p4-undo.json"))?)?;
for (phase, path) in [
(0, "/src/demo-cli/Cargo.toml"),
(1, "/src/demo-cli/src/main.rs"),
] {
let tag = format!("p4-{number}-{phase}");
ensure!(
entries.iter().any(|entry| entry.tag == tag
&& entry.program == "bash"
&& entry.changed_paths.iter().any(|p| p == path)),
"undo lost {tag}: {path}"
);
}
let hits = cache_hits(dir.path())?;
if number == 1 {
first_hits = hits;
} else {
ensure!(
hits > first_hits,
"second run missed kache: {first_hits} -> {hits}"
);
println!("P4 CACHE SECOND RUN: {first_hits} -> {hits} hits");
}
}
Ok(())
}
+39
View File
@@ -41,6 +41,45 @@ async fn command(runtime: &Arc<Runtime>, args: &[&str]) -> String {
#[tokio::test(flavor = "current_thread")]
async fn tools_restore_one_call_or_the_whole_turn() {
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "userland/cwd-init/build.nu"])
.current_dir(repo)
.status()
.unwrap()
.success()
);
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"--target",
"wasm32-wasip2",
"-p",
"git",
"-p",
"undo",
"--target-dir"
])
.arg(&target)
.args(["-j", "2"])
.current_dir(repo.join("userland"))
.status()
.unwrap()
.success()
);
assert!(
std::process::Command::new("nu")
.args(["--no-config-file", "userland/brush/build.nu"])
.env("CARGO_TARGET_DIR", &target)
.current_dir(repo)
.status()
.unwrap()
.success()
);
for since in [false, true] {
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
+299
View File
@@ -0,0 +1,299 @@
//! World worktrees, landing checks, and SIGKILL recovery before push.
use sausage::{harness::run_module, world::World};
use std::{
io::Write,
path::Path,
process::{Command, Stdio},
sync::Arc,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/workspaces-test-bundle.js");
fn git(path: &Path, args: &[&str]) -> anyhow::Result<()> {
assert!(
Command::new("git")
.args(args)
.current_dir(path)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()?
.success()
);
Ok(())
}
fn prepare(dir: &Path) -> anyhow::Result<()> {
let disk = dir.join("world");
let repo = disk.join("src/sausage");
if repo.join(".git").exists() {
return Ok(());
}
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
let userland = source.join("userland");
if !userland.join("cwd-init/init.o").exists() {
assert!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success()
);
}
assert!(
Command::new("cargo")
.args(["build", "--release", "-p", "git", "--manifest-path"])
.arg(userland.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
.current_dir(&userland)
.status()?
.success()
);
if !target.join("wasm32-wasip2/release/brush.wasm").exists() {
assert!(
Command::new("nu")
.arg("brush/build.nu")
.env("CARGO_TARGET_DIR", target)
.current_dir(&userland)
.status()?
.success()
);
}
std::fs::create_dir_all(disk.join("bin"))?;
for (binary, name) in [("git", "git"), ("brush", "sh")] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
disk.join(format!("bin/{name}.wasm")),
)?;
}
for extension in [
"subagents",
"models",
"workspaces",
"messages",
"lineage",
"conversations",
] {
let dest = repo.join("extensions").join(extension);
std::fs::create_dir_all(&dest)?;
for entry in std::fs::read_dir(source.join("extensions").join(extension))? {
let entry = entry?;
if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), dest.join(entry.file_name()))?;
}
}
}
let server = repo.join("extensions/workspaces/server.ts");
let code = std::fs::read_to_string(&server)?;
std::fs::write(server,code.replace("const service=workspaces(host)","globalThis.workspaceExecutions??=[]; const exec=host.exec; host.exec=(command,options)=>{globalThis.workspaceExecutions.push({command,options});return exec(command,options)}; const service=workspaces(host)"))?;
std::fs::create_dir_all(repo.join("extensions/core"))?;
std::fs::copy(
source.join("extensions/core/agents.ts"),
repo.join("extensions/core/agents.ts"),
)?;
std::fs::create_dir_all(repo.join("extensions/probe"))?;
std::fs::write(
repo.join("extensions/probe/server.ts"),
"export default host => { globalThis.workspaceService=host.need({name:'workspaces'}); globalThis.workspaceHost=host; return {name:'probe'}; }",
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
std::fs::write(
repo.join("agents/worker.md"),
"---\nname: worker\ndescription: Implement\nisolate: worktree\n---\nImplement\n",
)?;
std::fs::write(
repo.join("settings.json"),
r#"{"lander":{"push":"origin","checks":["sh -c 'test -f feature.txt'"]}}"#,
)?;
let project = disk.join("project");
std::fs::create_dir_all(&project)?;
std::fs::write(project.join("base.txt"), "base")?;
for path in [&project, &repo] {
git(path, &["init", "-b", "main"])?;
git(path, &["config", "user.name", "Test"])?;
git(path, &["config", "user.email", "test@example.com"])?;
git(path, &["add", "."])?;
git(path, &["commit", "--no-gpg-sign", "-m", "fixture"])?;
}
assert!(
Command::new("git")
.args(["clone", "--bare"])
.arg(&project)
.arg(disk.join("remote.git"))
.status()?
.success()
);
git(&project, &["remote", "add", "origin", "/remote.git"])?;
Ok(())
}
// The world Git supports smart HTTP and SSH, not file remotes.
struct GitServer(Arc<tiny_http::Server>, Option<std::thread::JoinHandle<()>>);
impl Drop for GitServer {
fn drop(&mut self) {
self.0.unblock();
self.1.take().unwrap().join().unwrap();
}
}
fn serve(dir: &Path) -> anyhow::Result<GitServer> {
let root = dir.join("world");
git(
&root.join("remote.git"),
&["config", "http.receivepack", "true"],
)?;
let server =
Arc::new(tiny_http::Server::http("127.0.0.1:0").map_err(|e| anyhow::anyhow!("{e}"))?);
let port = server.server_addr().to_ip().unwrap().port();
git(
&root.join("project"),
&[
"remote",
"set-url",
"origin",
&format!("http://127.0.0.1:{port}/remote.git"),
],
)?;
let serving = server.clone();
let thread = std::thread::spawn(move || {
while let Ok(mut request) = serving.recv() {
let url = request.url().to_owned();
let (path, query) = url.split_once('?').unwrap_or((&url, ""));
let content_type = request
.headers()
.iter()
.find(|h| h.field.equiv("Content-Type"))
.map(|h| h.value.as_str().to_owned())
.unwrap_or_default();
let mut body = Vec::new();
request.as_reader().read_to_end(&mut body).unwrap();
let mut process = Command::new("git")
.arg("http-backend")
.env("GIT_PROJECT_ROOT", &root)
.env("GIT_HTTP_EXPORT_ALL", "1")
.env("REQUEST_METHOD", request.method().as_str())
.env("PATH_INFO", path)
.env("QUERY_STRING", query)
.env("CONTENT_TYPE", content_type)
.env("CONTENT_LENGTH", body.len().to_string())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
process.stdin.take().unwrap().write_all(&body).unwrap();
let output = process.wait_with_output().unwrap();
assert!(output.status.success());
let split = output
.stdout
.windows(4)
.position(|p| p == b"\r\n\r\n")
.unwrap();
let headers = std::str::from_utf8(&output.stdout[..split]).unwrap();
let mut response = tiny_http::Response::from_data(output.stdout[split + 4..].to_vec());
for line in headers.split("\r\n") {
let (name, value) = line.split_once(':').unwrap();
if name.eq_ignore_ascii_case("status") {
response = response.with_status_code(
value
.trim()
.split(' ')
.next()
.unwrap()
.parse::<u16>()
.unwrap(),
);
} else {
response = response
.with_header(tiny_http::Header::from_bytes(name, value.trim()).unwrap());
}
}
let _ = request.respond(response);
}
});
Ok(GitServer(server, Some(thread)))
}
async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> {
let world = World::open(dir)?;
prepare(dir)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
run_module(
world,
exec,
&format!("globalThis.workspacesMode={mode:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn coordination() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
World::open(dir.path())?;
prepare(dir.path())?;
let _server = serve(dir.path())?;
run(dir.path(), "normal").await
}
#[tokio::test(flavor = "current_thread")]
async fn restart_child() -> anyhow::Result<()> {
if let Ok(dir) = std::env::var("WORKSPACES_DIR") {
run(Path::new(&dir), &std::env::var("WORKSPACES_MODE")?).await?;
}
Ok(())
}
#[test]
fn sigkill_between_fast_forward_and_push() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
World::open(dir.path())?;
prepare(dir.path())?;
let _server = serve(dir.path())?;
let spawn = |mode: &str| {
Command::new(std::env::current_exe().unwrap())
.args(["--exact", "restart_child", "--nocapture"])
.env("WORKSPACES_DIR", dir.path())
.env("WORKSPACES_MODE", mode)
.spawn()
};
let mut child = spawn("kill")?;
let deadline = Instant::now() + Duration::from_secs(60);
while !dir.path().join("world/push-ready").exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("Child exited before push: {status}");
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Lander did not reach push");
}
std::thread::yield_now();
}
child.kill()?;
child.wait()?;
// A later target commit must not replace the recorded SHA in the push or report.
git(
&dir.path().join("world/project"),
&["commit", "--allow-empty", "--no-gpg-sign", "-m", "later"],
)?;
for _ in 0..2 {
let mut child = spawn("resume")?;
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = child.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Recovery timed out");
}
std::thread::yield_now();
}
}
Ok(())
}