Merge v3-t52 (T52)
# Conflicts: # host/js/package.json
This commit is contained in:
@@ -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" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)}
|
||||
Reference in New Issue
Block a user