From 8d2ed60a23ab8fb403416337e0aa8b34bdde2292 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 6 Nov 2026 22:17:57 -0600 Subject: [PATCH 01/17] feat(sidecar): the agent loop runs durably on the app's own tools Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 160 ++++++++++++++ sidecar/src/app.ts | 90 ++++++++ sidecar/src/chat.ts | 255 ++++++++++++++++++++++ sidecar/src/main.ts | 48 +--- sidecar/src/methods.ts | 33 ++- sidecar/src/protocol.ts | 33 +++ sidecar/src/store.ts | 51 ++++- sidecar/src/text.d.ts | 4 + sidecar/src/transcript.ts | 42 ++++ sidecar/src/workflow.md | 31 +++ sidecar/test/agent.test.ts | 152 +++++++++++++ sidecar/test/fixtures/scripted-sidecar.ts | 54 +++++ sidecar/test/inference.test.ts | 2 +- sidecar/test/sidecar.ts | 97 ++++++-- sidecar/test/store.test.ts | 2 +- sidecar/test/waiting.test.ts | 70 ++++++ 16 files changed, 1047 insertions(+), 77 deletions(-) create mode 100644 sidecar/src/agent.ts create mode 100644 sidecar/src/app.ts create mode 100644 sidecar/src/chat.ts create mode 100644 sidecar/src/text.d.ts create mode 100644 sidecar/src/transcript.ts create mode 100644 sidecar/src/workflow.md create mode 100644 sidecar/test/agent.test.ts create mode 100644 sidecar/test/fixtures/scripted-sidecar.ts create mode 100644 sidecar/test/waiting.test.ts diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts new file mode 100644 index 0000000..efba5c6 --- /dev/null +++ b/sidecar/src/agent.ts @@ -0,0 +1,160 @@ +/** + * The agent's own tools, and nothing of the person's agent setup. Every tool works in the project folder only. + * + * Tools that act outside the store (`run`, `slice`, `astra`) are replay-safe and keep a receipt: a call that finished + * its action records the result before it returns, so a rerun after a crash hands back the receipt instead of acting + * again. A crash between the action and its receipt still repeats that one action, because no checkpoint can make an + * outside action run exactly once. + */ +import { createHash } from 'node:crypto' +import { isAbsolute, relative } from 'node:path' +import type { Context, JsonValue } from '@earendil-works/chord' +import { + AssistantEntry, + configure, + defineExtension, + defineTool, + type ToolExecutionApi, + type ToolRegistration, +} from '@earendil-works/pi-durable' +import { err, FileError, type Result } from '@earendil-works/pi-durable/env' +import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node' +import { createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-durable/tools' +import { arr, int, obj, opt, str } from '@earendil-works/pi-schema' +import type { Host } from './host' +import { textOf } from './transcript' +import workflow from './workflow.md' with { type: 'text' } + +/** The instructions every turn runs with, and the version a turn records. */ +export const WORKFLOW = workflow +export const WORKFLOW_VERSION = createHash('sha256').update(workflow).digest('hex').slice(0, 12) + +/** Astra, the GPT helper: OpenAI's model on the person's ChatGPT plan. */ +export const ASTRA_MODEL = { provider: 'openai-codex', modelId: 'gpt-6-astra' } as const + +/** How much of a command's output the model gets back: the end, where errors are. */ +const OUTPUT_TAIL = 20_000 + +/** One project's files and processes. Any path that leaves the project folder is refused, for every file tool. */ +export class ProjectEnv extends NodeExecutionEnv { + override async absolutePath(path: string, context: Context): Promise> { + const resolved = await super.absolutePath(path, context) + if (!resolved.ok) return resolved + const inside = relative(this.cwd, resolved.value) + if (inside.startsWith('..') || isAbsolute(inside)) { + return err(new FileError('permission_denied', `${path} is outside the project folder`, path)) + } + return resolved + } +} + +/** Does `act` once for this tool call. A rerun after a crash gets the first run's receipt back. */ +async function once(api: ToolExecutionApi, context: Context, act: () => Promise): Promise { + const receipt = await api.memo('receipt', context) + if (receipt !== undefined) return receipt + return api.memo('receipt', await act(), context) +} + +const text = (value: string) => [{ type: 'text' as const, text: value }] + +export interface AgentHost { + host: Host + /** The project a conversation belongs to. */ + projectOf(conversation: number): Promise +} + +/** The agent's tools, in the order the model is offered them. */ +export function agentTools({ host, projectOf }: AgentHost) { + const read = createReadTool() + const write = { ...createWriteTool(), replay: 'safe' as const } + const edit = createEditTool() + + const run = defineTool({ + name: 'run', + description: + 'Run uv in the project folder, for the build and check scripts. Pass the arguments after `uv`, for example ' + + '["run", "--with", "manifold3d", "--with", "numpy", "python", "build/model.py"]. Returns the exit code and the end of the output.', + parameters: obj({ args: arr(str(), { minItems: 1 }), timeoutSeconds: opt(int({ minimum: 1, maximum: 3600 })) }), + replay: 'safe', + execute: async ({ args, timeoutSeconds }, api, context) => { + const env = api.env + if (env === undefined) throw new Error('this conversation has no project folder') + const receipt = await once(api, context, async () => { + let output = '' + const ran = await env.exec( + ['uv', ...args], + { + timeout: (timeoutSeconds ?? 600) * 1000, + onOutput: (chunk) => { + output = (output + chunk).slice(-OUTPUT_TAIL) + api.output(chunk) + }, + }, + context, + ) + return ran.ok ? { exitCode: ran.value.exitCode, output } : { exitCode: -1, output: `${output}\n${ran.error.message}` } + }) + return { output: text(`exit code ${receipt.exitCode}\n${receipt.output}`), isError: receipt.exitCode !== 0 } + }, + }) + + const slice = defineTool({ + name: 'slice', + description: + 'Slice STL files in the project folder to one print-ready 3MF for the Bambu Lab P2S (0.4 mm nozzle, 0.20 mm layers, ' + + 'PETG) with the validated Bambu Studio. Paths are relative to the project folder; the output ends in .3mf.', + parameters: obj({ inputs: arr(str(), { minItems: 1 }), output: str() }), + replay: 'safe', + execute: async ({ inputs, output }, api, context) => { + const project = await projectOf(api.conversationId) + const sliced = await once(api, context, async () => { + try { + return { ok: true, ...(await host.request('slice', { project, inputs, output })) } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + }) + if (!sliced.ok) return { output: text(`The slice failed: ${sliced.error}`), isError: true } + const minutes = Math.round(Number(sliced.printSeconds) / 60) + return { output: text(`Wrote ${output}: ${sliced.parts} part(s), about ${minutes} min and ${Number(sliced.filamentGrams).toFixed(1)} g of filament.`) } + }, + }) + + const astra = defineTool({ + name: 'astra', + description: + 'Hand a task to Astra, the GPT helper, who works in this project folder with the same file tools and `run`. ' + + 'Astra cannot see this chat, so say everything: what to do, the files to read, and the file to write.', + parameters: obj({ task: str({ minLength: 1 }) }), + replay: 'safe', + execute: async ({ task }, api, context) => { + // Astra's conversation is owned by this call, so a rerun finds the same one and the same request. + const child = await api.commit(async (tx) => { + const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0] + if (existing !== undefined) return existing.id + const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } }) + await configure(tx, created.id, { model: ASTRA_MODEL, tools: [read, write, edit, run] }) + return created.id + }, context) + await api.details({ astra: child }, context) + const conversation = await api.conversation(child, context) + if (conversation === undefined) throw new Error("Astra's conversation is gone") + const submission = await conversation.submit({ type: 'input', content: task, requestId: `astra:${api.taskId}` }, context) + const settled = await submission.wait(context) + if (settled.status !== 'done' || settled.type !== 'input') { + const why = settled.status === 'unanswered' ? `${settled.reason}${settled.detail === undefined ? '' : `: ${settled.detail}`}` : settled.status + return { output: text(`Astra did not answer (${why}).`), isError: true } + } + const { answer } = settled + const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context) + return { output: text(textOf(entry?.model?.[0]?.content) || 'Astra finished without a message.') } + }, + }) + + return [read, write, edit, run, slice, astra] as const satisfies readonly ToolRegistration[] +} + +/** The one extension the app installs. Nothing else is registered, so the agent has these tools and no others. */ +export function agentExtension(tools: readonly ToolRegistration[]) { + return defineExtension({ name: 'materialize', tools }) +} diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts new file mode 100644 index 0000000..72998d9 --- /dev/null +++ b/sidecar/src/app.ts @@ -0,0 +1,90 @@ +import { parseArgs } from 'node:util' +import type { MutableModels } from '@earendil-works/pi-ai' +import { createRegistry } from '@earendil-works/pi-durable' +import { agentExtension, agentTools, ProjectEnv } from './agent' +import { Chats } from './chat' +import { Host } from './host' +import { inferenceModels, KeychainCredentials, Logins } from './inference' +import { handlers } from './methods' +import { EXIT, type SidecarEvent } from './protocol' +import { Store, StoreInUse } from './store' + +export interface SidecarOptions { + argv: string[] + /** Tests only: providers beside the app's three, such as a scripted model. */ + addProviders?(models: MutableModels): void +} + +/** The sidecar from start to quit: open the store, serve the pipe until stdin closes, then close the store. */ +export async function runSidecar({ argv, addProviders }: SidecarOptions): Promise { + // Stdout carries protocol lines only, so a library that logs cannot corrupt the pipe. + console.log = console.error + console.info = console.error + console.debug = console.error + + const { values } = parseArgs({ + args: argv, + options: { 'app-version': { type: 'string' }, data: { type: 'string' }, projects: { type: 'string' } }, + strict: true, + }) + const { data, projects } = values + if (data === undefined || projects === undefined) throw new Error('usage: --data --projects ') + const version = values['app-version'] ?? 'dev' + + const host = new Host((line) => process.stdout.write(line)) + // The credentials keep their index in the store, which needs the models to open, so the index is reached late. + const credentials = new KeychainCredentials(host, { + logins: () => store.logins(), + setLogin: (provider, kind) => store.setLogin(provider, kind), + }) + const models = inferenceModels(credentials) + addProviders?.(models) + + const registry = createRegistry() + const tools = agentTools({ host, projectOf: (conversation) => store.projectOf(conversation) }) + const extension = agentExtension(tools) + registry.install(extension) + + let store: Store + try { + store = await Store.open({ + dataDir: data, + projectsDir: projects, + models, + registry, + env: ({ cwd }) => { + if (cwd === undefined) throw new Error('this conversation has no project folder') + return new ProjectEnv({ cwd }) + }, + }) + } catch (error) { + if (!(error instanceof StoreInUse)) throw error + console.error(error.message) + process.exit(EXIT.storeInUse) + } + // A store that fails is not used again. Rust starts a new sidecar, which reopens it from what was committed. + void store.harness.closed.then((end) => { + if (end.kind !== 'failed') return + console.error(`the store failed: ${end.error instanceof Error ? end.error.message : String(end.error)}`) + process.exit(1) + }) + + const emit = (event: SidecarEvent) => { + host.emit(event) + if (event.type === 'login' && event.step === 'done') { + chats.signedIn(event.provider).catch((error: unknown) => console.error(`resuming turns after a sign-in failed: ${String(error)}`)) + } + } + const chats = new Chats({ store, models, extension, tools, version, emit }) + const logins = new Logins(models, credentials, emit) + console.error(`agent tools: ${tools.map((tool) => tool.name).join(', ')}`) + + // The pipe opens before the reconcile, which asks Rust's Keychain whether a waited-on login is there now. + const serving = host.serve(handlers({ store, logins, chats, version }), process.stdin) + host.emit({ type: 'ready', version }) + await chats.reconcile() + await serving + await chats.close() + await store.close() + process.exit(EXIT.done) +} diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts new file mode 100644 index 0000000..60d7ea8 --- /dev/null +++ b/sidecar/src/chat.ts @@ -0,0 +1,255 @@ +/** + * Chats: one Pi conversation per project. A send commits the message, the settings its turn runs with, and the + * draft's removal together. A turn that stops before its answer waits with the fix named, and continues once it is + * fixed. The window sees committed entries only. + */ +import { mkdirSync, readFileSync, statSync } from 'node:fs' +import { extname, isAbsolute, join, relative } from 'node:path' +import type { Context } from '@earendil-works/chord' +import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import type { ImageContent, Models, TextContent } from '@earendil-works/pi-ai' +import { + type Conversation, + ConversationBusy, + type ConversationId, + configure, + type EntryRecord, + type Extension, + LiveDoc, + type RunFailure, + type ToolRegistration, + type WatchHandle, +} from '@earendil-works/pi-durable' +import type { ConversationView } from '@earendil-works/pi-durable' +import { WORKFLOW, WORKFLOW_VERSION } from './agent' +import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' +import { DraftsDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' +import { chatEntry } from './transcript' + +const context: Context = BACKGROUND_CONTEXT + +const IMAGE_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' } +/** Models read pictures up to about 5 MB. Larger ones, and HEIC photos, go as a smaller JPEG on macOS. */ +const MAX_IMAGE_BYTES = 3_500_000 + +/** A failure the provider gave because the login was refused, not missing. */ +const REFUSED_LOGIN = /\b(401|403)\b|unauthori[sz]ed|authentication|invalid (api )?(key|token)|expired/i + +export interface ChatsOptions { + store: Store + models: Models + extension: Extension + tools: readonly ToolRegistration[] + version: string + emit(event: SidecarEvent): void +} + +/** A path the window named for an attachment, inside the project folder. */ +function inside(folder: string, rel: string): string { + const path = join(folder, rel) + const back = relative(folder, path) + if (back === '' || back.startsWith('..') || isAbsolute(back)) throw new Error(`${rel} is not a file in the project folder`) + return path +} + +/** A picture the model can read: as it is, or on macOS as a JPEG made with `sips`. Undefined when neither works. */ +function picture(folder: string, rel: string): ImageContent | undefined { + const path = inside(folder, rel) + const type = IMAGE_TYPES[extname(rel).toLowerCase()] + if (type !== undefined && statSync(path).size <= MAX_IMAGE_BYTES) { + return { type: 'image', data: readFileSync(path).toString('base64'), mimeType: type } + } + if (process.platform !== 'darwin') return undefined + const out = join(folder, '.m3d', 'send', `${rel.replaceAll('/', '_')}.jpg`) + mkdirSync(join(folder, '.m3d', 'send'), { recursive: true }) + const converted = Bun.spawnSync(['sips', '-s', 'format', 'jpeg', '-Z', '2000', path, '--out', out]) + if (converted.exitCode !== 0) return undefined + return { type: 'image', data: readFileSync(out).toString('base64'), mimeType: 'image/jpeg' } +} + +function userContent(folder: string, text: string, attachments: string[]): string | (TextContent | ImageContent)[] { + if (attachments.length === 0) return text + const images = attachments.flatMap((rel) => picture(folder, rel) ?? []) + const note = `\n\n(Pictures attached, saved in this folder as: ${attachments.join(', ')})` + return [{ type: 'text', text: `${text}${note}` }, ...images] +} + +export class Chats { + readonly #o: ChatsOptions + readonly #watches = new Map>() + /** The newest entry each watched chat has sent the window. */ + readonly #sent = new Map() + + constructor(options: ChatsOptions) { + this.#o = options + } + + async #conversation(project: string): Promise { + const { conversation } = await this.#o.store.project(project) + const found = await this.#o.store.harness.conversation(conversation as ConversationId, context) + if (found === undefined) throw new Error(`project ${project} has no conversation`) + return found + } + + async #entries(conversation: Conversation, after = 0): Promise { + const entries: ChatEntry[] = [] + let cursor: Parameters[2] + do { + const page = await conversation.entries({ order: 'ascending', minEntryId: (after + 1) as EntryRecord['id'] }, 500, cursor, context) + entries.push(...page.items.flatMap((entry) => chatEntry(entry) ?? [])) + cursor = page.next + } while (cursor !== undefined) + return entries + } + + async #state(conversation: Conversation): Promise<{ running: boolean; waiting: Waiting | null; turn: Turn | null; failure: RunFailure | undefined }> { + const { harness } = this.#o.store + const [live, waiting, turn] = await Promise.all([ + harness.snapshot(LiveDoc, conversation.id, context), + harness.snapshot(WaitingDoc, conversation.id, context), + harness.snapshot(TurnDoc, conversation.id, context), + ]) + return { running: live?.run !== undefined, waiting: waiting?.waiting ?? null, turn: turn?.turn ?? null, failure: live?.runFailure } + } + + /** The whole chat, and from now on its changes as `chat` events. */ + async open(project: string): Promise { + const conversation = await this.#conversation(project) + await this.#watch(project, conversation) + const entries = await this.#entries(conversation) + this.#sent.set(project, Math.max(this.#sent.get(project) ?? 0, entries.at(-1)?.id ?? 0)) + const { running, waiting, turn } = await this.#state(conversation) + return { project, entries, running, waiting, turn } + } + + async #watch(project: string, conversation: Conversation): Promise { + if (this.#watches.has(project)) return + const watch = await conversation.watch(context) + this.#watches.set(project, watch) + watch.start(async (call) => { + if (call.kind === 'value') await this.#changed(project, conversation) + }) + } + + async #changed(project: string, conversation: Conversation): Promise { + let state = await this.#state(conversation) + const recorded = (await this.#o.store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null + if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { + // The waiting document is not part of the view a watch follows, so its commit wakes no one: read it here. + await this.#read(conversation, state.failure, state.turn) + state = await this.#state(conversation) + } + const entries = await this.#entries(conversation, this.#sent.get(project) ?? 0) + if (entries.length > 0) this.#sent.set(project, entries.at(-1)?.id ?? 0) + this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn }) + } + + /** Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. */ + async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { + const { models, store } = this.#o + const provider = turn?.model.provider ?? 'anthropic' + const name = models.getProvider(provider)?.name ?? provider + const auth = await models.getAuth(provider).catch(() => undefined) + const detail = typeof failure.detail === 'string' ? failure.detail : failure.reason + const waiting: Waiting = + auth === undefined || (failure.reason === 'model_error' && REFUSED_LOGIN.test(detail)) + ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } + : { kind: 'error', detail, fix: 'Try again' } + await store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, conversation.id) + record.waiting = waiting + record.failure = failure.taskId + }, context) + } + + /** + * Sends a message. The message, the settings its turn runs with, and the draft's removal commit together. A send + * whose `requestId` was already committed changes nothing, so the window can send again after any failure. + */ + async send(project: string, requestId: string, text: string, attachments: string[]): Promise { + const { store, extension, tools, version } = this.#o + const conversation = await this.#conversation(project) + const folder = store.projectFolder(project) + const content = userContent(folder, text, attachments) + try { + await store.harness.commit(async (tx) => { + if ((await tx.submissionByRequest(conversation.id, requestId)) !== undefined) return + const settings = await tx.doc(SettingsDoc, store.root.id) + const model = { provider: settings.model.provider, modelId: settings.model.modelId } + const thinking = settings.thinking + await conversation.submit(tx, { + type: 'input', + content, + requestId, + whenBusy: 'reject', + entry: { kind: 'm3d.user', data: { text, images: attachments } }, + }) + await configure(tx, conversation.id, { model, thinkingLevel: thinking, extensions: [extension], tools, instructions: WORKFLOW, cwd: folder }) + const turn: Turn = { requestId, model, thinking, tools: tools.map((t) => t.name), instructions: WORKFLOW_VERSION, app: version, startedMs: Date.now() } + ;(await tx.doc(TurnDoc, conversation.id)).turn = turn + delete (await tx.doc(DraftsDoc, store.root.id)).drafts[project] + ;(await tx.doc(WaitingDoc, conversation.id)).waiting = null + }, context) + } catch (error) { + if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') + throw error + } + await this.#watch(project, conversation) + } + + /** Goes on from where the turn stopped, with the settings it started with, and clears what it waited on. */ + async #continue(project: string): Promise { + const conversation = await this.#conversation(project) + await this.#o.store.harness.commit(async (tx) => { + await conversation.submit(tx, { type: 'continue' }) + ;(await tx.doc(WaitingDoc, conversation.id)).waiting = null + }, context) + await this.#watch(project, conversation) + } + + async retry(project: string): Promise { + await this.#continue(project) + } + + async tools(project: string): Promise { + const agent = await (await this.#conversation(project)).agent(context) + return agent.tools.map((tool) => tool.name) + } + + async stop(project: string): Promise { + await (await this.#conversation(project)).abort(context) + } + + /** After a sign-in: every turn that waited on that provider goes on. */ + async signedIn(provider: string): Promise { + for (const project of await this.#o.store.projects()) { + const waiting = await this.#o.store.harness.snapshot(WaitingDoc, project.conversation as ConversationId, context) + if (waiting?.waiting?.kind === 'login' && waiting.waiting.provider === provider) await this.#continue(project.id) + } + } + + /** + * At start: runs a crash cut off go on from their last committed step, failures not yet read are read, and turns + * that waited on a login that is now there go on. + */ + async reconcile(): Promise { + const { store, models } = this.#o + store.harness.resume() + for (const project of await store.projects()) { + const conversation = await this.#conversation(project.id) + const state = await this.#state(conversation) + const recorded = (await store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null + if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { + await this.#read(conversation, state.failure, state.turn) + } + const waiting = (await store.harness.snapshot(WaitingDoc, conversation.id, context))?.waiting + if (waiting?.kind === 'login' && (await models.getAuth(waiting.provider).catch(() => undefined)) !== undefined) { + await this.#continue(project.id) + } + } + } + + async close(): Promise { + await Promise.all([...this.#watches.values()].map((watch) => watch.stop())) + } +} diff --git a/sidecar/src/main.ts b/sidecar/src/main.ts index e8ca4db..4578137 100644 --- a/sidecar/src/main.ts +++ b/sidecar/src/main.ts @@ -1,47 +1,3 @@ -import { parseArgs } from 'node:util' -import { createRegistry } from '@earendil-works/pi-durable' -import { Host } from './host' -import { inferenceModels, KeychainCredentials, Logins } from './inference' -import { handlers } from './methods' -import { EXIT } from './protocol' -import { Store, StoreInUse } from './store' +import { runSidecar } from './app' -// Stdout carries protocol lines only, so a library that logs cannot corrupt the pipe. -console.log = console.error -console.info = console.error -console.debug = console.error - -const { values } = parseArgs({ - options: { - 'app-version': { type: 'string' }, - data: { type: 'string' }, - projects: { type: 'string' }, - }, - strict: true, -}) -const { data, projects } = values -if (data === undefined || projects === undefined) throw new Error('usage: --data --projects ') -const version = values['app-version'] ?? 'dev' - -const host = new Host((line) => process.stdout.write(line)) -// The credentials keep their index in the store, which needs the models to open, so the index is reached late. -const credentials = new KeychainCredentials(host, { - logins: () => store.logins(), - setLogin: (provider, kind) => store.setLogin(provider, kind), -}) -const models = inferenceModels(credentials) - -let store: Store -try { - store = await Store.open({ dataDir: data, projectsDir: projects, models, registry: createRegistry() }) -} catch (error) { - if (!(error instanceof StoreInUse)) throw error - console.error(error.message) - process.exit(EXIT.storeInUse) -} - -const logins = new Logins(models, credentials, (event) => host.emit(event)) -host.emit({ type: 'ready', version }) -await host.serve(handlers(store, logins, version), process.stdin) -await store.close() -process.exit(EXIT.done) +await runSidecar({ argv: process.argv.slice(2) }) diff --git a/sidecar/src/methods.ts b/sidecar/src/methods.ts index 2b7e9b7..6e1beb5 100644 --- a/sidecar/src/methods.ts +++ b/sidecar/src/methods.ts @@ -1,8 +1,16 @@ import { arr, bool, compile, lit, obj, opt, str, union } from '@earendil-works/pi-schema' +import type { Chats } from './chat' import type { Handlers } from './host' import type { Logins } from './inference' import type { Store } from './store' +export interface Services { + store: Store + logins: Logins + chats: Chats + version: string +} + const step = union([lit('welcome'), lit('check'), lit('model'), lit('printer'), lit('ready')]) const thinking = union([lit('off'), lit('minimal'), lit('low'), lit('medium'), lit('high'), lit('xhigh'), lit('max')]) const model = obj({ provider: str(), modelId: str() }) @@ -10,7 +18,7 @@ const printer = obj({ machine: str(), process: str(), filament: str() }) const provider = union([lit('anthropic'), lit('openai-codex'), lit('opencode-go')]) /** Every method the window can call, each parsed at the pipe before it runs. */ -export function handlers(store: Store, logins: Logins, version: string): Handlers { +export function handlers({ store, logins, chats, version }: Services): Handlers { return { 'app.ping': { params: compile(obj({})), run: async () => ({ version }) }, 'state.get': { params: compile(obj({})), run: () => store.state() }, @@ -65,5 +73,28 @@ export function handlers(store: Store, logins: Logins, version: string): Handler return logins.status() }, }, + 'chat.open': { params: compile(obj({ project: str() })), run: ({ project }) => chats.open(project) }, + 'chat.send': { + params: compile(obj({ project: str(), requestId: str({ minLength: 1 }), text: str(), attachments: arr(str()) })), + run: async ({ project, requestId, text, attachments }) => { + await chats.send(project, requestId, text, attachments) + return null + }, + }, + 'chat.stop': { + params: compile(obj({ project: str() })), + run: async ({ project }) => { + await chats.stop(project) + return null + }, + }, + 'chat.retry': { + params: compile(obj({ project: str() })), + run: async ({ project }) => { + await chats.retry(project) + return null + }, + }, + 'agent.tools': { params: compile(obj({ project: str() })), run: ({ project }) => chats.tools(project) }, } } diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index 9f26d73..de712f9 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -48,6 +48,31 @@ export type LoginEvent = | { type: 'login'; provider: ProviderId; step: 'done' } | { type: 'login'; provider: ProviderId; step: 'failed'; detail: string } +/** One committed line of a chat. Tool calls fold under their assistant line; each result is its own line. */ +export type ChatEntry = + | { id: number; kind: 'user'; text: string; images: string[] } + | { id: number; kind: 'assistant'; text: string; calls: { id: string; name: string; args: unknown }[] } + | { id: number; kind: 'tool'; callId: string; name: string; text: string; failed: boolean } + +/** Why a turn stopped before its answer, and the one thing that lets it go on. */ +export type Waiting = + | { kind: 'login'; provider: string; name: string; fix: string } + | { kind: 'error'; detail: string; fix: string } + +/** What a turn runs with, frozen when it was sent. */ +export type Turn = { + requestId: string + model: ModelChoice + thinking: Thinking + tools: string[] + instructions: string + app: string + startedMs: number +} + +/** A chat as the window shows it: committed entries, and whether a turn runs, waits, or neither. */ +export type ChatView = { project: string; entries: ChatEntry[]; running: boolean; waiting: Waiting | null; turn: Turn | null } + export type AppState = { setup: Setup; settings: Settings; projects: Project[]; drafts: Record } /** What the window can ask the sidecar, by method name. */ @@ -64,6 +89,13 @@ export interface Methods { 'auth.login': { params: { provider: ProviderId }; result: null } 'auth.answer': { params: { provider: ProviderId; text: string }; result: null } 'auth.logout': { params: { provider: ProviderId }; result: Login[] } + 'chat.open': { params: { project: string }; result: ChatView } + /** `requestId` is the window's own id for this message: sending it again never sends twice. */ + 'chat.send': { params: { project: string; requestId: string; text: string; attachments: string[] }; result: null } + 'chat.stop': { params: { project: string }; result: null } + 'chat.retry': { params: { project: string }; result: null } + /** The tools a project's agent is offered, as Pi resolves them. */ + 'agent.tools': { params: { project: string }; result: string[] } } export type Method = keyof Methods @@ -96,6 +128,7 @@ export interface SliceResult { export type SidecarEvent = | { type: 'ready'; version: string } | LoginEvent + | ({ type: 'chat' } & ChatView) | { type: 'engine'; state: 'restarting' | 'stopped'; detail: string } /** Rust to sidecar. */ diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 39b3bf3..fd825cf 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -9,10 +9,10 @@ import { join } from 'node:path' import { DatabaseSync } from 'node:sqlite' import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { Models } from '@earendil-works/pi-ai' -import { type Conversation, defineDoc, Harness, type Registry } from '@earendil-works/pi-durable' +import { type Conversation, defineDoc, type HarnessOptions, Harness, type Registry } from '@earendil-works/pi-durable' import { openNodeSqliteStorage } from '@earendil-works/pi-durable/storage/sqlite/node' import type { LoginIndex } from './inference' -import type { AppState, Draft, LoginKind, Project, Settings, Setup } from './protocol' +import type { AppState, Draft, LoginKind, Project, Settings, Setup, Turn, Waiting } from './protocol' const context = BACKGROUND_CONTEXT @@ -28,7 +28,7 @@ const SetupDoc = defineDoc({ checkpointWhen: always, }) -const SettingsDoc = defineDoc({ +export const SettingsDoc = defineDoc({ kind: 'm3d.settings', version: 1, scope: 'conversation', @@ -66,6 +66,31 @@ export const DraftsDoc = defineDoc<{ drafts: Record }>({ checkpointWhen: always, }) +/** What a project's current turn runs with, frozen in the commit that sent it. */ +export const TurnDoc = defineDoc<{ turn: Turn | null }>({ + kind: 'm3d.turn', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + initial: () => ({ turn: null }), + checkpointWhen: always, +}) + +/** + * Why a project's turn stopped before its answer, and the run failure that was read, so one failure is read once. + * Cleared in the commit that sends, retries, or resumes. + */ +export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | null }>({ + kind: 'm3d.waiting', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + initial: () => ({ waiting: null, failure: null }), + checkpointWhen: always, +}) + /** Which providers have a login in the Keychain. Never the secret itself. */ const LoginsDoc = defineDoc<{ logins: Record }>({ kind: 'm3d.logins', @@ -114,6 +139,8 @@ export interface StoreOptions { projectsDir: string models: Models registry: Registry + /** The files and processes a conversation's tools work in. */ + env: NonNullable } export class Store implements LoginIndex { @@ -133,7 +160,7 @@ export class Store implements LoginIndex { mkdirSync(options.dataDir, { recursive: true }) const lock = own(options.dataDir) const storage = await openStorage(join(options.dataDir, 'store.sqlite')) - const harness = await Harness.open(storage, { models: options.models, registry: options.registry }, context) + const harness = await Harness.open(storage, { models: options.models, registry: options.registry, env: options.env }, context) const root = await harness.root(context) return new Store(lock, harness, root, options.projectsDir) } @@ -210,6 +237,22 @@ export class Store implements LoginIndex { return project } + async projects(): Promise { + return (await this.harness.snapshot(ProjectsDoc, this.root.id, context))?.projects ?? [] + } + + async project(id: string): Promise { + const project = (await this.projects()).find((p) => p.id === id) + if (project === undefined) throw new Error(`no project ${id}`) + return project + } + + async projectOf(conversation: number): Promise { + const project = (await this.projects()).find((p) => p.conversation === conversation) + if (project === undefined) throw new Error(`conversation ${conversation} is no project's`) + return project.id + } + async logins(): Promise> { return (await this.harness.snapshot(LoginsDoc, this.root.id, context))?.logins ?? {} } diff --git a/sidecar/src/text.d.ts b/sidecar/src/text.d.ts new file mode 100644 index 0000000..876c62b --- /dev/null +++ b/sidecar/src/text.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const text: string + export default text +} diff --git a/sidecar/src/transcript.ts b/sidecar/src/transcript.ts new file mode 100644 index 0000000..d2dc46b --- /dev/null +++ b/sidecar/src/transcript.ts @@ -0,0 +1,42 @@ +/** + * The chat the window shows, read from committed entries only. A partial answer or running output is never shown, so + * nothing on screen can vanish after a crash. + */ +import type { EntryRecord } from '@earendil-works/pi-durable' +import type { ChatEntry } from './protocol' + +type Content = string | readonly ({ type: string; text?: string } | { type: 'toolCall'; id: string; name: string; arguments: unknown })[] + +/** The text of a message, whether it is a string or blocks. */ +export function textOf(content: Content | undefined): string { + if (content === undefined) return '' + if (typeof content === 'string') return content + return content.flatMap((block) => ('text' in block && typeof block.text === 'string' ? [block.text] : [])).join('\n') +} + +/** One committed entry as the chat shows it, or nothing for entries only the model reads. */ +export function chatEntry(entry: EntryRecord): ChatEntry | undefined { + const message = entry.model?.[0] + switch (entry.kind) { + case 'm3d.user': { + const data = entry.data as { text: string; images: string[] } + return { id: entry.id, kind: 'user', text: data.text, images: data.images } + } + case 'pi.assistant': { + if (message?.role !== 'assistant') return undefined + const calls = message.content.flatMap((block) => + block.type === 'toolCall' ? [{ id: block.id, name: block.name, args: block.arguments }] : [], + ) + const text = textOf(message.content) + // A request that failed leaves an empty answer behind. The waiting state says what happened. + if (text === '' && calls.length === 0) return undefined + return { id: entry.id, kind: 'assistant', text, calls } + } + case 'pi.tool-result': { + if (message?.role !== 'toolResult') return undefined + return { id: entry.id, kind: 'tool', callId: message.toolCallId, name: message.toolName, text: textOf(message.content), failed: message.isError } + } + default: + return undefined + } +} diff --git a/sidecar/src/workflow.md b/sidecar/src/workflow.md new file mode 100644 index 0000000..571c2e0 --- /dev/null +++ b/sidecar/src/workflow.md @@ -0,0 +1,31 @@ +# Materialize 3D + +You are working inside Materialize 3D, a desktop app. Ossie talks to you here to design parts he prints on his Bambu Lab P2S. The end of every job is a print-ready 3MF file. Everything else supports that. + +Your working directory is this chat's project folder, and your tools reach only that folder. The app shows Ossie every file you write here: + +- An HTML file renders as a page in the app's preview panel, scripts included. Inline your scripts (for example three.js) or put them beside the page with relative paths. The preview blocks the internet, so a page loads only files in this folder and code inlined in it. +- An STL, 3MF, GLB, or glTF file opens in a 3D orbit viewer. +- PNG, JPG, and SVG files show as pictures. +- The newest file opens on its own, so write the file you want Ossie to look at last. + +Pictures Ossie drops into the chat are saved in `refs/` and attached to his message. Use them as references for shape and measurements. + +## Steps + +1. **Parameters.** Write the design as numbers in `params.json`. Mark a measurement you do not know as `"TBD"` with your best estimate beside it, so the person does not have to measure before they see anything. Ask only for what you cannot estimate. +2. **Model and fit checks.** Write Python build scripts in `build/` that read `params.json`. Build the parts with manifold3d, through the `run` tool: `run` with `["run", "--with", "manifold3d", "--with", "numpy", "python", "build/model.py"]`. Check that the parts are manifold, that parts that should not touch do not overlap, and that clearances match the parameters. Say what you checked and the numbers. +3. **Preview.** Export the parts to STL (or GLB) in the project folder so the person can turn them in the viewer. When they ask to see a model before the 3MF, this is the step they mean. +4. **HTML guide.** Write a self-contained HTML page: a technical drawing with dimensions, an interactive three.js assembly guide, or both, as the job needs. +5. **Slice to 3MF.** Slice the STL files with the `slice` tool into `print/`, for example `inputs: ["cube.stl"]`, `output: "print/cube_P2S_PETG.3mf"`. It slices with the validated Bambu Studio for the P2S with a 0.4 mm nozzle, standard 0.20 mm layers, and PETG, and says the print time and filament. If it says Bambu Studio is not installed, say so plainly and leave the STL files ready. + +Keep each step's script, so a change to `params.json` rebuilds everything. + +## Astra, the GPT helper + +You can hand a task to Astra with the `astra` tool. Astra suits drawings and second opinions. Astra works in this same folder with the same file tools, but cannot see this chat, so the task must say everything it needs: what to do, the files to read, and the file to write. Check what Astra writes before you show it. + +## Rules + +- Keep your messages short and plain. Lead with what changed and what Ossie should look at. +- Never invent measurements for parts Ossie owns. Estimate, mark it TBD, and say so. diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts new file mode 100644 index 0000000..569a08b --- /dev/null +++ b/sidecar/test/agent.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +type App = ReturnType + +/** A scratch store, project root, and a `uv` that runs what follows `uv run`, so tests need neither uv nor Python. */ +function scratch() { + const dir = mkdtempSync(join(tmpdir(), 'm3d-agent-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}` } } +} + +function start(where: ReturnType, script: string): App { + return spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env }) +} + +async function newProject(app: App, modelId = 'faux-a'): Promise { + await app.call('settings.set', { model: { provider: 'faux', modelId } }) + const { id } = (await app.call('projects.create', { title: 'A 20 mm cube' })) as { id: string } + await app.call('chat.open', { project: id }) + return id +} + +const hasAnswer = (entries: ChatEntry[], text: string) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) + +/** Waits until the chat holds this answer: already in the committed chat, or in a later change. */ +async function answered(app: App, project: string, text: string): Promise { + const view = (await app.call('chat.open', { project })) as ChatView + if (hasAnswer(view.entries, text)) return + await app.until((event) => event.type === 'chat' && event.project === project && hasAnswer(event.entries as ChatEntry[], text)) +} + +async function waitForFile(path: string, ms = 15_000) { + const deadline = Date.now() + ms + while (!existsSync(path)) { + if (Date.now() > deadline) throw new Error(`${path} never appeared`) + await Bun.sleep(25) + } +} + +const send = (app: App, project: string, requestId: string, text: string) => app.call('chat.send', { project, requestId, text, attachments: [] }) + +describe('a send', () => { + test('commits the message, the settings its turn runs with, and the draft removal together, and only once', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + await app.call('drafts.set', { key: project, text: 'A 20 mm cube', attachments: [] }) + + await send(app, project, 'r1', 'A 20 mm cube') + expect(((await app.call('state.get')) as { drafts: object }).drafts).toEqual({}) + const view = (await app.call('chat.open', { project })) as ChatView + expect(view.entries[0]).toMatchObject({ kind: 'user', text: 'A 20 mm cube' }) + expect(view.turn).toMatchObject({ + requestId: 'r1', + model: { provider: 'faux', modelId: 'faux-a' }, + thinking: 'high', + tools: ['read', 'write', 'edit', 'run', 'slice', 'astra'], + app: '9.9.9', + }) + await answered(app, project, 'answered by faux-a') + + await send(app, project, 'r1', 'A 20 mm cube') + const again = (await app.call('chat.open', { project })) as ChatView + expect(again.entries.filter((entry) => entry.kind === 'user')).toHaveLength(1) + + // A send while a turn runs is refused whole: no message, and the draft stays. + await send(app, project, 'r2', 'please wait') + await waitForFile(join(where.projects, project, 'started')) + await app.call('drafts.set', { key: project, text: 'make it red', attachments: [] }) + await expect(send(app, project, 'r3', 'make it red')).rejects.toThrow('The agent is still working on the last message.') + expect(((await app.call('state.get')) as { drafts: Record }).drafts[project]).toEqual({ text: 'make it red', attachments: [] }) + const refused = (await app.call('chat.open', { project })) as ChatView + expect(refused.entries.filter((entry) => entry.kind === 'user').map((entry) => entry.kind === 'user' && entry.text)).toEqual(['A 20 mm cube', 'please wait']) + writeFileSync(join(where.projects, project, 'go'), '') + await answered(app, project, 'answered by faux-a') + await app.quit() + }) +}) + +describe('a crash mid-turn', () => { + test('the reopened sidecar finishes the turn and never repeats a finished tool call', async () => { + const where = scratch() + const first = start(where, 'ops') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1', 'Build it') + const log = join(where.projects, project, 'ops.log') + await waitForFile(log) + while (!readFileSync(log, 'utf8').includes('op2')) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + + const reopened = start(where, 'ops') + await reopened.next() + await reopened.call('chat.open', { project }) + writeFileSync(join(where.projects, project, 'go'), '') + await answered(reopened, project, 'done') + + // op1 finished before the crash and ran once. op2 was cut off mid-run, so it ran again from its start. + expect(readFileSync(log, 'utf8').trim().split('\n')).toEqual(['op1', 'op2', 'op2']) + const view = (await reopened.call('chat.open', { project })) as ChatView + expect(view.entries.map((entry) => entry.kind)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'assistant']) + expect(view.running).toBe(false) + await reopened.quit() + }) +}) + +describe('settings', () => { + test('a change mid-turn leaves the running turn alone, and the next turn uses it', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app, 'faux-a') + await send(app, project, 'r1', 'please wait') + await waitForFile(join(where.projects, project, 'started')) + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-b' } }) + writeFileSync(join(where.projects, project, 'go'), '') + await answered(app, project, 'answered by faux-a') + expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-a') + + await send(app, project, 'r2', 'and now?') + await answered(app, project, 'answered by faux-b') + expect(((await app.call('chat.open', { project })) as ChatView).turn?.model.modelId).toBe('faux-b') + await app.quit() + }) +}) + +describe('the agent', () => { + test('is offered only the app tools, and they never reach outside the project folder', async () => { + const where = scratch() + const app = start(where, 'escape') + await app.next() + const project = await newProject(app) + expect(await app.call('agent.tools', { project })).toEqual(['read', 'write', 'edit', 'run', 'slice', 'astra']) + await send(app, project, 'r1', 'read the hostname') + await answered(app, project, 'tried') + const view = (await app.call('chat.open', { project })) as ChatView + const read = view.entries.find((entry) => entry.kind === 'tool') + expect(read).toMatchObject({ kind: 'tool', name: 'read', failed: true }) + expect(read?.kind === 'tool' && read.text).toContain('outside the project folder') + await app.quit() + }) +}) diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts new file mode 100644 index 0000000..34a8213 --- /dev/null +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -0,0 +1,54 @@ +/** + * The sidecar with a scripted model beside the app's providers, for tests: provider `faux`, models `faux-a` and + * `faux-b`. `M3D_SCRIPT` picks the script. Each answer depends only on the transcript, so a sidecar that restarts + * mid-turn goes on with the same script. + */ +import { type AssistantMessage, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, type Message, type Model } from '@earendil-works/pi-ai' +import type { JsonObject } from '@earendil-works/pi-durable' +import { runSidecar } from '../../src/app' + +/** The messages since the person's newest message, and that message's text. */ +function sinceUser(messages: Message[]) { + const at = messages.findLastIndex((m) => m.role === 'user') + const user = messages[at] + const text = user === undefined ? '' : typeof user.content === 'string' ? user.content : user.content.map((b) => ('text' in b ? b.text : '')).join('') + return { text, results: messages.slice(at + 1).filter((m) => m.role === 'toolResult') } +} + +/** A step that calls one tool. Its stop reason tells Pi the model waits for the result. */ +const call = (name: string, args: JsonObject) => fauxAssistantMessage(fauxToolCall(name, args), { stopReason: 'toolUse' }) +const run = (script: string) => call('run', { args: ['run', 'sh', '-c', script] }) + +const scripts: Record) => AssistantMessage> = { + /** Two outside actions, then an answer. The second waits for a `go` file, so a test can crash the sidecar in it. */ + ops: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('echo op1 >> ops.log') + if (results.length === 1) return run('echo op2 >> ops.log; while [ ! -f go ]; do sleep 0.05; done') + return fauxAssistantMessage(fauxText('done')) + }, + /** A message with "wait" runs a tool that waits for `go`; every answer names the model that gave it. */ + models: (messages, model) => { + const { text, results } = sinceUser(messages) + if (text.includes('wait') && results.length === 0) return run('touch started; while [ ! -f go ]; do sleep 0.05; done') + return fauxAssistantMessage(fauxText(`answered by ${model.id}`)) + }, + /** Tries to read a file outside the project folder, then reports what the tool said. */ + escape: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('read', { path: '/etc/hostname' }) + return fauxAssistantMessage(fauxText('tried')) + }, +} + +const script = scripts[process.env.M3D_SCRIPT ?? ''] +if (script === undefined) throw new Error(`M3D_SCRIPT must be one of ${Object.keys(scripts).join(', ')}`) + +await runSidecar({ + argv: process.argv.slice(2), + addProviders: (models) => { + const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-a' }, { id: 'faux-b' }] }) + faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) + models.setProvider(faux.provider) + }, +}) diff --git a/sidecar/test/inference.test.ts b/sidecar/test/inference.test.ts index 64e1c6b..e65225e 100644 --- a/sidecar/test/inference.test.ts +++ b/sidecar/test/inference.test.ts @@ -21,7 +21,7 @@ describe('logins', () => { expect(JSON.parse(keychain.get('anthropic') ?? '{}')).toEqual({ type: 'api_key', key: TOKEN, env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' } }) await app.quit() - const stderr = await new Response(app.child.stderr).text() + const stderr = await app.stderr() expect(stderr).not.toContain(TOKEN) const files = readdirSync(join(dir, 'state')) expect(files).toContain('store.sqlite') diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 74d46e4..506b7b2 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -1,29 +1,27 @@ +type Message = Record + /** * The sidecar as Rust runs it: a child process spoken to over stdin and stdout, one JSON object per line. The test - * plays Rust: it answers the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. + * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it, and it answers + * the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. */ -export function spawnSidecar(dataDir: string, projectsDir: string, keychain = new Map()) { - const child = Bun.spawn(['bun', 'src/main.ts', '--app-version', '9.9.9', '--data', dataDir, '--projects', projectsDir], { +export function spawnSidecar( + dataDir: string, + projectsDir: string, + keychain = new Map(), + options: { script?: string; env?: Record } = {}, +) { + const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' + const child = Bun.spawn(['bun', entry, '--app-version', '9.9.9', '--data', dataDir, '--projects', projectsDir], { cwd: `${import.meta.dir}/..`, + env: { ...process.env, ...options.env, ...(options.script === undefined ? {} : { M3D_SCRIPT: options.script }) }, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', }) - const lines = (async function* () { - let buffered = '' - for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { - buffered += chunk - let end = buffered.indexOf('\n') - while (end >= 0) { - const message = JSON.parse(buffered.slice(0, end)) as Record - buffered = buffered.slice(end + 1) - end = buffered.indexOf('\n') - if (message.kind === 'request') answer(message) - else yield message - } - } - })() - function answer(request: Record) { + const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) + + function answer(request: Message) { const params = request.params as { account: string; secret?: string } const value = request.method === 'keychain.get' @@ -33,19 +31,54 @@ export function spawnSidecar(dataDir: string, projectsDir: string, keychain = ne : request.method === 'keychain.delete' ? (keychain.delete(params.account), null) : undefined - send(value === undefined ? { kind: 'answer', id: request.id, ok: false, error: `no ${String(request.method)} here` } : { kind: 'answer', id: request.id, ok: true, value }) + send( + value === undefined + ? { kind: 'answer', id: request.id, ok: false, error: `no ${String(request.method)} here` } + : { kind: 'answer', id: request.id, ok: true, value }, + ) + } + + // Every line the sidecar writes, but its requests, waits here until a test reads it. `undefined` marks the end. + const inbox: (Message | undefined)[] = [] + let wake: (() => void) | undefined + void (async () => { + let buffered = '' + for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) { + buffered += chunk + for (let end = buffered.indexOf('\n'); end >= 0; end = buffered.indexOf('\n')) { + const message = JSON.parse(buffered.slice(0, end)) as Message + buffered = buffered.slice(end + 1) + if (message.kind === 'request') answer(message) + else inbox.push(message) + } + wake?.() + } + inbox.push(undefined) + wake?.() + })() + let errors = '' + const stderrDone = (async () => { + for await (const chunk of child.stderr.pipeThrough(new TextDecoderStream())) errors += chunk + })() + + /** The next message, waiting for it when there is none yet. */ + async function take(): Promise { + while (inbox.length === 0) await new Promise((resolve) => (wake = resolve)) + const message = inbox.shift() + if (message === undefined) inbox.unshift(undefined) + return message } + /** Messages a call read past while it waited for its reply, in order, for `next`. */ - const skipped: Record[] = [] - const next = async () => skipped.shift() ?? (await lines.next()).value - const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) + const skipped: Message[] = [] + const next = async () => skipped.shift() ?? (await take()) let calls = 0 /** Calls one method and returns its value, or throws its error. */ const call = async (method: string, params: unknown = {}) => { const id = ++calls send({ kind: 'call', id, method, params }) for (;;) { - const message = (await lines.next()).value + const message = await take() if (message === undefined) throw new Error(`the sidecar ended before it answered ${method}`) if (message.kind !== 'reply' || message.id !== id) { skipped.push(message) @@ -55,9 +88,25 @@ export function spawnSidecar(dataDir: string, projectsDir: string, keychain = ne return message.value } } + /** Reads events until one matches, or fails after `ms`. */ + const until = async (match: (event: Message) => boolean, ms = 15_000) => { + const deadline = Date.now() + ms + for (;;) { + const message = await Promise.race([next(), Bun.sleep(Math.max(0, deadline - Date.now())).then(() => 'timeout' as const)]) + if (message === 'timeout') throw new Error('no matching event in time') + if (message === undefined) throw new Error('the sidecar ended first') + if (message.kind === 'event' && match(message.event as Message)) return message.event as Message + } + } const quit = async () => { child.stdin.end() return child.exited } - return { child, next, send, call, quit } + /** Everything the sidecar logged, once it has exited. */ + const stderr = async () => { + await child.exited + await stderrDone + return errors + } + return { child, next, send, call, until, quit, stderr } } diff --git a/sidecar/test/store.test.ts b/sidecar/test/store.test.ts index d8d4ab2..6aed12f 100644 --- a/sidecar/test/store.test.ts +++ b/sidecar/test/store.test.ts @@ -33,7 +33,7 @@ describe('one writer', () => { const second = spawnSidecar(data, projects) expect(await second.child.exited).toBe(EXIT.storeInUse) - expect(await new Response(second.child.stderr).text()).toContain('Another copy of Materialize 3D owns the store.') + expect(await second.stderr()).toContain('Another copy of Materialize 3D owns the store.') first.child.kill('SIGKILL') await first.child.exited diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts new file mode 100644 index 0000000..48d2da1 --- /dev/null +++ b/sidecar/test/waiting.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +/** A proxy that speaks Anthropic's Messages API, as Ossie's Mac reaches Anthropic, and remembers who called. */ +function anthropicProxy(reply: string) { + const seen: { authorization: string | null; path: string }[] = [] + const event = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n` + const server = Bun.serve({ + port: 0, + fetch(request) { + seen.push({ authorization: request.headers.get('authorization'), path: new URL(request.url).pathname }) + const body = + event('message_start', { + type: 'message_start', + message: { id: 'msg_1', type: 'message', role: 'assistant', model: 'claude-opus-5-5', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 10, output_tokens: 0 } }, + }) + + event('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) + + event('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: reply } }) + + event('content_block_stop', { type: 'content_block_stop', index: 0 }) + + event('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 3 } }) + + event('message_stop', { type: 'message_stop' }) + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }) + }, + }) + return { url: `http://127.0.0.1:${server.port}`, seen, stop: () => server.stop(true) } +} + +describe('a turn whose provider is not signed in', () => { + test('waits with the fix named, through a restart, and goes on once the person signs in', async () => { + const proxy = anthropicProxy('Hello from the proxy') + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const keychain = new Map() + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await first.next() + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const waiting = await first.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toEqual({ + kind: 'login', + provider: 'anthropic', + name: 'Anthropic', + fix: 'Sign in to Anthropic. The message picks up where it stopped.', + }) + await first.quit() + + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await second.next() + const reopened = (await second.call('chat.open', { project })) as ChatView + expect(reopened.waiting?.kind).toBe('login') + expect(reopened.entries).toEqual([{ id: expect.any(Number), kind: 'user', text: 'A 20 mm cube', images: [] }]) + + await second.call('auth.setKey', { provider: 'anthropic', key: 'proxy-token', baseUrl: proxy.url }) + const answered = await second.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello from the proxy' }) + expect(answered.waiting).toBeNull() + expect(proxy.seen).toEqual([{ authorization: 'Bearer proxy-token', path: '/v1/messages' }]) + + const view = (await second.call('chat.open', { project })) as ChatView + expect(view.entries.map((entry) => entry.kind)).toEqual(['user', 'assistant']) + await second.quit() + proxy.stop() + }) +}) From c51a596496b668738ca689ad236303f74b74705f Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 6 Nov 2026 22:49:53 -0600 Subject: [PATCH 02/17] fix(sidecar): turns keep waiting with the fix named in every case, and tools stay in their folder - Every chat is watched from start, so a resumed run that fails after a restart waits with its fix even when no one opened it. - Astra without its ChatGPT sign-in holds the turn with the fix named on the chat, and goes on from where it stopped once the person signs in. - A turn that ran on another app version stops after an update instead of going on with code it did not start with. Going on records the new version. - run and slice keep a receipt per operation id: Pi's memo, then a receipt file the action writes the moment it ends. run passes M3D_OPERATION_ID on. - The file tools check where a path really leads, so a symbolic link out of the project is refused. - run gets only PATH, HOME, USER, LANG, and TMPDIR, and its timeout is in seconds, as Pi expects. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 128 ++++++++++++---- sidecar/src/app.ts | 9 +- sidecar/src/chat.ts | 92 ++++++++++-- sidecar/src/inference.ts | 5 + sidecar/src/store.ts | 9 +- sidecar/src/workflow.md | 2 +- sidecar/test/fixtures/scripted-sidecar.ts | 43 +++++- sidecar/test/sidecar.ts | 4 +- sidecar/test/tools.test.ts | 174 ++++++++++++++++++++++ 9 files changed, 411 insertions(+), 55 deletions(-) create mode 100644 sidecar/test/tools.test.ts diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index efba5c6..f195a73 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -1,13 +1,15 @@ /** - * The agent's own tools, and nothing of the person's agent setup. Every tool works in the project folder only. + * The agent's own tools, and nothing of the person's agent setup. The file tools work in the project folder only. * - * Tools that act outside the store (`run`, `slice`, `astra`) are replay-safe and keep a receipt: a call that finished - * its action records the result before it returns, so a rerun after a crash hands back the receipt instead of acting - * again. A crash between the action and its receipt still repeats that one action, because no checkpoint can make an - * outside action run exactly once. + * Tools that act outside the store (`run`, `slice`) are replay-safe and keep a receipt per operation, under a stable + * operation id (the tool call's id). The action writes its receipt file the moment it ends, and the call records it in + * Pi before it returns, so a rerun after a crash hands back the result instead of acting again. A crash between the + * end of an action and its receipt file still repeats that one action, because no checkpoint can make an outside + * action run exactly once. `run` passes the id on as `M3D_OPERATION_ID`, for a script that wants to check for itself. */ import { createHash } from 'node:crypto' -import { isAbsolute, relative } from 'node:path' +import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative } from 'node:path' import type { Context, JsonValue } from '@earendil-works/chord' import { AssistantEntry, @@ -22,6 +24,7 @@ import { NodeExecutionEnv } from '@earendil-works/pi-durable/env/node' import { createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-durable/tools' import { arr, int, obj, opt, str } from '@earendil-works/pi-schema' import type { Host } from './host' +import { isLoginFailure } from './inference' import { textOf } from './transcript' import workflow from './workflow.md' with { type: 'text' } @@ -35,12 +38,31 @@ export const ASTRA_MODEL = { provider: 'openai-codex', modelId: 'gpt-6-astra' } /** How much of a command's output the model gets back: the end, where errors are. */ const OUTPUT_TAIL = 20_000 -/** One project's files and processes. Any path that leaves the project folder is refused, for every file tool. */ +/** The only variables a command the agent runs gets. */ +const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const + +/** + * Where `path` really is: the real path of its nearest folder that exists, with the rest of the path after it. A + * symbolic link on the way is followed, so a path that leads out of the project through one is seen to. + */ +function realPath(path: string): string { + const rest: string[] = [] + let at = path + while (!existsSync(at)) { + rest.unshift(basename(at)) + const up = dirname(at) + if (up === at) break + at = up + } + return join(realpathSync(at), ...rest) +} + +/** One project's files and processes. A path that leads out of the project folder is refused, for every file tool. */ export class ProjectEnv extends NodeExecutionEnv { override async absolutePath(path: string, context: Context): Promise> { const resolved = await super.absolutePath(path, context) if (!resolved.ok) return resolved - const inside = relative(this.cwd, resolved.value) + const inside = relative(realpathSync(this.cwd), realPath(resolved.value)) if (inside.startsWith('..') || isAbsolute(inside)) { return err(new FileError('permission_denied', `${path} is outside the project folder`, path)) } @@ -48,23 +70,47 @@ export class ProjectEnv extends NodeExecutionEnv { } } -/** Does `act` once for this tool call. A rerun after a crash gets the first run's receipt back. */ -async function once(api: ToolExecutionApi, context: Context, act: () => Promise): Promise { - const receipt = await api.memo('receipt', context) - if (receipt !== undefined) return receipt - return api.memo('receipt', await act(), context) +/** Pi's record of one operation's result, kept by its tool call. */ +export interface Memo { + get(): Promise + set(value: T): Promise +} + +/** + * The result of one outside action, done once per operation: the result Pi kept, else the receipt file the action + * left when it ended, else the action itself, whose receipt file is written the moment it ends. + */ +export async function once(memo: Memo, receipt: string, act: () => Promise): Promise { + const kept = await memo.get() + if (kept !== undefined) return kept + if (existsSync(receipt)) return memo.set(JSON.parse(readFileSync(receipt, 'utf8')) as T) + const result = await act() + mkdirSync(dirname(receipt), { recursive: true }) + writeFileSync(`${receipt}.new`, JSON.stringify(result)) + renameSync(`${receipt}.new`, receipt) + return memo.set(result) } const text = (value: string) => [{ type: 'text' as const, text: value }] export interface AgentHost { host: Host - /** The project a conversation belongs to. */ - projectOf(conversation: number): Promise + /** The project a conversation belongs to, and its folder. */ + projectOf(conversation: number): Promise<{ id: string; folder: string }> + /** Holds a tool until the person signs in to `provider`, with the fix named on the chat. A stop ends the wait. */ + waitForLogin(conversation: number, provider: string, context: Context): Promise +} + +/** A tool call's operation: its id, its receipt file in the project's hidden folder, and Pi's memo of it. */ +async function operation(api: ToolExecutionApi, context: Context, projectOf: AgentHost['projectOf']) { + const project = await projectOf(api.conversationId) + const id = api.callId.replace(/[^A-Za-z0-9_-]/g, '_') + const memo: Memo = { get: () => api.memo('receipt', context), set: (value) => api.memo('receipt', value, context) } + return { id, project, memo, receipt: join(project.folder, '.m3d', 'ops', `${id}.json`) } } /** The agent's tools, in the order the model is offered them. */ -export function agentTools({ host, projectOf }: AgentHost) { +export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { const read = createReadTool() const write = { ...createWriteTool(), replay: 'safe' as const } const edit = createEditTool() @@ -79,12 +125,20 @@ export function agentTools({ host, projectOf }: AgentHost) { execute: async ({ args, timeoutSeconds }, api, context) => { const env = api.env if (env === undefined) throw new Error('this conversation has no project folder') - const receipt = await once(api, context, async () => { + const op = await operation<{ exitCode: number; output: string }>(api, context, projectOf) + const vars: Record = { M3D_OPERATION_ID: op.id } + for (const name of COMMAND_ENV) { + const value = process.env[name] + if (value !== undefined) vars[name] = value + } + const receipt = await once(op.memo, op.receipt, async () => { let output = '' const ran = await env.exec( ['uv', ...args], { - timeout: (timeoutSeconds ?? 600) * 1000, + env: vars, + inheritEnv: false, + timeout: timeoutSeconds ?? 600, onOutput: (chunk) => { output = (output + chunk).slice(-OUTPUT_TAIL) api.output(chunk) @@ -106,17 +160,19 @@ export function agentTools({ host, projectOf }: AgentHost) { parameters: obj({ inputs: arr(str(), { minItems: 1 }), output: str() }), replay: 'safe', execute: async ({ inputs, output }, api, context) => { - const project = await projectOf(api.conversationId) - const sliced = await once(api, context, async () => { + type Sliced = { ok: true; parts: number; printSeconds: number; filamentGrams: number } | { ok: false; error: string } + const op = await operation(api, context, projectOf) + const sliced = await once(op.memo, op.receipt, async () => { try { - return { ok: true, ...(await host.request('slice', { project, inputs, output })) } + const { parts, printSeconds, filamentGrams } = await host.request('slice', { project: op.project.id, inputs, output }) + return { ok: true, parts, printSeconds, filamentGrams } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } }) if (!sliced.ok) return { output: text(`The slice failed: ${sliced.error}`), isError: true } - const minutes = Math.round(Number(sliced.printSeconds) / 60) - return { output: text(`Wrote ${output}: ${sliced.parts} part(s), about ${minutes} min and ${Number(sliced.filamentGrams).toFixed(1)} g of filament.`) } + const minutes = Math.round(sliced.printSeconds / 60) + return { output: text(`Wrote ${output}: ${sliced.parts} part(s), about ${minutes} min and ${sliced.filamentGrams.toFixed(1)} g of filament.`) } }, }) @@ -128,6 +184,7 @@ export function agentTools({ host, projectOf }: AgentHost) { parameters: obj({ task: str({ minLength: 1 }) }), replay: 'safe', execute: async ({ task }, api, context) => { + const signedIn = async () => (await api.models.getAuth(ASTRA_MODEL.provider).catch(() => undefined)) !== undefined // Astra's conversation is owned by this call, so a rerun finds the same one and the same request. const child = await api.commit(async (tx) => { const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0] @@ -139,15 +196,24 @@ export function agentTools({ host, projectOf }: AgentHost) { await api.details({ astra: child }, context) const conversation = await api.conversation(child, context) if (conversation === undefined) throw new Error("Astra's conversation is gone") - const submission = await conversation.submit({ type: 'input', content: task, requestId: `astra:${api.taskId}` }, context) - const settled = await submission.wait(context) - if (settled.status !== 'done' || settled.type !== 'input') { - const why = settled.status === 'unanswered' ? `${settled.reason}${settled.detail === undefined ? '' : `: ${settled.detail}`}` : settled.status - return { output: text(`Astra did not answer (${why}).`), isError: true } + // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. + if (!(await signedIn())) await waitForLogin(api.conversationId, ASTRA_MODEL.provider, context) + let submission = await conversation.submit({ type: 'input', content: task, requestId: `astra:${api.taskId}` }, context) + for (;;) { + const settled = await submission.wait(context) + if (settled.status === 'done' && settled.type !== 'write') { + const { answer } = settled + const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context) + return { output: text(textOf(entry?.model?.[0]?.content) || 'Astra finished without a message.') } + } + const detail = typeof settled.detail === 'string' ? settled.detail : '' + if (settled.reason !== 'model_error' || !(isLoginFailure(detail) || !(await signedIn()))) { + return { output: text(`Astra did not answer (${settled.reason}${detail === '' ? '' : `: ${detail}`}).`), isError: true } + } + // The sign-in was missing or refused: wait for it, then Astra goes on from where it stopped. + await waitForLogin(api.conversationId, ASTRA_MODEL.provider, context) + submission = await conversation.submit({ type: 'continue' }, context) } - const { answer } = settled - const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context) - return { output: text(textOf(entry?.model?.[0]?.content) || 'Astra finished without a message.') } }, }) diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 72998d9..959d5e2 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -41,7 +41,14 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis addProviders?.(models) const registry = createRegistry() - const tools = agentTools({ host, projectOf: (conversation) => store.projectOf(conversation) }) + const tools = agentTools({ + host, + projectOf: async (conversation) => { + const id = await store.projectOf(conversation) + return { id, folder: store.projectFolder(id) } + }, + waitForLogin: (conversation, provider, context) => chats.waitForLogin(conversation, provider, context), + }) const extension = agentExtension(tools) registry.install(extension) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 60d7ea8..809b037 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -6,7 +6,7 @@ import { mkdirSync, readFileSync, statSync } from 'node:fs' import { extname, isAbsolute, join, relative } from 'node:path' import type { Context } from '@earendil-works/chord' -import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context' +import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context' import type { ImageContent, Models, TextContent } from '@earendil-works/pi-ai' import { type Conversation, @@ -23,6 +23,7 @@ import { import type { ConversationView } from '@earendil-works/pi-durable' import { WORKFLOW, WORKFLOW_VERSION } from './agent' import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' +import { isLoginFailure } from './inference' import { DraftsDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' import { chatEntry } from './transcript' @@ -32,9 +33,6 @@ const IMAGE_TYPES: Record = { '.png': 'image/png', '.jpg': 'imag /** Models read pictures up to about 5 MB. Larger ones, and HEIC photos, go as a smaller JPEG on macOS. */ const MAX_IMAGE_BYTES = 3_500_000 -/** A failure the provider gave because the login was refused, not missing. */ -const REFUSED_LOGIN = /\b(401|403)\b|unauthori[sz]ed|authentication|invalid (api )?(key|token)|expired/i - export interface ChatsOptions { store: Store models: Models @@ -79,6 +77,8 @@ export class Chats { readonly #watches = new Map>() /** The newest entry each watched chat has sent the window. */ readonly #sent = new Map() + /** Tools waiting for a sign-in, by provider. */ + readonly #signIns = new Map void>>() constructor(options: ChatsOptions) { this.#o = options @@ -152,7 +152,7 @@ export class Chats { const auth = await models.getAuth(provider).catch(() => undefined) const detail = typeof failure.detail === 'string' ? failure.detail : failure.reason const waiting: Waiting = - auth === undefined || (failure.reason === 'model_error' && REFUSED_LOGIN.test(detail)) + auth === undefined || (failure.reason === 'model_error' && isLoginFailure(detail)) ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } : { kind: 'error', detail, fix: 'Try again' } await store.harness.commit(async (tx) => { @@ -197,16 +197,56 @@ export class Chats { await this.#watch(project, conversation) } - /** Goes on from where the turn stopped, with the settings it started with, and clears what it waited on. */ + /** + * Goes on from where the turn stopped, with the model, tools, and instructions it started with, and clears what it + * waited on. The turn records the app version that goes on with it. + */ async #continue(project: string): Promise { + const { store, version } = this.#o const conversation = await this.#conversation(project) - await this.#o.store.harness.commit(async (tx) => { + await store.harness.commit(async (tx) => { await conversation.submit(tx, { type: 'continue' }) ;(await tx.doc(WaitingDoc, conversation.id)).waiting = null + const { turn } = await tx.doc(TurnDoc, conversation.id) + if (turn !== null) turn.app = version }, context) await this.#watch(project, conversation) } + /** + * Holds a running tool until the person signs in to `provider`, with the fix named on its chat. The turn stays where + * it is and goes on once the sign-in is done. A stop ends the wait. + */ + async waitForLogin(conversation: number, provider: string, waiting: Context): Promise { + const { models, store } = this.#o + const id = conversation as ConversationId + const name = models.getProvider(provider)?.name ?? provider + const project = await store.projectOf(conversation) + const hold = (wait: Waiting | null) => + store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, id) + record.waiting = wait + record.tool = wait !== null + }, context) + const { promise, resolve } = Promise.withResolvers() + const waiters = this.#signIns.get(provider) ?? new Set() + this.#signIns.set(provider, waiters.add(resolve)) + await hold({ kind: 'login', provider, name, fix: `Sign in to ${name}. Astra picks up where it stopped.` }) + await this.#show(project) + try { + await awaitWithContext(promise, waiting) + } finally { + waiters.delete(resolve) + await hold(null) + await this.#show(project) + } + } + + /** Sends the window a chat's state, for a change its watch does not see. */ + async #show(project: string): Promise { + await this.#changed(project, await this.#conversation(project)) + } + async retry(project: string): Promise { await this.#continue(project) } @@ -220,30 +260,52 @@ export class Chats { await (await this.#conversation(project)).abort(context) } - /** After a sign-in: every turn that waited on that provider goes on. */ + /** After a sign-in: every tool and every turn that waited on that provider goes on. */ async signedIn(provider: string): Promise { + for (const resolve of this.#signIns.get(provider) ?? []) resolve() for (const project of await this.#o.store.projects()) { const waiting = await this.#o.store.harness.snapshot(WaitingDoc, project.conversation as ConversationId, context) - if (waiting?.waiting?.kind === 'login' && waiting.waiting.provider === provider) await this.#continue(project.id) + if (waiting?.waiting?.kind === 'login' && waiting.waiting.provider === provider && !waiting.tool) await this.#continue(project.id) } } /** - * At start: runs a crash cut off go on from their last committed step, failures not yet read are read, and turns - * that waited on a login that is now there go on. + * At start: a turn that ran on another version of the app stops, waiting for the person to go on with this one, + * instead of going on with code it did not start with. Then runs a crash cut off go on from their last committed + * step, every chat is watched, failures not yet read are read, and turns that waited on a login that is now there + * go on. */ async reconcile(): Promise { - const { store, models } = this.#o + const { store, models, version } = this.#o + const projects = await store.projects() + const outdated: Conversation[] = [] + for (const project of projects) { + const conversation = await this.#conversation(project.id) + const { running, turn } = await this.#state(conversation) + if (running && turn !== null && turn.app !== version) outdated.push(conversation) + } + await Promise.all( + outdated.map(async (conversation) => { + await conversation.abort(context) + await store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, conversation.id) + record.waiting = { kind: 'error', detail: `Materialize 3D was updated to ${version} while this turn ran.`, fix: 'Go on' } + record.tool = false + }, context) + }), + ) store.harness.resume() - for (const project of await store.projects()) { + for (const project of projects) { const conversation = await this.#conversation(project.id) + await this.#watch(project.id, conversation) const state = await this.#state(conversation) const recorded = (await store.harness.snapshot(WaitingDoc, conversation.id, context))?.failure ?? null if (state.failure !== undefined && state.failure.reason !== 'aborted' && state.failure.taskId !== recorded) { await this.#read(conversation, state.failure, state.turn) } - const waiting = (await store.harness.snapshot(WaitingDoc, conversation.id, context))?.waiting - if (waiting?.kind === 'login' && (await models.getAuth(waiting.provider).catch(() => undefined)) !== undefined) { + const record = await store.harness.snapshot(WaitingDoc, conversation.id, context) + const waiting = record?.waiting + if (waiting?.kind === 'login' && !record?.tool && (await models.getAuth(waiting.provider).catch(() => undefined)) !== undefined) { await this.#continue(project.id) } } diff --git a/sidecar/src/inference.ts b/sidecar/src/inference.ts index 18e65b2..768540d 100644 --- a/sidecar/src/inference.ts +++ b/sidecar/src/inference.ts @@ -60,6 +60,11 @@ const credential = compile( ]), ) +/** A provider's answer that means the login itself is missing or refused, not that the request failed. */ +const LOGIN_FAILURE = /not configured|\b(401|403)\b|unauthori[sz]ed|authentication|invalid (api )?(key|token)|expired/i + +export const isLoginFailure = (detail: string) => LOGIN_FAILURE.test(detail) + /** Which providers have a login. Not secret: it lives in the store. */ export interface LoginIndex { logins(): Promise> diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index fd825cf..10085e3 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -78,16 +78,17 @@ export const TurnDoc = defineDoc<{ turn: Turn | null }>({ }) /** - * Why a project's turn stopped before its answer, and the run failure that was read, so one failure is read once. - * Cleared in the commit that sends, retries, or resumes. + * Why a project's turn waits, and the run failure that was read, so one failure is read once. `tool` marks a wait a + * running tool holds, such as Astra's for a sign-in: the tool goes on by itself, so nothing continues the run. Cleared + * in the commit that sends, retries, or resumes, or by the tool once its wait ends. */ -export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | null }>({ +export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | null; tool: boolean }>({ kind: 'm3d.waiting', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', - initial: () => ({ waiting: null, failure: null }), + initial: () => ({ waiting: null, failure: null, tool: false }), checkpointWhen: always, }) diff --git a/sidecar/src/workflow.md b/sidecar/src/workflow.md index 571c2e0..74c731c 100644 --- a/sidecar/src/workflow.md +++ b/sidecar/src/workflow.md @@ -2,7 +2,7 @@ You are working inside Materialize 3D, a desktop app. Ossie talks to you here to design parts he prints on his Bambu Lab P2S. The end of every job is a print-ready 3MF file. Everything else supports that. -Your working directory is this chat's project folder, and your tools reach only that folder. The app shows Ossie every file you write here: +Your working directory is this chat's project folder, and your file tools reach only that folder. The app shows Ossie every file you write here: - An HTML file renders as a page in the app's preview panel, scripts included. Inline your scripts (for example three.js) or put them beside the page with relative paths. The preview blocks the internet, so a page loads only files in this folder and code inlined in it. - An STL, 3MF, GLB, or glTF file opens in a 3D orbit viewer. diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index 34a8213..ec3163b 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -1,7 +1,8 @@ /** * The sidecar with a scripted model beside the app's providers, for tests: provider `faux`, models `faux-a` and * `faux-b`. `M3D_SCRIPT` picks the script. Each answer depends only on the transcript, so a sidecar that restarts - * mid-turn goes on with the same script. + * mid-turn goes on with the same script. OpenAI Codex, Astra's provider, is replaced by a scripted Astra that answers + * only once a key is saved for it, as the real one answers only once the person signs in. */ import { type AssistantMessage, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, type Message, type Model } from '@earendil-works/pi-ai' import type { JsonObject } from '@earendil-works/pi-durable' @@ -18,6 +19,8 @@ function sinceUser(messages: Message[]) { /** A step that calls one tool. Its stop reason tells Pi the model waits for the result. */ const call = (name: string, args: JsonObject) => fauxAssistantMessage(fauxToolCall(name, args), { stopReason: 'toolUse' }) const run = (script: string) => call('run', { args: ['run', 'sh', '-c', script] }) +const resultText = (message: Message | undefined) => + message?.role === 'toolResult' ? message.content.map((b) => ('text' in b ? b.text : '')).join('') : '' const scripts: Record) => AssistantMessage> = { /** Two outside actions, then an answer. The second waits for a `go` file, so a test can crash the sidecar in it. */ @@ -33,6 +36,38 @@ const scripts: Record) => Ass if (text.includes('wait') && results.length === 0) return run('touch started; while [ ! -f go ]; do sleep 0.05; done') return fauxAssistantMessage(fauxText(`answered by ${model.id}`)) }, + /** Hands Astra a task, then repeats what Astra answered. */ + astra: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('astra', { task: 'Draw it as an HTML page.' }) + return fauxAssistantMessage(fauxText(`parent saw: ${resultText(results[0])}`)) + }, + /** Shows the command's environment: whether the sidecar's own variables reach it, and its operation id. */ + env: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('echo "secret=${M3D_SECRET:-none} op=${M3D_OPERATION_ID:+set}"') + return fauxAssistantMessage(fauxText(resultText(results[0]))) + }, + /** A command that runs past its one-second timeout. */ + slow: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('run', { args: ['run', 'sh', '-c', 'sleep 3; touch finished'], timeoutSeconds: 1 }) + return fauxAssistantMessage(fauxText('timed')) + }, + /** Reads, writes, and edits through `linked`, which a test points outside the project. */ + linked: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return call('read', { path: 'linked/secret.txt' }) + if (results.length === 1) return call('write', { path: 'linked/new.txt', content: 'x' }) + if (results.length === 2) return call('edit', { path: 'linked/secret.txt', edits: [{ oldText: 'secret', newText: 'leaked' }] }) + return fauxAssistantMessage(fauxText('tried')) + }, + /** A tool that waits for `go`, then a model request that fails. */ + 'fails-after': (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('touch started; while [ ! -f go ]; do sleep 0.05; done') + return fauxAssistantMessage([], { stopReason: 'error', errorMessage: 'the model broke' }) + }, /** Tries to read a file outside the project folder, then reports what the tool said. */ escape: (messages) => { const { results } = sinceUser(messages) @@ -50,5 +85,11 @@ await runSidecar({ const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-a' }, { id: 'faux-b' }] }) faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) models.setProvider(faux.provider) + const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) + astra.setResponses(Array.from({ length: 50 }, () => () => fauxAssistantMessage(fauxText('astra drew it')))) + models.setProvider({ + ...astra.provider, + auth: { apiKey: { name: 'Astra test key', resolve: async ({ credential }) => (credential?.key === undefined ? undefined : { auth: { apiKey: credential.key } }) } }, + }) }, }) diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 506b7b2..03d581b 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -9,10 +9,10 @@ export function spawnSidecar( dataDir: string, projectsDir: string, keychain = new Map(), - options: { script?: string; env?: Record } = {}, + options: { script?: string; env?: Record; version?: string } = {}, ) { const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' - const child = Bun.spawn(['bun', entry, '--app-version', '9.9.9', '--data', dataDir, '--projects', projectsDir], { + const child = Bun.spawn(['bun', entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { cwd: `${import.meta.dir}/..`, env: { ...process.env, ...options.env, ...(options.script === undefined ? {} : { M3D_SCRIPT: options.script }) }, stdin: 'pipe', diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts new file mode 100644 index 0000000..186b11d --- /dev/null +++ b/sidecar/test/tools.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from 'bun:test' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { once } from '../src/agent' +import type { ChatEntry, ChatView } from '../src/protocol' +import { spawnSidecar } from './sidecar' + +type App = ReturnType + +function scratch(env: Record = {}) { + const dir = mkdtempSync(join(tmpdir(), 'm3d-tools-')) + const bin = join(dir, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'uv'), '#!/bin/sh\n[ "$1" = run ] && shift\nexec "$@"\n') + chmodSync(join(bin, 'uv'), 0o755) + return { dir, data: join(dir, 'state'), projects: join(dir, 'projects'), env: { PATH: `${bin}:${process.env.PATH}`, ...env } } +} + +const start = (where: ReturnType, script: string, version?: string) => + spawnSidecar(where.data, where.projects, new Map(), { script, env: where.env, ...(version === undefined ? {} : { version }) }) + +async function newProject(app: App) { + await app.call('settings.set', { model: { provider: 'faux', modelId: 'faux-a' } }) + const { id } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project: id }) + return id +} + +const send = (app: App, project: string, requestId: string, text = 'go') => app.call('chat.send', { project, requestId, text, attachments: [] }) + +/** Waits until the chat holds this answer, already committed or in a later change. */ +async function answered(app: App, project: string, text: string) { + const has = (entries: ChatEntry[]) => entries.some((entry) => entry.kind === 'assistant' && entry.text === text) + if (has(((await app.call('chat.open', { project })) as ChatView).entries)) return + await app.until((event) => event.type === 'chat' && event.project === project && has(event.entries as ChatEntry[]), 30_000) +} + +const toolResults = async (app: App, project: string) => + ((await app.call('chat.open', { project })) as ChatView).entries.flatMap((entry) => (entry.kind === 'tool' ? [entry] : [])) + +describe('an outside action', () => { + test('runs once per operation: a rerun takes the result Pi kept, or else the receipt file the action left', async () => { + const dir = mkdtempSync(join(tmpdir(), 'm3d-once-')) + let kept: { n: number } | undefined + const memo = { get: async () => kept, set: async (value: { n: number }) => (kept = value) } + let acted = 0 + const act = async () => ({ n: ++acted }) + + expect(await once(memo, join(dir, 'op1.json'), act)).toEqual({ n: 1 }) + expect(JSON.parse(readFileSync(join(dir, 'op1.json'), 'utf8'))).toEqual({ n: 1 }) + expect(await once(memo, join(dir, 'op1.json'), act)).toEqual({ n: 1 }) + + // A crash after the action and its receipt file, before Pi kept the result. + kept = undefined + expect(await once(memo, join(dir, 'op1.json'), act)).toEqual({ n: 1 }) + expect(await memo.get()).toEqual({ n: 1 }) + expect(acted).toBe(1) + }) +}) + +describe('the run tool', () => { + test('gets none of the sidecar environment but what commands need, plus its operation id', async () => { + const where = scratch({ M3D_SECRET: 'leak' }) + const app = start(where, 'env') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + await answered(app, project, 'exit code 0\nsecret=none op=set\n') + expect(readdirSync(join(where.projects, project, '.m3d', 'ops'))).toHaveLength(1) + await app.quit() + }) + + test('stops a command at its timeout, in seconds', async () => { + const where = scratch() + const app = start(where, 'slow') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + await answered(app, project, 'timed') + expect((await toolResults(app, project))[0]).toMatchObject({ name: 'run', failed: true }) + await Bun.sleep(2500) + expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) + await app.quit() + }) +}) + +describe('the file tools', () => { + test('never follow a symbolic link out of the project', async () => { + const where = scratch() + const outside = join(where.dir, 'outside') + mkdirSync(outside) + writeFileSync(join(outside, 'secret.txt'), 'secret') + const app = start(where, 'linked') + await app.next() + const project = await newProject(app) + symlinkSync(outside, join(where.projects, project, 'linked')) + await send(app, project, 'r1') + await answered(app, project, 'tried') + const results = await toolResults(app, project) + expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true]]) + for (const result of results) expect(result.text).toContain('outside the project folder') + expect(readFileSync(join(outside, 'secret.txt'), 'utf8')).toBe('secret') + expect(existsSync(join(outside, 'new.txt'))).toBe(false) + await app.quit() + }) +}) + +describe('Astra', () => { + test('without its sign-in, the turn waits with the fix named, and goes on once the person signs in', async () => { + const where = scratch() + const app = start(where, 'astra') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toEqual({ + kind: 'login', + provider: 'openai-codex', + name: 'openai-codex', + fix: 'Sign in to openai-codex. Astra picks up where it stopped.', + }) + expect(waiting.running).toBe(true) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await answered(app, project, 'parent saw: astra drew it') + expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await app.quit() + }) +}) + +describe('a restart', () => { + test('watches every chat, so a resumed run that then fails waits with its fix even if no one opened it', async () => { + const where = scratch() + const first = start(where, 'fails-after') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + + const second = start(where, 'fails-after') + await second.next() + writeFileSync(join(where.projects, project, 'go'), '') + const waiting = await second.until((event) => event.type === 'chat' && event.waiting !== null, 60_000) + expect(waiting).toMatchObject({ project, running: false, waiting: { kind: 'error', fix: 'Try again' } }) + await second.quit() + }) + + test('after an update stops a turn that ran on the old version, until the person goes on with the new one', async () => { + const where = scratch() + const first = start(where, 'models', '1.0.0') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1', 'please wait') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + first.child.kill('SIGKILL') + await first.child.exited + + const second = start(where, 'models', '2.0.0') + await second.next() + const view = (await second.call('chat.open', { project })) as ChatView + expect(view).toMatchObject({ running: false, waiting: { kind: 'error', detail: 'Materialize 3D was updated to 2.0.0 while this turn ran.', fix: 'Go on' } }) + expect(view.turn?.app).toBe('1.0.0') + + writeFileSync(join(where.projects, project, 'go'), '') + await second.call('chat.retry', { project }) + await answered(second, project, 'answered by faux-a') + const after = (await second.call('chat.open', { project })) as ChatView + expect(after.turn).toMatchObject({ app: '2.0.0', model: { modelId: 'faux-a' } }) + expect(after.waiting).toBeNull() + await second.quit() + }) +}) From a8158e486c3995b05c6398a0d5c5e0de00fb31f3 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Fri, 6 Nov 2026 23:11:55 -0600 Subject: [PATCH 03/17] fix(sidecar): Astra's waits survive a crash, old turns never resume on new code, and every link is checked - Astra's continues carry their own request ids, so a rerun after a crash finds the same continue, and it waits only when the sign-in is missing. - A wait a tool held is let go once the sign-in is there, and sends and failure reads clear the tool flag, so a later wait is never skipped. - A turn from another app version never goes on by itself, also when it waited on a sign-in: it waits for the person to go on. - Receipt files are named by a hash of the call id, so two calls never share one. - The path guard follows a link to a file that does not exist yet. - Astra's own run calls use the folder their tools share. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 93 ++++++++++++++--------- sidecar/src/app.ts | 7 +- sidecar/src/chat.ts | 70 +++++++++++++---- sidecar/test/fixtures/scripted-sidecar.ts | 8 +- sidecar/test/tools.test.ts | 51 ++++++++++++- 5 files changed, 170 insertions(+), 59 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index f195a73..1d42b63 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -8,8 +8,8 @@ * action run exactly once. `run` passes the id on as `M3D_OPERATION_ID`, for a script that wants to check for itself. */ import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative } from 'node:path' +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, writeFileSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import type { Context, JsonValue } from '@earendil-works/chord' import { AssistantEntry, @@ -41,20 +41,29 @@ const OUTPUT_TAIL = 20_000 /** The only variables a command the agent runs gets. */ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const +/** How many symbolic links a path may pass through before it is refused, as the system refuses a loop. */ +const MAX_LINKS = 40 + /** - * Where `path` really is: the real path of its nearest folder that exists, with the rest of the path after it. A - * symbolic link on the way is followed, so a path that leads out of the project through one is seen to. + * Where `path` really leads. Every symbolic link on the way is followed, a link whose target does not exist yet + * included, so a path that leads out of the project through one is seen to. A part that does not exist yet is + * where its folder really is. */ -function realPath(path: string): string { - const rest: string[] = [] - let at = path - while (!existsSync(at)) { - rest.unshift(basename(at)) - const up = dirname(at) - if (up === at) break - at = up +function realPath(path: string, links = 0): string { + if (links > MAX_LINKS) throw new Error(`${path} passes through too many links`) + try { + return realpathSync(path) + } catch { + let link = false + try { + link = lstatSync(path).isSymbolicLink() + } catch { + // Nothing is at this path yet. + } + if (link) return realPath(resolve(dirname(path), readlinkSync(path)), links + 1) + const up = dirname(path) + return up === path ? path : join(realPath(up, links), basename(path)) } - return join(realpathSync(at), ...rest) } /** One project's files and processes. A path that leads out of the project folder is refused, for every file tool. */ @@ -62,7 +71,13 @@ export class ProjectEnv extends NodeExecutionEnv { override async absolutePath(path: string, context: Context): Promise> { const resolved = await super.absolutePath(path, context) if (!resolved.ok) return resolved - const inside = relative(realpathSync(this.cwd), realPath(resolved.value)) + let real: string + try { + real = realPath(resolved.value) + } catch (error) { + return err(new FileError('permission_denied', error instanceof Error ? error.message : String(error), path)) + } + const inside = relative(realpathSync(this.cwd), real) if (inside.startsWith('..') || isAbsolute(inside)) { return err(new FileError('permission_denied', `${path} is outside the project folder`, path)) } @@ -95,22 +110,30 @@ const text = (value: string) => [{ type: 'text' as const, text: value }] export interface AgentHost { host: Host - /** The project a conversation belongs to, and its folder. */ - projectOf(conversation: number): Promise<{ id: string; folder: string }> - /** Holds a tool until the person signs in to `provider`, with the fix named on the chat. A stop ends the wait. */ - waitForLogin(conversation: number, provider: string, context: Context): Promise + /** The project a conversation belongs to. */ + projectOf(conversation: number): Promise + /** + * Resolves once the person is signed in to `provider`: at once when they are, after holding the turn with the fix + * named on its chat when they are not. A stop ends the wait. + */ + signedIn(conversation: number, provider: string, context: Context): Promise } +/** A call's operation id: a hash of the call id, since an id the model made can hold any character, and no two may share a receipt. */ +export const operationId = (callId: string) => createHash('sha256').update(callId).digest('hex').slice(0, 32) + /** A tool call's operation: its id, its receipt file in the project's hidden folder, and Pi's memo of it. */ -async function operation(api: ToolExecutionApi, context: Context, projectOf: AgentHost['projectOf']) { - const project = await projectOf(api.conversationId) - const id = api.callId.replace(/[^A-Za-z0-9_-]/g, '_') +function operation(api: ToolExecutionApi, context: Context) { + const env = api.env + // Astra's conversation is not a project, so the folder is the one its tools work in, which it shares. + if (!(env instanceof ProjectEnv)) throw new Error('this conversation has no project folder') + const id = operationId(api.callId) const memo: Memo = { get: () => api.memo('receipt', context), set: (value) => api.memo('receipt', value, context) } - return { id, project, memo, receipt: join(project.folder, '.m3d', 'ops', `${id}.json`) } + return { env, id, memo, receipt: join(env.cwd, '.m3d', 'ops', `${id}.json`) } } /** The agent's tools, in the order the model is offered them. */ -export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { +export function agentTools({ host, projectOf, signedIn }: AgentHost) { const read = createReadTool() const write = { ...createWriteTool(), replay: 'safe' as const } const edit = createEditTool() @@ -123,9 +146,8 @@ export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { parameters: obj({ args: arr(str(), { minItems: 1 }), timeoutSeconds: opt(int({ minimum: 1, maximum: 3600 })) }), replay: 'safe', execute: async ({ args, timeoutSeconds }, api, context) => { - const env = api.env - if (env === undefined) throw new Error('this conversation has no project folder') - const op = await operation<{ exitCode: number; output: string }>(api, context, projectOf) + const op = operation<{ exitCode: number; output: string }>(api, context) + const { env } = op const vars: Record = { M3D_OPERATION_ID: op.id } for (const name of COMMAND_ENV) { const value = process.env[name] @@ -161,10 +183,11 @@ export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { replay: 'safe', execute: async ({ inputs, output }, api, context) => { type Sliced = { ok: true; parts: number; printSeconds: number; filamentGrams: number } | { ok: false; error: string } - const op = await operation(api, context, projectOf) + const op = operation(api, context) + const project = await projectOf(api.conversationId) const sliced = await once(op.memo, op.receipt, async () => { try { - const { parts, printSeconds, filamentGrams } = await host.request('slice', { project: op.project.id, inputs, output }) + const { parts, printSeconds, filamentGrams } = await host.request('slice', { project, inputs, output }) return { ok: true, parts, printSeconds, filamentGrams } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } @@ -184,7 +207,6 @@ export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { parameters: obj({ task: str({ minLength: 1 }) }), replay: 'safe', execute: async ({ task }, api, context) => { - const signedIn = async () => (await api.models.getAuth(ASTRA_MODEL.provider).catch(() => undefined)) !== undefined // Astra's conversation is owned by this call, so a rerun finds the same one and the same request. const child = await api.commit(async (tx) => { const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0] @@ -197,9 +219,10 @@ export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { const conversation = await api.conversation(child, context) if (conversation === undefined) throw new Error("Astra's conversation is gone") // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. - if (!(await signedIn())) await waitForLogin(api.conversationId, ASTRA_MODEL.provider, context) + await signedIn(api.conversationId, ASTRA_MODEL.provider, context) let submission = await conversation.submit({ type: 'input', content: task, requestId: `astra:${api.taskId}` }, context) - for (;;) { + // Each continue has its own request id, so a rerun after a crash finds the same one instead of sending another. + for (let resumed = 0; ; resumed++) { const settled = await submission.wait(context) if (settled.status === 'done' && settled.type !== 'write') { const { answer } = settled @@ -207,12 +230,12 @@ export function agentTools({ host, projectOf, waitForLogin }: AgentHost) { return { output: text(textOf(entry?.model?.[0]?.content) || 'Astra finished without a message.') } } const detail = typeof settled.detail === 'string' ? settled.detail : '' - if (settled.reason !== 'model_error' || !(isLoginFailure(detail) || !(await signedIn()))) { + if (settled.reason !== 'model_error' || !isLoginFailure(detail)) { return { output: text(`Astra did not answer (${settled.reason}${detail === '' ? '' : `: ${detail}`}).`), isError: true } } - // The sign-in was missing or refused: wait for it, then Astra goes on from where it stopped. - await waitForLogin(api.conversationId, ASTRA_MODEL.provider, context) - submission = await conversation.submit({ type: 'continue' }, context) + // The sign-in was missing or refused: once it is there, Astra goes on from where it stopped. + await signedIn(api.conversationId, ASTRA_MODEL.provider, context) + submission = await conversation.submit({ type: 'continue', requestId: `astra:${api.taskId}:continue:${resumed}` }, context) } }, }) diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 959d5e2..1146072 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -43,11 +43,8 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const registry = createRegistry() const tools = agentTools({ host, - projectOf: async (conversation) => { - const id = await store.projectOf(conversation) - return { id, folder: store.projectFolder(id) } - }, - waitForLogin: (conversation, provider, context) => chats.waitForLogin(conversation, provider, context), + projectOf: (conversation) => store.projectOf(conversation), + signedIn: (conversation, provider, context) => chats.loginFor(conversation, provider, context), }) const extension = agentExtension(tools) registry.install(extension) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 809b037..5e25cc8 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -159,6 +159,7 @@ export class Chats { const record = await tx.doc(WaitingDoc, conversation.id) record.waiting = waiting record.failure = failure.taskId + record.tool = false }, context) } @@ -188,7 +189,9 @@ export class Chats { const turn: Turn = { requestId, model, thinking, tools: tools.map((t) => t.name), instructions: WORKFLOW_VERSION, app: version, startedMs: Date.now() } ;(await tx.doc(TurnDoc, conversation.id)).turn = turn delete (await tx.doc(DraftsDoc, store.root.id)).drafts[project] - ;(await tx.doc(WaitingDoc, conversation.id)).waiting = null + const waiting = await tx.doc(WaitingDoc, conversation.id) + waiting.waiting = null + waiting.tool = false }, context) } catch (error) { if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') @@ -214,10 +217,11 @@ export class Chats { } /** - * Holds a running tool until the person signs in to `provider`, with the fix named on its chat. The turn stays where - * it is and goes on once the sign-in is done. A stop ends the wait. + * Resolves once the person is signed in to `provider`, for a running tool. When they are not, it holds the turn where + * it is, with the fix named on its chat, and goes on once they sign in. A stop ends the wait. A wait the tool held + * before a restart is let go once the sign-in is there. */ - async waitForLogin(conversation: number, provider: string, waiting: Context): Promise { + async loginFor(conversation: number, provider: string, waiting: Context): Promise { const { models, store } = this.#o const id = conversation as ConversationId const name = models.getProvider(provider)?.name ?? provider @@ -228,6 +232,13 @@ export class Chats { record.waiting = wait record.tool = wait !== null }, context) + if ((await models.getAuth(provider).catch(() => undefined)) !== undefined) { + if ((await store.harness.snapshot(WaitingDoc, id, context))?.tool) { + await hold(null) + await this.#show(project) + } + return + } const { promise, resolve } = Promise.withResolvers() const waiters = this.#signIns.get(provider) ?? new Set() this.#signIns.set(provider, waiters.add(resolve)) @@ -260,12 +271,33 @@ export class Chats { await (await this.#conversation(project)).abort(context) } - /** After a sign-in: every tool and every turn that waited on that provider goes on. */ + /** A turn that started on another version of the app waits for the person to go on with this one. */ + async #outdated(conversation: ConversationId): Promise { + await this.#o.store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, conversation) + record.waiting = { kind: 'error', detail: `Materialize 3D was updated to ${this.#o.version} while this turn ran.`, fix: 'Go on' } + record.tool = false + }, context) + } + + /** + * After a sign-in: every tool and every turn that waited on that provider goes on, except a turn that started on + * another version of the app, which waits for the person to go on with this one. + */ async signedIn(provider: string): Promise { + const { store, version } = this.#o for (const resolve of this.#signIns.get(provider) ?? []) resolve() - for (const project of await this.#o.store.projects()) { - const waiting = await this.#o.store.harness.snapshot(WaitingDoc, project.conversation as ConversationId, context) - if (waiting?.waiting?.kind === 'login' && waiting.waiting.provider === provider && !waiting.tool) await this.#continue(project.id) + for (const project of await store.projects()) { + const conversation = project.conversation as ConversationId + const waiting = await store.harness.snapshot(WaitingDoc, conversation, context) + if (waiting?.waiting?.kind !== 'login' || waiting.waiting.provider !== provider || waiting.tool) continue + const turn = (await store.harness.snapshot(TurnDoc, conversation, context))?.turn + if (turn !== null && turn !== undefined && turn.app !== version) { + await this.#outdated(conversation) + await this.#show(project.id) + } else { + await this.#continue(project.id) + } } } @@ -287,11 +319,7 @@ export class Chats { await Promise.all( outdated.map(async (conversation) => { await conversation.abort(context) - await store.harness.commit(async (tx) => { - const record = await tx.doc(WaitingDoc, conversation.id) - record.waiting = { kind: 'error', detail: `Materialize 3D was updated to ${version} while this turn ran.`, fix: 'Go on' } - record.tool = false - }, context) + await this.#outdated(conversation.id) }), ) store.harness.resume() @@ -304,10 +332,20 @@ export class Chats { await this.#read(conversation, state.failure, state.turn) } const record = await store.harness.snapshot(WaitingDoc, conversation.id, context) - const waiting = record?.waiting - if (waiting?.kind === 'login' && !record?.tool && (await models.getAuth(waiting.provider).catch(() => undefined)) !== undefined) { - await this.#continue(project.id) + const running = (await this.#state(conversation)).running + if (record?.tool && !running) { + // A tool held this wait, and its run is gone: nothing will let it go but this. + await store.harness.commit(async (tx) => { + const stale = await tx.doc(WaitingDoc, conversation.id) + stale.waiting = null + stale.tool = false + }, context) + continue } + const waiting = record?.waiting + if (waiting?.kind !== 'login' || record?.tool || running) continue + if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) + else if ((await models.getAuth(waiting.provider).catch(() => undefined)) !== undefined) await this.#continue(project.id) } } diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index ec3163b..7fed903 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -60,6 +60,7 @@ const scripts: Record) => Ass if (results.length === 0) return call('read', { path: 'linked/secret.txt' }) if (results.length === 1) return call('write', { path: 'linked/new.txt', content: 'x' }) if (results.length === 2) return call('edit', { path: 'linked/secret.txt', edits: [{ oldText: 'secret', newText: 'leaked' }] }) + if (results.length === 3) return call('write', { path: 'dangling', content: 'x' }) return fauxAssistantMessage(fauxText('tried')) }, /** A tool that waits for `go`, then a model request that fails. */ @@ -86,7 +87,12 @@ await runSidecar({ faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) models.setProvider(faux.provider) const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) - astra.setResponses(Array.from({ length: 50 }, () => () => fauxAssistantMessage(fauxText('astra drew it')))) + // Astra runs a command of its own first, in the project folder it shares. + astra.setResponses( + Array.from({ length: 50 }, () => (context) => + sinceUser(context.messages).results.length === 0 ? run('echo astra-ran > astra.txt') : fauxAssistantMessage(fauxText('astra drew it')), + ), + ) models.setProvider({ ...astra.provider, auth: { apiKey: { name: 'Astra test key', resolve: async ({ credential }) => (credential?.key === undefined ? undefined : { auth: { apiKey: credential.key } }) } }, diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 186b11d..c8c7cba 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { once } from '../src/agent' +import { once, operationId } from '../src/agent' import type { ChatEntry, ChatView } from '../src/protocol' import { spawnSidecar } from './sidecar' @@ -57,6 +57,11 @@ describe('an outside action', () => { expect(await memo.get()).toEqual({ n: 1 }) expect(acted).toBe(1) }) + + test('calls whose ids differ only in characters a file name cannot hold keep separate receipts', () => { + expect(operationId('call/a')).not.toBe(operationId('call:a')) + expect(operationId('call/a')).toBe(operationId('call/a')) + }) }) describe('the run tool', () => { @@ -95,13 +100,16 @@ describe('the file tools', () => { await app.next() const project = await newProject(app) symlinkSync(outside, join(where.projects, project, 'linked')) + // A link to a file that does not exist yet: writing through it would make the file outside. + symlinkSync(join(outside, 'made-outside.txt'), join(where.projects, project, 'dangling')) await send(app, project, 'r1') await answered(app, project, 'tried') const results = await toolResults(app, project) - expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true]]) + expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true], ['write', true]]) for (const result of results) expect(result.text).toContain('outside the project folder') expect(readFileSync(join(outside, 'secret.txt'), 'utf8')).toBe('secret') expect(existsSync(join(outside, 'new.txt'))).toBe(false) + expect(existsSync(join(outside, 'made-outside.txt'))).toBe(false) await app.quit() }) }) @@ -124,8 +132,29 @@ describe('Astra', () => { await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) await answered(app, project, 'parent saw: astra drew it') expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + expect(readFileSync(join(where.projects, project, 'astra.txt'), 'utf8')).toBe('astra-ran\n') await app.quit() }) + + test('a wait a crash cut off is let go once the sign-in is there, and the turn finishes', async () => { + const where = scratch() + const keychain = new Map() + const first = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await first.next() + const project = await newProject(first) + await send(first, project, 'r1') + await first.until((event) => event.type === 'chat' && event.waiting !== null) + first.child.kill('SIGKILL') + await first.child.exited + + // The sign-in lands in the Keychain while the app is closed. + keychain.set('openai-codex', JSON.stringify({ type: 'api_key', key: 'astra-key' })) + const second = spawnSidecar(where.data, where.projects, keychain, { script: 'astra', env: where.env }) + await second.next() + await answered(second, project, 'parent saw: astra drew it') + expect(((await second.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await second.quit() + }) }) describe('a restart', () => { @@ -147,6 +176,24 @@ describe('a restart', () => { await second.quit() }) + test('after an update, a turn that waited on a sign-in from the old version waits for the person to go on', async () => { + const where = scratch() + const first = start(where, 'models', '1.0.0') + await first.next() + await first.call('settings.set', { model: { provider: 'anthropic', modelId: 'claude-opus-5-5' } }) + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await send(first, project, 'r1') + await first.until((event) => event.type === 'chat' && event.waiting !== null) + await first.quit() + + const second = start(where, 'models', '2.0.0') + await second.next() + const view = (await second.call('chat.open', { project })) as ChatView + expect(view.waiting).toEqual({ kind: 'error', detail: 'Materialize 3D was updated to 2.0.0 while this turn ran.', fix: 'Go on' }) + await second.quit() + }) + test('after an update stops a turn that ran on the old version, until the person goes on with the new one', async () => { const where = scratch() const first = start(where, 'models', '1.0.0') From 67a5b98bc522c19b08e9543662a700c40cbd4dc0 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:03:52 -0600 Subject: [PATCH 04/17] fix(sidecar): a refused sign-in makes Astra wait, and a link that climbs is checked where it really leads - A provider that refused Astra's saved sign-in (a 401) made Astra ask again at once with the same sign-in, over and over. Astra now waits, with the fix named, for a new sign-in. A rerun after a crash takes a continue it already sent without waiting again. - A link whose target climbs with `..` after another link was checked where the `..` would lead on paper, not where the system leads. The link target now stays as written, and the system's own realpath answers, because the JS one drops `..` before it follows the links ahead of it. - The run tool's timeout test gets 20 s, as a loaded machine took 5.1 s. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 29 +++++++++++++++-------- sidecar/src/app.ts | 2 +- sidecar/src/chat.ts | 13 +++++----- sidecar/test/fixtures/scripted-sidecar.ts | 10 ++++---- sidecar/test/tools.test.ts | 23 ++++++++++++++++-- 5 files changed, 54 insertions(+), 23 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index 1d42b63..7625d08 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -9,7 +9,7 @@ */ import { createHash } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, writeFileSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, relative } from 'node:path' import type { Context, JsonValue } from '@earendil-works/chord' import { AssistantEntry, @@ -52,7 +52,8 @@ const MAX_LINKS = 40 function realPath(path: string, links = 0): string { if (links > MAX_LINKS) throw new Error(`${path} passes through too many links`) try { - return realpathSync(path) + // The system's own answer: the JS version drops `..` before it follows the links ahead of it. + return realpathSync.native(path) } catch { let link = false try { @@ -60,7 +61,11 @@ function realPath(path: string, links = 0): string { } catch { // Nothing is at this path yet. } - if (link) return realPath(resolve(dirname(path), readlinkSync(path)), links + 1) + if (link) { + // The target stays as written: `..` in it climbs from where the links before it lead, as the system climbs. + const target = readlinkSync(path) + return realPath(isAbsolute(target) ? target : `${dirname(path)}/${target}`, links + 1) + } const up = dirname(path) return up === path ? path : join(realPath(up, links), basename(path)) } @@ -77,7 +82,7 @@ export class ProjectEnv extends NodeExecutionEnv { } catch (error) { return err(new FileError('permission_denied', error instanceof Error ? error.message : String(error), path)) } - const inside = relative(realpathSync(this.cwd), real) + const inside = relative(realpathSync.native(this.cwd), real) if (inside.startsWith('..') || isAbsolute(inside)) { return err(new FileError('permission_denied', `${path} is outside the project folder`, path)) } @@ -114,9 +119,10 @@ export interface AgentHost { projectOf(conversation: number): Promise /** * Resolves once the person is signed in to `provider`: at once when they are, after holding the turn with the fix - * named on its chat when they are not. A stop ends the wait. + * named on its chat when they are not. After the provider `refused` the saved sign-in, only a new one will do. A stop + * ends the wait. */ - signedIn(conversation: number, provider: string, context: Context): Promise + signedIn(conversation: number, provider: string, context: Context, refused: boolean): Promise } /** A call's operation id: a hash of the call id, since an id the model made can hold any character, and no two may share a receipt. */ @@ -219,7 +225,7 @@ export function agentTools({ host, projectOf, signedIn }: AgentHost) { const conversation = await api.conversation(child, context) if (conversation === undefined) throw new Error("Astra's conversation is gone") // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. - await signedIn(api.conversationId, ASTRA_MODEL.provider, context) + await signedIn(api.conversationId, ASTRA_MODEL.provider, context, false) let submission = await conversation.submit({ type: 'input', content: task, requestId: `astra:${api.taskId}` }, context) // Each continue has its own request id, so a rerun after a crash finds the same one instead of sending another. for (let resumed = 0; ; resumed++) { @@ -233,9 +239,12 @@ export function agentTools({ host, projectOf, signedIn }: AgentHost) { if (settled.reason !== 'model_error' || !isLoginFailure(detail)) { return { output: text(`Astra did not answer (${settled.reason}${detail === '' ? '' : `: ${detail}`}).`), isError: true } } - // The sign-in was missing or refused: once it is there, Astra goes on from where it stopped. - await signedIn(api.conversationId, ASTRA_MODEL.provider, context) - submission = await conversation.submit({ type: 'continue', requestId: `astra:${api.taskId}:continue:${resumed}` }, context) + // The sign-in was missing or refused, so Astra waits for a new one, then goes on from where it stopped. A rerun + // after a crash takes a continue it already sent as it was, without waiting again. + const requestId = `astra:${api.taskId}:continue:${resumed}` + const sent = await api.commit((tx) => tx.submissionByRequest(child, requestId), context) + if (sent === undefined) await signedIn(api.conversationId, ASTRA_MODEL.provider, context, true) + submission = await conversation.submit({ type: 'continue', requestId }, context) } }, }) diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index 1146072..ecdef7a 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -44,7 +44,7 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const tools = agentTools({ host, projectOf: (conversation) => store.projectOf(conversation), - signedIn: (conversation, provider, context) => chats.loginFor(conversation, provider, context), + signedIn: (conversation, provider, context, refused) => chats.loginFor(conversation, provider, context, refused), }) const extension = agentExtension(tools) registry.install(extension) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 5e25cc8..f42c602 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -217,11 +217,11 @@ export class Chats { } /** - * Resolves once the person is signed in to `provider`, for a running tool. When they are not, it holds the turn where - * it is, with the fix named on its chat, and goes on once they sign in. A stop ends the wait. A wait the tool held - * before a restart is let go once the sign-in is there. + * Resolves once the person is signed in to `provider`, for a running tool. When they are not, or the provider + * `refused` the saved sign-in, it holds the turn where it is, with the fix named on its chat, and goes on once they + * sign in. A stop ends the wait. A wait the tool held before a restart is let go once the sign-in is there. */ - async loginFor(conversation: number, provider: string, waiting: Context): Promise { + async loginFor(conversation: number, provider: string, waiting: Context, refused: boolean): Promise { const { models, store } = this.#o const id = conversation as ConversationId const name = models.getProvider(provider)?.name ?? provider @@ -232,7 +232,7 @@ export class Chats { record.waiting = wait record.tool = wait !== null }, context) - if ((await models.getAuth(provider).catch(() => undefined)) !== undefined) { + if (!refused && (await models.getAuth(provider).catch(() => undefined)) !== undefined) { if ((await store.harness.snapshot(WaitingDoc, id, context))?.tool) { await hold(null) await this.#show(project) @@ -242,7 +242,8 @@ export class Chats { const { promise, resolve } = Promise.withResolvers() const waiters = this.#signIns.get(provider) ?? new Set() this.#signIns.set(provider, waiters.add(resolve)) - await hold({ kind: 'login', provider, name, fix: `Sign in to ${name}. Astra picks up where it stopped.` }) + const fix = refused ? `${name} refused the saved sign-in. Sign in again. Astra picks up where it stopped.` : `Sign in to ${name}. Astra picks up where it stopped.` + await hold({ kind: 'login', provider, name, fix }) await this.#show(project) try { await awaitWithContext(promise, waiting) diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index 7fed903..2bbac5f 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -61,6 +61,7 @@ const scripts: Record) => Ass if (results.length === 1) return call('write', { path: 'linked/new.txt', content: 'x' }) if (results.length === 2) return call('edit', { path: 'linked/secret.txt', edits: [{ oldText: 'secret', newText: 'leaked' }] }) if (results.length === 3) return call('write', { path: 'dangling', content: 'x' }) + if (results.length === 4) return call('write', { path: 'climb', content: 'x' }) return fauxAssistantMessage(fauxText('tried')) }, /** A tool that waits for `go`, then a model request that fails. */ @@ -87,11 +88,12 @@ await runSidecar({ faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) models.setProvider(faux.provider) const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) - // Astra runs a command of its own first, in the project folder it shares. + // Astra runs a command of its own first, in the project folder it shares. The provider refuses the key `refused`. astra.setResponses( - Array.from({ length: 50 }, () => (context) => - sinceUser(context.messages).results.length === 0 ? run('echo astra-ran > astra.txt') : fauxAssistantMessage(fauxText('astra drew it')), - ), + Array.from({ length: 50 }, () => (context, options) => { + if (options?.apiKey === 'refused') return fauxAssistantMessage([], { stopReason: 'error', errorMessage: '401 Unauthorized: invalid token' }) + return sinceUser(context.messages).results.length === 0 ? run('echo astra-ran > astra.txt') : fauxAssistantMessage(fauxText('astra drew it')) + }), ) models.setProvider({ ...astra.provider, diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index c8c7cba..ca83405 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -87,7 +87,7 @@ describe('the run tool', () => { await Bun.sleep(2500) expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) await app.quit() - }) + }, 20_000) }) describe('the file tools', () => { @@ -102,14 +102,17 @@ describe('the file tools', () => { symlinkSync(outside, join(where.projects, project, 'linked')) // A link to a file that does not exist yet: writing through it would make the file outside. symlinkSync(join(outside, 'made-outside.txt'), join(where.projects, project, 'dangling')) + // A link to a file not made yet, through `linked` and then up: the system climbs from where `linked` leads. + symlinkSync('linked/../climbed.txt', join(where.projects, project, 'climb')) await send(app, project, 'r1') await answered(app, project, 'tried') const results = await toolResults(app, project) - expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true], ['write', true]]) + expect(results.map((r) => [r.name, r.failed])).toEqual([['read', true], ['write', true], ['edit', true], ['write', true], ['write', true]]) for (const result of results) expect(result.text).toContain('outside the project folder') expect(readFileSync(join(outside, 'secret.txt'), 'utf8')).toBe('secret') expect(existsSync(join(outside, 'new.txt'))).toBe(false) expect(existsSync(join(outside, 'made-outside.txt'))).toBe(false) + expect(existsSync(join(where.dir, 'climbed.txt'))).toBe(false) await app.quit() }) }) @@ -136,6 +139,22 @@ describe('Astra', () => { await app.quit() }) + test('a sign-in the provider refuses makes the turn wait for a new one, instead of asking again and again', async () => { + const where = scratch() + const app = start(where, 'astra') + await app.next() + const project = await newProject(app) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) + await send(app, project, 'r1') + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null, 30_000) + expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'openai-codex' }) + expect(waiting.running).toBe(true) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + await answered(app, project, 'parent saw: astra drew it') + expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await app.quit() + }, 60_000) + test('a wait a crash cut off is let go once the sign-in is there, and the turn finishes', async () => { const where = scratch() const keychain = new Map() From 10149515578b701d35eead212de8ae7a84629605 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:15:08 -0600 Subject: [PATCH 05/17] fix(sidecar): a command the agent runs ends with its sidecar A live crash test on macOS killed the sidecar mid-command. Rust started a new sidecar, whose resumed turn ran the unfinished step again, while the first copy kept running and wrote its output a second time. Pi starts each command in a process group of its own, so it can end the tree on a timeout or a stop, and a sidecar killed outright ends nothing. Each command now runs under a small sh watch that ends its group once the sidecar is gone. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 17 ++++++++++++++++- sidecar/test/fixtures/scripted-sidecar.ts | 6 ++++++ sidecar/test/tools.test.ts | 13 +++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index 7625d08..cb05ad7 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -41,6 +41,21 @@ const OUTPUT_TAIL = 20_000 /** The only variables a command the agent runs gets. */ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const +/** + * Runs a command under a watch on the sidecar. Pi starts each command in a process group of its own, so it can end the + * whole tree on a timeout or a stop, but a sidecar killed outright ends nothing. The watch ends the group, command and + * all, once the sidecar is gone: a command a crash cut off must not run on beside the copy the resumed turn runs. + */ +const GUARD = [ + 'sidecar=$1; shift', + '(while kill -0 "$sidecar" 2>/dev/null; do sleep 1; done; kill -KILL 0) /dev/null 2>&1 &', + 'watch=$!', + '"$@"', + 'status=$?', + 'kill "$watch" 2>/dev/null', + 'exit "$status"', +].join('\n') + /** How many symbolic links a path may pass through before it is refused, as the system refuses a loop. */ const MAX_LINKS = 40 @@ -162,7 +177,7 @@ export function agentTools({ host, projectOf, signedIn }: AgentHost) { const receipt = await once(op.memo, op.receipt, async () => { let output = '' const ran = await env.exec( - ['uv', ...args], + ['/bin/sh', '-c', GUARD, 'm3d-run', String(process.pid), 'uv', ...args], { env: vars, inheritEnv: false, diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index 2bbac5f..d033279 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -48,6 +48,12 @@ const scripts: Record) => Ass if (results.length === 0) return run('echo "secret=${M3D_SECRET:-none} op=${M3D_OPERATION_ID:+set}"') return fauxAssistantMessage(fauxText(resultText(results[0]))) }, + /** A command that marks its start, then runs for three seconds, long enough for a test to kill the sidecar first. */ + orphan: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('touch started; sleep 3; touch finished') + return fauxAssistantMessage(fauxText('ran')) + }, /** A command that runs past its one-second timeout. */ slow: (messages) => { const { results } = sinceUser(messages) diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index ca83405..3a5d405 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -88,6 +88,19 @@ describe('the run tool', () => { expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) await app.quit() }, 20_000) + + test('ends with its sidecar, so a command a crash cut off never runs on beside the rerun', async () => { + const where = scratch() + const app = start(where, 'orphan') + await app.next() + const project = await newProject(app) + await send(app, project, 'r1') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(25) + app.child.kill('SIGKILL') + await app.child.exited + await Bun.sleep(5000) + expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) + }, 20_000) }) describe('the file tools', () => { From 049298f61c1b01573281bb18724a92b6ec00e6d5 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:30:43 -0600 Subject: [PATCH 06/17] fix(sidecar): Astra waits for a login saved after the refused one, not for the next sign-in After a provider refused Astra's request, Astra waited for the next sign-in event. A sign-in saved while that request was still out had already fired its event, so Astra waited on with a working login stored, and a restart before its next request waited again too. The store now counts each provider's login saves, and each Astra request keeps the count it went out with in Pi's memo. A refusal waits until a later save is there, checked after its waiter is in place. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 27 ++++++++---- sidecar/src/app.ts | 3 +- sidecar/src/chat.ts | 50 ++++++++++++++--------- sidecar/src/store.ts | 22 +++++++++- sidecar/test/fixtures/scripted-sidecar.ts | 16 ++++++-- sidecar/test/tools.test.ts | 15 +++++++ 6 files changed, 99 insertions(+), 34 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index cb05ad7..b6c8e23 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -134,10 +134,12 @@ export interface AgentHost { projectOf(conversation: number): Promise /** * Resolves once the person is signed in to `provider`: at once when they are, after holding the turn with the fix - * named on its chat when they are not. After the provider `refused` the saved sign-in, only a new one will do. A stop - * ends the wait. + * named on its chat when they are not. With `after`, only a login saved after that many saves will do, as the one + * then saved was refused. A stop ends the wait. */ - signedIn(conversation: number, provider: string, context: Context, refused: boolean): Promise + signedIn(conversation: number, provider: string, context: Context, after: number | undefined): Promise + /** How many times the provider's login was saved. */ + loginSaves(provider: string): Promise } /** A call's operation id: a hash of the call id, since an id the model made can hold any character, and no two may share a receipt. */ @@ -154,7 +156,7 @@ function operation(api: ToolExecutionApi, context: Context) } /** The agent's tools, in the order the model is offered them. */ -export function agentTools({ host, projectOf, signedIn }: AgentHost) { +export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) { const read = createReadTool() const write = { ...createWriteTool(), replay: 'safe' as const } const edit = createEditTool() @@ -239,9 +241,14 @@ export function agentTools({ host, projectOf, signedIn }: AgentHost) { await api.details({ astra: child }, context) const conversation = await api.conversation(child, context) if (conversation === undefined) throw new Error("Astra's conversation is gone") + const { provider } = ASTRA_MODEL + // The login save each request goes out with, kept with the request, so a rerun after a crash finds the same one. + const savedWith = async (requestId: string) => api.memo(`login:${requestId}`, await loginSaves(provider), context) // Astra runs on the person's ChatGPT sign-in. Without it the turn waits here, with the fix named, and goes on. - await signedIn(api.conversationId, ASTRA_MODEL.provider, context, false) - let submission = await conversation.submit({ type: 'input', content: task, requestId: `astra:${api.taskId}` }, context) + await signedIn(api.conversationId, provider, context, undefined) + const first = `astra:${api.taskId}` + let saves = await savedWith(first) + let submission = await conversation.submit({ type: 'input', content: task, requestId: first }, context) // Each continue has its own request id, so a rerun after a crash finds the same one instead of sending another. for (let resumed = 0; ; resumed++) { const settled = await submission.wait(context) @@ -254,11 +261,13 @@ export function agentTools({ host, projectOf, signedIn }: AgentHost) { if (settled.reason !== 'model_error' || !isLoginFailure(detail)) { return { output: text(`Astra did not answer (${settled.reason}${detail === '' ? '' : `: ${detail}`}).`), isError: true } } - // The sign-in was missing or refused, so Astra waits for a new one, then goes on from where it stopped. A rerun - // after a crash takes a continue it already sent as it was, without waiting again. + // The sign-in was missing or refused, so Astra waits for a login saved after the one this request went out + // with, which may have landed while it was out, then goes on from where it stopped. A rerun after a crash takes + // a continue it already sent as it was, without waiting again. const requestId = `astra:${api.taskId}:continue:${resumed}` const sent = await api.commit((tx) => tx.submissionByRequest(child, requestId), context) - if (sent === undefined) await signedIn(api.conversationId, ASTRA_MODEL.provider, context, true) + if (sent === undefined) await signedIn(api.conversationId, provider, context, saves) + saves = await savedWith(requestId) submission = await conversation.submit({ type: 'continue', requestId }, context) } }, diff --git a/sidecar/src/app.ts b/sidecar/src/app.ts index ecdef7a..26228e2 100644 --- a/sidecar/src/app.ts +++ b/sidecar/src/app.ts @@ -44,7 +44,8 @@ export async function runSidecar({ argv, addProviders }: SidecarOptions): Promis const tools = agentTools({ host, projectOf: (conversation) => store.projectOf(conversation), - signedIn: (conversation, provider, context, refused) => chats.loginFor(conversation, provider, context, refused), + signedIn: (conversation, provider, context, after) => chats.loginFor(conversation, provider, context, after), + loginSaves: (provider) => store.loginSaves(provider), }) const extension = agentExtension(tools) registry.install(extension) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index f42c602..0d661e0 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -217,11 +217,12 @@ export class Chats { } /** - * Resolves once the person is signed in to `provider`, for a running tool. When they are not, or the provider - * `refused` the saved sign-in, it holds the turn where it is, with the fix named on its chat, and goes on once they - * sign in. A stop ends the wait. A wait the tool held before a restart is let go once the sign-in is there. + * Resolves once the person is signed in to `provider`, for a running tool: with `after`, signed in with a login saved + * after that many saves, as the one then saved was refused. Until then it holds the turn where it is, with the fix + * named on its chat, and goes on once they sign in. A stop ends the wait. A wait the tool held before a restart is + * let go once the sign-in is there. */ - async loginFor(conversation: number, provider: string, waiting: Context, refused: boolean): Promise { + async loginFor(conversation: number, provider: string, waiting: Context, after: number | undefined): Promise { const { models, store } = this.#o const id = conversation as ConversationId const name = models.getProvider(provider)?.name ?? provider @@ -232,25 +233,34 @@ export class Chats { record.waiting = wait record.tool = wait !== null }, context) - if (!refused && (await models.getAuth(provider).catch(() => undefined)) !== undefined) { - if ((await store.harness.snapshot(WaitingDoc, id, context))?.tool) { + const ready = async () => + after === undefined ? (await models.getAuth(provider).catch(() => undefined)) !== undefined : (await store.loginSaves(provider)) > after + const fix = + after === undefined ? `Sign in to ${name}. Astra picks up where it stopped.` : `${name} refused the saved sign-in. Sign in again. Astra picks up where it stopped.` + let held = (await store.harness.snapshot(WaitingDoc, id, context))?.tool ?? false + try { + for (;;) { + // The waiter is in place before the check, so a sign-in that lands between the two is not missed. + const { promise, resolve } = Promise.withResolvers() + const waiters = this.#signIns.get(provider) ?? new Set() + this.#signIns.set(provider, waiters.add(resolve)) + try { + if (await ready()) return + if (!held) { + held = true + await hold({ kind: 'login', provider, name, fix }) + await this.#show(project) + } + await awaitWithContext(promise, waiting) + } finally { + waiters.delete(resolve) + } + } + } finally { + if (held) { await hold(null) await this.#show(project) } - return - } - const { promise, resolve } = Promise.withResolvers() - const waiters = this.#signIns.get(provider) ?? new Set() - this.#signIns.set(provider, waiters.add(resolve)) - const fix = refused ? `${name} refused the saved sign-in. Sign in again. Astra picks up where it stopped.` : `Sign in to ${name}. Astra picks up where it stopped.` - await hold({ kind: 'login', provider, name, fix }) - await this.#show(project) - try { - await awaitWithContext(promise, waiting) - } finally { - waiters.delete(resolve) - await hold(null) - await this.#show(project) } } diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 10085e3..309b02b 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -92,6 +92,17 @@ export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | checkpointWhen: always, }) +/** How many times each provider's login was saved, so a wait can tell a new sign-in from the one a provider refused. */ +const LoginSavesDoc = defineDoc<{ saves: Record }>({ + kind: 'm3d.login-saves', + version: 1, + scope: 'conversation', + history: 'latest', + fork: 'initial', + initial: () => ({ saves: {} }), + checkpointWhen: always, +}) + /** Which providers have a login in the Keychain. Never the secret itself. */ const LoginsDoc = defineDoc<{ logins: Record }>({ kind: 'm3d.logins', @@ -262,10 +273,19 @@ export class Store implements LoginIndex { await this.harness.commit(async (tx) => { const { logins } = await tx.doc(LoginsDoc, this.root.id) if (kind === undefined) delete logins[provider] - else logins[provider] = kind + else { + logins[provider] = kind + const { saves } = await tx.doc(LoginSavesDoc, this.root.id) + saves[provider] = (saves[provider] ?? 0) + 1 + } }, context) } + /** How many times the provider's login was saved. */ + async loginSaves(provider: string): Promise { + return (await this.harness.snapshot(LoginSavesDoc, this.root.id, context))?.saves[provider] ?? 0 + } + /** The project's folder, made if it is missing. */ projectFolder(id: string): string { const dir = join(this.projectsDir, id) diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index d033279..14fdc76 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -4,6 +4,8 @@ * mid-turn goes on with the same script. OpenAI Codex, Astra's provider, is replaced by a scripted Astra that answers * only once a key is saved for it, as the real one answers only once the person signs in. */ +import { existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { type AssistantMessage, fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, type Message, type Model } from '@earendil-works/pi-ai' import type { JsonObject } from '@earendil-works/pi-durable' import { runSidecar } from '../../src/app' @@ -94,10 +96,18 @@ await runSidecar({ faux.setResponses(Array.from({ length: 200 }, () => (context, _options, _state, model) => script(context.messages, model))) models.setProvider(faux.provider) const astra = fauxProvider({ provider: 'openai-codex', models: [{ id: 'gpt-6-astra' }] }) - // Astra runs a command of its own first, in the project folder it shares. The provider refuses the key `refused`. + // Astra runs a command of its own first, in the project folder it shares. The provider refuses the key `refused`, + // and with M3D_ASTRA_GATE set, it holds that request open until the test writes `release` there. + const gate = process.env.M3D_ASTRA_GATE astra.setResponses( - Array.from({ length: 50 }, () => (context, options) => { - if (options?.apiKey === 'refused') return fauxAssistantMessage([], { stopReason: 'error', errorMessage: '401 Unauthorized: invalid token' }) + Array.from({ length: 50 }, () => async (context, options) => { + if (options?.apiKey === 'refused') { + if (gate !== undefined) { + writeFileSync(join(gate, 'asked'), '') + while (!existsSync(join(gate, 'release'))) await Bun.sleep(25) + } + return fauxAssistantMessage([], { stopReason: 'error', errorMessage: '401 Unauthorized: invalid token' }) + } return sinceUser(context.messages).results.length === 0 ? run('echo astra-ran > astra.txt') : fauxAssistantMessage(fauxText('astra drew it')) }), ) diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 3a5d405..c8a8454 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -168,6 +168,21 @@ describe('Astra', () => { await app.quit() }, 60_000) + test('a sign-in saved while a request it replaces was still out is not missed when that request is refused', async () => { + const gate = mkdtempSync(join(tmpdir(), 'm3d-astra-gate-')) + const where = scratch({ M3D_ASTRA_GATE: gate }) + const app = start(where, 'astra') + await app.next() + const project = await newProject(app) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'refused' }) + await send(app, project, 'r1') + while (!existsSync(join(gate, 'asked'))) await Bun.sleep(25) + await app.call('auth.setKey', { provider: 'openai-codex', key: 'astra-key' }) + writeFileSync(join(gate, 'release'), '') + await answered(app, project, 'parent saw: astra drew it') + await app.quit() + }, 60_000) + test('a wait a crash cut off is let go once the sign-in is there, and the turn finishes', async () => { const where = scratch() const keychain = new Map() From 1650de16d42f919f94e7aa4f7ac0b54f0b82006a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 00:37:40 -0600 Subject: [PATCH 07/17] fix(sidecar): a command a crash cut off finishes once, even when the sidecar restarts at once The watch on a command ends it within a second of the sidecar's end, but Rust starts a new sidecar sooner, so a resumed turn could run a command again while the cut-off copy still ran and finished. A command that ends now leaves its receipt with its exit code, and a resumed run of a command that started but left no receipt first waits longer than the watch takes. So the cut-off copy either finished, and the resumed turn takes its receipt, or was ended first. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 15 ++++++++++++--- sidecar/test/agent.test.ts | 2 ++ sidecar/test/fixtures/scripted-sidecar.ts | 6 ++++++ sidecar/test/tools.test.ts | 17 +++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index b6c8e23..4d804e9 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -44,18 +44,23 @@ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const /** * Runs a command under a watch on the sidecar. Pi starts each command in a process group of its own, so it can end the * whole tree on a timeout or a stop, but a sidecar killed outright ends nothing. The watch ends the group, command and - * all, once the sidecar is gone: a command a crash cut off must not run on beside the copy the resumed turn runs. + * all, within a second of the sidecar's end, and a command that ends first leaves its receipt with its exit code. So + * a command a crash cut off either finished, and a resumed turn takes its receipt, or was ended. */ const GUARD = [ - 'sidecar=$1; shift', + 'sidecar=$1; receipt=$2; shift 2', '(while kill -0 "$sidecar" 2>/dev/null; do sleep 1; done; kill -KILL 0) /dev/null 2>&1 &', 'watch=$!', '"$@"', 'status=$?', 'kill "$watch" 2>/dev/null', + `printf '{"exitCode":%d,"output":""}' "$status" > "$receipt.new" && mv -f "$receipt.new" "$receipt"`, 'exit "$status"', ].join('\n') +/** Longer than the watch takes to end a command after its sidecar ends. */ +const GUARD_GRACE_MS = 2000 + /** How many symbolic links a path may pass through before it is refused, as the system refuses a loop. */ const MAX_LINKS = 40 @@ -176,10 +181,14 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) const value = process.env[name] if (value !== undefined) vars[name] = value } + // A command a crash cut off may still be ending: wait until its watch has ended it or it left its receipt. + if ((await api.memo('started', context)) === true && !existsSync(op.receipt)) await Bun.sleep(GUARD_GRACE_MS) const receipt = await once(op.memo, op.receipt, async () => { let output = '' + mkdirSync(dirname(op.receipt), { recursive: true }) + await api.memo('started', true, context) const ran = await env.exec( - ['/bin/sh', '-c', GUARD, 'm3d-run', String(process.pid), 'uv', ...args], + ['/bin/sh', '-c', GUARD, 'm3d-run', String(process.pid), op.receipt, 'uv', ...args], { env: vars, inheritEnv: false, diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index 569a08b..d80bea6 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -98,6 +98,8 @@ describe('a crash mid-turn', () => { while (!readFileSync(log, 'utf8').includes('op2')) await Bun.sleep(25) first.child.kill('SIGKILL') await first.child.exited + // The watch on op2 ends it within a second of the crash, before `go` would let it finish. + await Bun.sleep(1500) const reopened = start(where, 'ops') await reopened.next() diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index 14fdc76..b6a1c12 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -56,6 +56,12 @@ const scripts: Record) => Ass if (results.length === 0) return run('touch started; sleep 3; touch finished') return fauxAssistantMessage(fauxText('ran')) }, + /** A command that ends a moment after it starts, so a sidecar killed at its start restarts while it still runs. */ + quick: (messages) => { + const { results } = sinceUser(messages) + if (results.length === 0) return run('touch started; sleep 0.5; echo ran >> ran.log') + return fauxAssistantMessage(fauxText('ran')) + }, /** A command that runs past its one-second timeout. */ slow: (messages) => { const { results } = sinceUser(messages) diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index c8a8454..fd79e6e 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -101,6 +101,23 @@ describe('the run tool', () => { await Bun.sleep(5000) expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) }, 20_000) + + test('runs once when the sidecar restarts at once while a command cut off by its crash still ends', async () => { + const where = scratch() + const first = start(where, 'quick') + await first.next() + const project = await newProject(first) + await send(first, project, 'r1') + while (!existsSync(join(where.projects, project, 'started'))) await Bun.sleep(10) + first.child.kill('SIGKILL') + await first.child.exited + const second = start(where, 'quick') + await second.next() + await answered(second, project, 'ran') + await Bun.sleep(1500) + expect(readFileSync(join(where.projects, project, 'ran.log'), 'utf8')).toBe('ran\n') + await second.quit() + }, 30_000) }) describe('the file tools', () => { From 76a3665a2a68a5bffa117a2aff90b977828346a0 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 01:14:59 -0600 Subject: [PATCH 08/17] test(sidecar): the crash test has time for the watch and the rerun's wait The crash test now waits 1.5 s for the watch to end the cut-off command, and the rerun waits 2 s more, which passed Bun's five-second default on a loaded machine. Co-Authored-By: Claude Opus 5.5 --- sidecar/test/agent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sidecar/test/agent.test.ts b/sidecar/test/agent.test.ts index d80bea6..d03d26f 100644 --- a/sidecar/test/agent.test.ts +++ b/sidecar/test/agent.test.ts @@ -113,7 +113,7 @@ describe('a crash mid-turn', () => { expect(view.entries.map((entry) => entry.kind)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'assistant']) expect(view.running).toBe(false) await reopened.quit() - }) + }, 20_000) }) describe('settings', () => { From 01076bac194fcd0fffb0911600a6ddd4d2b6b19a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 03:18:44 -0600 Subject: [PATCH 09/17] fix(sidecar): a picture sent with a message is read only from inside the project From PR Lens's review of #200: an attachment's path was checked only as text, so a link in the project named like a picture sent the bytes of a file outside it to the model. The check now follows links with the system's realpath, as the file tools do, and refuses a path that really leads outside. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 6 +++--- sidecar/test/tools.test.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 0d661e0..94e8c30 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -3,7 +3,7 @@ * draft's removal together. A turn that stops before its answer waits with the fix named, and continues once it is * fixed. The window sees committed entries only. */ -import { mkdirSync, readFileSync, statSync } from 'node:fs' +import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs' import { extname, isAbsolute, join, relative } from 'node:path' import type { Context } from '@earendil-works/chord' import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context' @@ -42,10 +42,10 @@ export interface ChatsOptions { emit(event: SidecarEvent): void } -/** A path the window named for an attachment, inside the project folder. */ +/** A path the window named for an attachment, inside the project folder where it really leads, links followed. */ function inside(folder: string, rel: string): string { const path = join(folder, rel) - const back = relative(folder, path) + const back = relative(realpathSync.native(folder), realpathSync.native(path)) if (back === '' || back.startsWith('..') || isAbsolute(back)) throw new Error(`${rel} is not a file in the project folder`) return path } diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index fd79e6e..ef1fe8d 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -147,6 +147,22 @@ describe('the file tools', () => { }) }) +describe('a picture sent with a message', () => { + test('never reads a file outside the project through a link', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + writeFileSync(join(where.dir, 'secret.jpg'), 'secret') + mkdirSync(join(where.projects, project, 'refs'), { recursive: true }) + symlinkSync(join(where.dir, 'secret.jpg'), join(where.projects, project, 'refs', 'photo.jpg')) + await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['refs/photo.jpg'] })).rejects.toThrow( + 'is not a file in the project folder', + ) + await app.quit() + }) +}) + describe('Astra', () => { test('without its sign-in, the turn waits with the fix named, and goes on once the person signs in', async () => { const where = scratch() From 9a51af96041b51c48e83d5c40f2737767de4a57e Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 03:28:14 -0600 Subject: [PATCH 10/17] fix(sidecar): a picture converted on macOS is written outside the project, where no link can steer it From review of the attachment fix: a picture too large or in a format the model cannot read is converted with sips into the project's .m3d/send folder, and a link there could make sips overwrite a file outside the project. It is now made in a folder of its own in the system's temp folder and removed once read. Proof on aojdevmac-m5: with a link planted at the old output path, the JPEG comes back and the linked file is untouched. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 94e8c30..95397fd 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -3,7 +3,8 @@ * draft's removal together. A turn that stops before its answer waits with the fix named, and continues once it is * fixed. The window sees committed entries only. */ -import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' import { extname, isAbsolute, join, relative } from 'node:path' import type { Context } from '@earendil-works/chord' import { awaitWithContext, BACKGROUND_CONTEXT } from '@earendil-works/chord/context' @@ -58,11 +59,16 @@ function picture(folder: string, rel: string): ImageContent | undefined { return { type: 'image', data: readFileSync(path).toString('base64'), mimeType: type } } if (process.platform !== 'darwin') return undefined - const out = join(folder, '.m3d', 'send', `${rel.replaceAll('/', '_')}.jpg`) - mkdirSync(join(folder, '.m3d', 'send'), { recursive: true }) - const converted = Bun.spawnSync(['sips', '-s', 'format', 'jpeg', '-Z', '2000', path, '--out', out]) - if (converted.exitCode !== 0) return undefined - return { type: 'image', data: readFileSync(out).toString('base64'), mimeType: 'image/jpeg' } + // The JPEG is made in a folder of its own outside the project, so no link in the project can steer where it is written. + const scratch = mkdtempSync(join(tmpdir(), 'm3d-send-')) + try { + const out = join(scratch, 'picture.jpg') + const converted = Bun.spawnSync(['sips', '-s', 'format', 'jpeg', '-Z', '2000', path, '--out', out]) + if (converted.exitCode !== 0) return undefined + return { type: 'image', data: readFileSync(out).toString('base64'), mimeType: 'image/jpeg' } + } finally { + rmSync(scratch, { recursive: true, force: true }) + } } function userContent(folder: string, text: string, attachments: string[]): string | (TextContent | ImageContent)[] { From 457e8196dddf5cc2abd7b0e748bb38d7bc5e2d96 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 03:47:47 -0600 Subject: [PATCH 11/17] fix(sidecar): a command's output on macOS no longer ends with the shell's note that its watch was stopped macOS's /bin/sh is bash, which prints "Terminated: 15" with the stopped watch's source when it reaps that background job. The note went into every command's output, which the agent reads, and the macOS CI step failed on the env test's exact output. The guard now waits on the stopped watch with its stderr dropped. On aojdevmac-m5: tool tests 15 of 15, the sidecar suite 85 of 85. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index 4d804e9..ed40de5 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -53,7 +53,8 @@ const GUARD = [ 'watch=$!', '"$@"', 'status=$?', - 'kill "$watch" 2>/dev/null', + // Waiting on the stopped watch keeps bash, the macOS sh, from printing that it was terminated into the output. + 'kill "$watch" 2>/dev/null; wait "$watch" 2>/dev/null', `printf '{"exitCode":%d,"output":""}' "$status" > "$receipt.new" && mv -f "$receipt.new" "$receipt"`, 'exit "$status"', ].join('\n') From 8a067a1d8c91cfaa339b0c69a0c4af01d5caa513 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 09:58:38 -0600 Subject: [PATCH 12/17] fix(sidecar): a turn refused after a newer login was saved goes on with that login A request still out on the old login when a new one was saved came back refused after the sign-in had already looked for waits to let go, so the turn waited for a sign-in that had happened. Each turn now records the login save count it went out with, and a refusal that comes after a newer save goes on at once. A refusal with no newer save still waits, with no retry. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 32 +++++++++++++++----- sidecar/src/store.ts | 9 +++--- sidecar/test/waiting.test.ts | 57 +++++++++++++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 15 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 95397fd..3bb3496 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -25,7 +25,7 @@ import type { ConversationView } from '@earendil-works/pi-durable' import { WORKFLOW, WORKFLOW_VERSION } from './agent' import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' import { isLoginFailure } from './inference' -import { DraftsDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' +import { DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' import { chatEntry } from './transcript' const context: Context = BACKGROUND_CONTEXT @@ -150,22 +150,35 @@ export class Chats { this.#o.emit({ type: 'chat', project, entries, running: state.running, waiting: state.waiting, turn: state.turn }) } - /** Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. */ + /** + * Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. + * A login refused after a newer one was saved, while the request was out, goes on with the newer one at once, since + * the sign-in that saved it found no wait to let go. A turn from another version of the app is left to wait. + */ async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { - const { models, store } = this.#o + const { models, store, version } = this.#o const provider = turn?.model.provider ?? 'anthropic' const name = models.getProvider(provider)?.name ?? provider const auth = await models.getAuth(provider).catch(() => undefined) const detail = typeof failure.detail === 'string' ? failure.detail : failure.reason + const refused = failure.reason === 'model_error' && isLoginFailure(detail) const waiting: Waiting = - auth === undefined || (failure.reason === 'model_error' && isLoginFailure(detail)) + auth === undefined || refused ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } : { kind: 'error', detail, fix: 'Try again' } await store.harness.commit(async (tx) => { const record = await tx.doc(WaitingDoc, conversation.id) - record.waiting = waiting record.failure = failure.taskId record.tool = false + // Read in this commit, so a sign-in either lands before it and is seen here, or after it and finds this wait. + const saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[provider] ?? 0 + if (refused && auth !== undefined && record.saves !== null && saves > record.saves && turn?.app === version) { + await conversation.submit(tx, { type: 'continue' }) + record.waiting = null + record.saves = saves + return + } + record.waiting = waiting }, context) } @@ -198,6 +211,7 @@ export class Chats { const waiting = await tx.doc(WaitingDoc, conversation.id) waiting.waiting = null waiting.tool = false + waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[model.provider] ?? 0 }, context) } catch (error) { if (error instanceof ConversationBusy) throw new Error('The agent is still working on the last message.') @@ -215,9 +229,13 @@ export class Chats { const conversation = await this.#conversation(project) await store.harness.commit(async (tx) => { await conversation.submit(tx, { type: 'continue' }) - ;(await tx.doc(WaitingDoc, conversation.id)).waiting = null + const waiting = await tx.doc(WaitingDoc, conversation.id) + waiting.waiting = null const { turn } = await tx.doc(TurnDoc, conversation.id) - if (turn !== null) turn.app = version + if (turn !== null) { + turn.app = version + waiting.saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[turn.model.provider] ?? 0 + } }, context) await this.#watch(project, conversation) } diff --git a/sidecar/src/store.ts b/sidecar/src/store.ts index 309b02b..7ef1e85 100644 --- a/sidecar/src/store.ts +++ b/sidecar/src/store.ts @@ -80,20 +80,21 @@ export const TurnDoc = defineDoc<{ turn: Turn | null }>({ /** * Why a project's turn waits, and the run failure that was read, so one failure is read once. `tool` marks a wait a * running tool holds, such as Astra's for a sign-in: the tool goes on by itself, so nothing continues the run. Cleared - * in the commit that sends, retries, or resumes, or by the tool once its wait ends. + * in the commit that sends, retries, or resumes, or by the tool once its wait ends. `saves` is how many times the + * turn's provider login was saved when the turn last went out, so a refusal can tell a login saved since then. */ -export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | null; tool: boolean }>({ +export const WaitingDoc = defineDoc<{ waiting: Waiting | null; failure: number | null; tool: boolean; saves: number | null }>({ kind: 'm3d.waiting', version: 1, scope: 'conversation', history: 'latest', fork: 'initial', - initial: () => ({ waiting: null, failure: null, tool: false }), + initial: () => ({ waiting: null, failure: null, tool: false, saves: null }), checkpointWhen: always, }) /** How many times each provider's login was saved, so a wait can tell a new sign-in from the one a provider refused. */ -const LoginSavesDoc = defineDoc<{ saves: Record }>({ +export const LoginSavesDoc = defineDoc<{ saves: Record }>({ kind: 'm3d.login-saves', version: 1, scope: 'conversation', diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index 48d2da1..e7d9c25 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -5,14 +5,22 @@ import { join } from 'node:path' import type { ChatEntry, ChatView } from '../src/protocol' import { spawnSidecar } from './sidecar' -/** A proxy that speaks Anthropic's Messages API, as Ossie's Mac reaches Anthropic, and remembers who called. */ -function anthropicProxy(reply: string) { +/** + * A proxy that speaks Anthropic's Messages API, as Ossie's Mac reaches Anthropic, and remembers who called. A request + * with a token in `refuse` waits for `release`, then is refused as a bad login. + */ +function anthropicProxy(reply: string, refuse: { token: string; release: Promise } | undefined = undefined) { const seen: { authorization: string | null; path: string }[] = [] const event = (name: string, data: unknown) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n` const server = Bun.serve({ port: 0, - fetch(request) { - seen.push({ authorization: request.headers.get('authorization'), path: new URL(request.url).pathname }) + async fetch(request) { + const authorization = request.headers.get('authorization') + seen.push({ authorization, path: new URL(request.url).pathname }) + if (refuse !== undefined && authorization === `Bearer ${refuse.token}`) { + await refuse.release + return Response.json({ type: 'error', error: { type: 'authentication_error', message: 'invalid x-api-key' } }, { status: 401 }) + } const body = event('message_start', { type: 'message_start', @@ -67,4 +75,45 @@ describe('a turn whose provider is not signed in', () => { await second.quit() proxy.stop() }) + + test('a request refused after a newer login was saved goes on with that login, without waiting', async () => { + const release = Promise.withResolvers() + const proxy = anthropicProxy('Hello with the new key', { token: 'old-token', release: release.promise }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + while (proxy.seen.length === 0) await Bun.sleep(10) + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await Bun.sleep(200) + release.resolve() + const answered = await app.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello with the new key' }) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await app.quit() + proxy.stop() + }, 20_000) + + test('a refused login with no newer one saved waits for a sign-in, and is not tried again', async () => { + const proxy = anthropicProxy('never', { token: 'old-token', release: Promise.resolve() }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + const waiting = await app.until((event) => event.type === 'chat' && event.waiting !== null) + expect(waiting.waiting).toMatchObject({ kind: 'login', provider: 'anthropic' }) + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(1) + await app.quit() + proxy.stop() + }, 20_000) }) From fd931c5f9a7e28494d8ddb545abda94baeff45b1 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 09:58:45 -0600 Subject: [PATCH 13/17] fix(sidecar): a command that ends after its sidecar's crash keeps its output A command wrote its output to the sidecar's pipe, so once a crash closed it the command died on its next write (exit 141), and the receipt it left held no output. The guard now sends the command's output to a log beside the receipt and leaves the exit code there when it ends. The sidecar shows the log as it grows, and a resumed turn takes the exit code and the log's end. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 58 ++++++++++++++--------- sidecar/test/fixtures/scripted-sidecar.ts | 7 ++- sidecar/test/tools.test.ts | 7 ++- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index ed40de5..0218308 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -44,21 +44,31 @@ const COMMAND_ENV = ['PATH', 'HOME', 'USER', 'LANG', 'TMPDIR'] as const /** * Runs a command under a watch on the sidecar. Pi starts each command in a process group of its own, so it can end the * whole tree on a timeout or a stop, but a sidecar killed outright ends nothing. The watch ends the group, command and - * all, within a second of the sidecar's end, and a command that ends first leaves its receipt with its exit code. So - * a command a crash cut off either finished, and a resumed turn takes its receipt, or was ended. + * all, within a second of the sidecar's end. The command writes its output to its log, never to the sidecar's pipe, so + * a sidecar's end cannot cut it off, and one that ends first leaves its exit code beside the log. So a command a crash + * cut off either finished, and a resumed turn takes its exit code and output, or was ended. */ const GUARD = [ - 'sidecar=$1; receipt=$2; shift 2', + 'sidecar=$1; log=$2; exited=$3; shift 3', '(while kill -0 "$sidecar" 2>/dev/null; do sleep 1; done; kill -KILL 0) /dev/null 2>&1 &', 'watch=$!', - '"$@"', + '"$@" >"$log" 2>&1', 'status=$?', - // Waiting on the stopped watch keeps bash, the macOS sh, from printing that it was terminated into the output. 'kill "$watch" 2>/dev/null; wait "$watch" 2>/dev/null', - `printf '{"exitCode":%d,"output":""}' "$status" > "$receipt.new" && mv -f "$receipt.new" "$receipt"`, + `printf '%d' "$status" > "$exited.new" && mv -f "$exited.new" "$exited"`, 'exit "$status"', ].join('\n') +/** How often a running command's log is read for its new output. */ +const LOG_POLL_MS = 200 + +/** The end of a command's log, where errors are. */ +function tailOf(log: string): string { + if (!existsSync(log)) return '' + const bytes = readFileSync(log) + return new TextDecoder().decode(bytes.subarray(Math.max(0, bytes.length - OUTPUT_TAIL))) +} + /** Longer than the watch takes to end a command after its sidecar ends. */ const GUARD_GRACE_MS = 2000 @@ -182,26 +192,28 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) const value = process.env[name] if (value !== undefined) vars[name] = value } - // A command a crash cut off may still be ending: wait until its watch has ended it or it left its receipt. - if ((await api.memo('started', context)) === true && !existsSync(op.receipt)) await Bun.sleep(GUARD_GRACE_MS) + const log = `${op.receipt}.log` + const exited = `${op.receipt}.exit` + // A command a crash cut off may still be ending: wait until its watch has ended it or it left its exit code. + if ((await api.memo('started', context)) === true && !existsSync(op.receipt) && !existsSync(exited)) { + await Bun.sleep(GUARD_GRACE_MS) + } const receipt = await once(op.memo, op.receipt, async () => { - let output = '' + if (existsSync(exited)) return { exitCode: Number(readFileSync(exited, 'utf8')), output: tailOf(log) } mkdirSync(dirname(op.receipt), { recursive: true }) await api.memo('started', true, context) - const ran = await env.exec( - ['/bin/sh', '-c', GUARD, 'm3d-run', String(process.pid), op.receipt, 'uv', ...args], - { - env: vars, - inheritEnv: false, - timeout: timeoutSeconds ?? 600, - onOutput: (chunk) => { - output = (output + chunk).slice(-OUTPUT_TAIL) - api.output(chunk) - }, - }, - context, - ) - return ran.ok ? { exitCode: ran.value.exitCode, output } : { exitCode: -1, output: `${output}\n${ran.error.message}` } + let shown = 0 + const show = () => { + const bytes = existsSync(log) ? readFileSync(log) : new Uint8Array() + if (bytes.length > shown) api.output(new TextDecoder().decode(bytes.subarray(shown))) + shown = bytes.length + } + const poll = setInterval(show, LOG_POLL_MS) + const ran = await env + .exec(['/bin/sh', '-c', GUARD, 'm3d-run', String(process.pid), log, exited, 'uv', ...args], { env: vars, inheritEnv: false, timeout: timeoutSeconds ?? 600 }, context) + .finally(() => clearInterval(poll)) + show() + return ran.ok ? { exitCode: ran.value.exitCode, output: tailOf(log) } : { exitCode: -1, output: `${tailOf(log)}\n${ran.error.message}` } }) return { output: text(`exit code ${receipt.exitCode}\n${receipt.output}`), isError: receipt.exitCode !== 0 } }, diff --git a/sidecar/test/fixtures/scripted-sidecar.ts b/sidecar/test/fixtures/scripted-sidecar.ts index b6a1c12..0001e5f 100644 --- a/sidecar/test/fixtures/scripted-sidecar.ts +++ b/sidecar/test/fixtures/scripted-sidecar.ts @@ -56,10 +56,13 @@ const scripts: Record) => Ass if (results.length === 0) return run('touch started; sleep 3; touch finished') return fauxAssistantMessage(fauxText('ran')) }, - /** A command that ends a moment after it starts, so a sidecar killed at its start restarts while it still runs. */ + /** + * A command that ends a moment after it starts, so a sidecar killed at its start restarts while it still runs. It + * prints its result after that kill. + */ quick: (messages) => { const { results } = sinceUser(messages) - if (results.length === 0) return run('touch started; sleep 0.5; echo ran >> ran.log') + if (results.length === 0) return run('touch started; sleep 0.5; echo ran >> ran.log; echo measured-value=42') return fauxAssistantMessage(fauxText('ran')) }, /** A command that runs past its one-second timeout. */ diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index ef1fe8d..819874b 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -72,7 +72,9 @@ describe('the run tool', () => { const project = await newProject(app) await send(app, project, 'r1') await answered(app, project, 'exit code 0\nsecret=none op=set\n') - expect(readdirSync(join(where.projects, project, '.m3d', 'ops'))).toHaveLength(1) + // One operation: its receipt, its log, and its exit code. + const files = readdirSync(join(where.projects, project, '.m3d', 'ops')) + expect(new Set(files.map((name) => name.split('.')[0])).size).toBe(1) await app.quit() }) @@ -102,7 +104,7 @@ describe('the run tool', () => { expect(existsSync(join(where.projects, project, 'finished'))).toBe(false) }, 20_000) - test('runs once when the sidecar restarts at once while a command cut off by its crash still ends', async () => { + test('runs once, with its output, when the sidecar restarts at once while a command cut off by its crash still ends', async () => { const where = scratch() const first = start(where, 'quick') await first.next() @@ -116,6 +118,7 @@ describe('the run tool', () => { await answered(second, project, 'ran') await Bun.sleep(1500) expect(readFileSync(join(where.projects, project, 'ran.log'), 'utf8')).toBe('ran\n') + expect((await toolResults(second, project))[0]).toMatchObject({ name: 'run', failed: false, text: 'exit code 0\nmeasured-value=42\n' }) await second.quit() }, 30_000) }) From 2e1b8ead7783768fab6963a96d180b11f31f65e9 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 09:58:45 -0600 Subject: [PATCH 14/17] fix(sidecar): a slice a crash cut off is never run twice at once Rust's slice goes on after the sidecar that asked for it crashes, and the resumed turn asked again, so two Bambu runs wrote the same output. The slice request now carries its tool call's operation id, and Rust runs each operation once: a repeat waits for the first and gets its result, kept for as long as the app runs. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 2 +- sidecar/src/protocol.ts | 6 +++- sidecar/test/pipe.test.ts | 2 +- src-tauri/src/sidecar.rs | 75 +++++++++++++++++++++++++++++++++++---- 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index 0218308..6f9c482 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -232,7 +232,7 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) const project = await projectOf(api.conversationId) const sliced = await once(op.memo, op.receipt, async () => { try { - const { parts, printSeconds, filamentGrams } = await host.request('slice', { project, inputs, output }) + const { parts, printSeconds, filamentGrams } = await host.request('slice', { operation: op.id, project, inputs, output }) return { ok: true, parts, printSeconds, filamentGrams } } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } diff --git a/sidecar/src/protocol.ts b/sidecar/src/protocol.ts index de712f9..fa38cb5 100644 --- a/sidecar/src/protocol.ts +++ b/sidecar/src/protocol.ts @@ -110,8 +110,12 @@ export interface HostRequests { export type HostRequest = keyof HostRequests -/** Paths are relative to the project folder, which Rust resolves from the project id. */ +/** + * Paths are relative to the project folder, which Rust resolves from the project id. Rust slices once per operation: + * a request that repeats one waits for the first and gets its result. + */ export interface SliceParams { + operation: string project: string inputs: string[] output: string diff --git a/sidecar/test/pipe.test.ts b/sidecar/test/pipe.test.ts index 28bd6ef..b3b1d36 100644 --- a/sidecar/test/pipe.test.ts +++ b/sidecar/test/pipe.test.ts @@ -75,7 +75,7 @@ describe('the pipe to Rust', () => { input.write(`${JSON.stringify({ kind: 'answer', id: 2, ok: false, error: 'Keychain is locked' })}\n`) await expect(refused).rejects.toThrow('Keychain is locked') - const open = host.request('slice', { project: 'p', inputs: ['a.stl'], output: 'print/a.3mf' }) + const open = host.request('slice', { operation: 'op', project: 'p', inputs: ['a.stl'], output: 'print/a.3mf' }) input.end() await serving await expect(open).rejects.toThrow('the app closed the pipe') diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs index baf8360..07c0775 100644 --- a/src-tauri/src/sidecar.rs +++ b/src-tauri/src/sidecar.rs @@ -9,7 +9,8 @@ use std::collections::HashMap; use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Mutex; +use std::future::Future; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use serde::de::DeserializeOwned; @@ -17,7 +18,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::{mpsc, oneshot, watch, Notify}; +use tokio::sync::{mpsc, oneshot, watch, Notify, OnceCell}; use crate::credentials; use crate::studio; @@ -54,6 +55,23 @@ pub struct Sidecar { alive: watch::Sender, /// Ends the running sidecar through its process handle, which never signals a reused pid. kill: Notify, + /// Each slice by its operation id, running or done, for as long as the app runs. + slices: Operations, +} + +/// Outside work by operation id: one result each, shared by every request that names the id. +type Operations = Mutex>>>>; + +/// Runs `work` once per operation id. A sidecar that crashed mid-slice leaves Rust's slice running, and its resumed +/// turn asks again with the same id: that request waits for the first and gets its result, so two slices never write +/// the same output, and a result that came after the crash is not lost. +async fn once_per_operation(operations: &Operations, id: String, work: F) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let cell = operations.lock().unwrap_or_else(|e| e.into_inner()).entry(id).or_default().clone(); + cell.get_or_init(work).await.clone() } impl Default for Sidecar { @@ -65,6 +83,7 @@ impl Default for Sidecar { quitting: AtomicBool::new(false), alive: watch::Sender::new(false), kill: Notify::new(), + slices: Mutex::default(), } } } @@ -345,6 +364,14 @@ fn parse(method: &str, params: Value) -> Result serde_json::from_value(params).map_err(|e| format!("bad params for {method}: {e}")) } +/// A slice the agent asked for, under its tool call's operation id. +#[derive(Deserialize)] +struct SliceCall { + operation: String, + #[serde(flatten)] + request: studio::slice::Request, +} + #[derive(Deserialize)] struct Account { account: String, @@ -387,12 +414,15 @@ async fn answer(app: &AppHandle, method: &str, params: Value) -> Result { - let request: studio::slice::Request = parse(method, params)?; + let SliceCall { operation, request } = parse(method, params)?; let cache = app.path().app_cache_dir().map_err(|e| format!("finding the app's cache folder: {e}"))?.join("bambu-presets"); - let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) - .await - .map_err(|e| format!("the slice task failed: {e}"))??; - serde_json::to_value(sliced).map_err(|e| e.to_string()) + once_per_operation(&app.state::().slices, operation, || async move { + let sliced = tauri::async_runtime::spawn_blocking(move || studio::slice::slice(&request, &cache)) + .await + .map_err(|e| format!("the slice task failed: {e}"))??; + serde_json::to_value(sliced).map_err(|e| e.to_string()) + }) + .await } other => Err(format!("no host request {other}")), } @@ -441,6 +471,37 @@ mod tests { assert!(names.iter().all(|name| ["PATH", "HOME", "USER", "LANG", "TMPDIR"].contains(name)), "{names:?}"); } + #[tokio::test] + async fn a_repeated_operation_waits_for_the_first_and_gets_its_result() { + let operations = Operations::default(); + let runs = AtomicU64::new(0); + let work = |answer: &'static str| { + let runs = &runs; + move || async move { + runs.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(50)).await; + Ok(json!(answer)) + } + }; + let (first, again) = tokio::join!( + once_per_operation(&operations, "op".to_owned(), work("first")), + once_per_operation(&operations, "op".to_owned(), work("again")), + ); + assert_eq!((first, again), (Ok(json!("first")), Ok(json!("first")))); + assert_eq!(once_per_operation(&operations, "op".to_owned(), work("later")).await, Ok(json!("first"))); + assert_eq!(runs.load(Ordering::SeqCst), 1); + assert_eq!(once_per_operation(&operations, "other".to_owned(), work("other")).await, Ok(json!("other"))); + assert_eq!(runs.load(Ordering::SeqCst), 2); + } + + #[test] + fn a_slice_call_carries_its_operation_id() { + let call: SliceCall = + serde_json::from_value(json!({ "operation": "abc", "project": "p", "inputs": ["a.stl"], "output": "out.3mf" })).expect("slice call"); + assert_eq!(call.operation, "abc"); + assert!(serde_json::from_value::(json!({ "project": "p", "inputs": ["a.stl"], "output": "out.3mf" })).is_err()); + } + #[test] fn a_protocol_line_parses_into_its_kind() { let request: Outbound = serde_json::from_str(r#"{"kind":"request","id":3,"method":"keychain.get","params":{"account":"anthropic"}}"#).expect("request"); From 014908c5ad5f150923eae799fed53db8731e7722 Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 10:14:13 -0600 Subject: [PATCH 15/17] fix(sidecar): a login saved while a failure is read is not missed The login a failure's read finds can be older than a save that lands while the read waits on the Keychain, and the sign-in that saved it finds no wait to let go. The save count, read in the same commit as the wait, now decides alone: a missing or refused login with a newer save goes on at once. The test harness can hold a Keychain answer read earlier, as a slow Keychain does. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/chat.ts | 11 ++++++----- sidecar/test/sidecar.ts | 25 ++++++++++++++++--------- sidecar/test/waiting.test.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 3bb3496..2d9f9ae 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -152,8 +152,9 @@ export class Chats { /** * Reads why a run stopped: a provider with no login, or a refused one, waits for a sign-in; anything else for a retry. - * A login refused after a newer one was saved, while the request was out, goes on with the newer one at once, since - * the sign-in that saved it found no wait to let go. A turn from another version of the app is left to wait. + * A login missing or refused when a newer one was saved since the turn went out goes on with the newer one at once, + * since the sign-in that saved it found no wait to let go. The login read here may be older than that save, so only + * the save count, read in the commit, decides. A turn from another version of the app is left to wait. */ async #read(conversation: Conversation, failure: RunFailure, turn: Turn | null): Promise { const { models, store, version } = this.#o @@ -161,9 +162,9 @@ export class Chats { const name = models.getProvider(provider)?.name ?? provider const auth = await models.getAuth(provider).catch(() => undefined) const detail = typeof failure.detail === 'string' ? failure.detail : failure.reason - const refused = failure.reason === 'model_error' && isLoginFailure(detail) + const login = auth === undefined || (failure.reason === 'model_error' && isLoginFailure(detail)) const waiting: Waiting = - auth === undefined || refused + login ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } : { kind: 'error', detail, fix: 'Try again' } await store.harness.commit(async (tx) => { @@ -172,7 +173,7 @@ export class Chats { record.tool = false // Read in this commit, so a sign-in either lands before it and is seen here, or after it and finds this wait. const saves = (await tx.doc(LoginSavesDoc, store.root.id)).saves[provider] ?? 0 - if (refused && auth !== undefined && record.saves !== null && saves > record.saves && turn?.app === version) { + if (login && record.saves !== null && saves > record.saves && turn?.app === version) { await conversation.submit(tx, { type: 'continue' }) record.waiting = null record.saves = saves diff --git a/sidecar/test/sidecar.ts b/sidecar/test/sidecar.ts index 03d581b..5f5a5d9 100644 --- a/sidecar/test/sidecar.ts +++ b/sidecar/test/sidecar.ts @@ -3,13 +3,14 @@ type Message = Record /** * The sidecar as Rust runs it: a child process spoken to over stdin and stdout, one JSON object per line. The test * plays Rust. Like Rust, it reads stdout all the time, since the sidecar stops when no one drains it, and it answers - * the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. + * the sidecar's Keychain requests from `keychain`, which stands in for the macOS Keychain. `gate` may hold a request's + * answer, read when the request came, until it calls `answer`, as a slow Keychain does. */ export function spawnSidecar( dataDir: string, projectsDir: string, keychain = new Map(), - options: { script?: string; env?: Record; version?: string } = {}, + options: { script?: string; env?: Record; version?: string; gate?: (request: Message, answer: () => void) => void } = {}, ) { const entry = options.script === undefined ? 'src/main.ts' : 'test/fixtures/scripted-sidecar.ts' const child = Bun.spawn(['bun', entry, '--app-version', options.version ?? '9.9.9', '--data', dataDir, '--projects', projectsDir], { @@ -21,7 +22,8 @@ export function spawnSidecar( }) const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`) - function answer(request: Message) { + /** Reads the Keychain now, and returns how to send that answer, now or later. */ + function answer(request: Message): () => void { const params = request.params as { account: string; secret?: string } const value = request.method === 'keychain.get' @@ -31,11 +33,12 @@ export function spawnSidecar( : request.method === 'keychain.delete' ? (keychain.delete(params.account), null) : undefined - send( - value === undefined - ? { kind: 'answer', id: request.id, ok: false, error: `no ${String(request.method)} here` } - : { kind: 'answer', id: request.id, ok: true, value }, - ) + return () => + send( + value === undefined + ? { kind: 'answer', id: request.id, ok: false, error: `no ${String(request.method)} here` } + : { kind: 'answer', id: request.id, ok: true, value }, + ) } // Every line the sidecar writes, but its requests, waits here until a test reads it. `undefined` marks the end. @@ -48,7 +51,11 @@ export function spawnSidecar( for (let end = buffered.indexOf('\n'); end >= 0; end = buffered.indexOf('\n')) { const message = JSON.parse(buffered.slice(0, end)) as Message buffered = buffered.slice(end + 1) - if (message.kind === 'request') answer(message) + if (message.kind === 'request') { + const reply = answer(message) + if (options.gate === undefined) reply() + else options.gate(message, reply) + } else inbox.push(message) } wake?.() diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index e7d9c25..09d0844 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -100,6 +100,40 @@ describe('a turn whose provider is not signed in', () => { proxy.stop() }, 20_000) + test('a turn that failed with no login goes on when one is saved while the failure is read', async () => { + const proxy = anthropicProxy('Hello after the slow read') + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + // While `holding`, each Keychain read waits in `held` until the test answers it. + let holding = true + const held: (() => void)[] = [] + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map(), { + gate: (request, answer) => (holding && request.method === 'keychain.get' ? held.push(answer) : answer()), + }) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + const nextRead = async (count: number) => { + while (held.length < count) await Bun.sleep(10) + } + // The turn's request finds no login and fails. Its failure is read, and that read asks the Keychain again. + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await nextRead(1) + held[0]?.() + await nextRead(2) + // A key is saved, and its sign-in looks for waits, while the failure's read still waits on the Keychain. + holding = false + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await Bun.sleep(200) + for (const answer of held.slice(1)) answer() + const answered = await app.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect((answered.entries as ChatEntry[]).find((entry) => entry.kind === 'assistant')).toMatchObject({ text: 'Hello after the slow read' }) + await app.quit() + proxy.stop() + }, 20_000) + test('a refused login with no newer one saved waits for a sign-in, and is not tried again', async () => { const proxy = anthropicProxy('never', { token: 'old-token', release: Promise.resolve() }) const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) From 7f27eb1cf73720f27fc4f004c139fca39b4dbe6a Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 10:30:49 -0600 Subject: [PATCH 16/17] fix(sidecar): a command's log is read from where the last read stopped, and only its end The live view read the whole log every 200 ms, and the result read all of it to keep its last 20 KB, so a noisy command made the sidecar read its output again and again. Each poll now reads from the byte it stopped at, at most the newest 20 KB of a burst, and the result reads only the log's end. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 51 +++++++++++++++++++++++++++++++------- sidecar/test/tools.test.ts | 25 +++++++++++++++++-- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index 6f9c482..b15929f 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -8,7 +8,7 @@ * action run exactly once. `run` passes the id on as `M3D_OPERATION_ID`, for a script that wants to check for itself. */ import { createHash } from 'node:crypto' -import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, writeFileSync } from 'node:fs' +import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, writeFileSync } from 'node:fs' import { basename, dirname, isAbsolute, join, relative } from 'node:path' import type { Context, JsonValue } from '@earendil-works/chord' import { @@ -62,11 +62,45 @@ const GUARD = [ /** How often a running command's log is read for its new output. */ const LOG_POLL_MS = 200 +/** The bytes of a file from `from` to its end, at most the last `OUTPUT_TAIL` of them, read without the rest. */ +function readEnd(path: string, from: number): { bytes: Uint8Array; start: number; end: number } { + const fd = openSync(path, 'r') + try { + const end = fstatSync(fd).size + const start = Math.max(from, end - OUTPUT_TAIL) + const bytes = new Uint8Array(Math.max(0, end - start)) + let read = 0 + while (read < bytes.length) { + const got = readSync(fd, bytes, read, bytes.length - read, start + read) + if (got === 0) break + read += got + } + return { bytes: bytes.subarray(0, read), start, end: start + read } + } finally { + closeSync(fd) + } +} + /** The end of a command's log, where errors are. */ -function tailOf(log: string): string { - if (!existsSync(log)) return '' - const bytes = readFileSync(log) - return new TextDecoder().decode(bytes.subarray(Math.max(0, bytes.length - OUTPUT_TAIL))) +export function tailOf(log: string): string { + return existsSync(log) ? new TextDecoder().decode(readEnd(log, 0).bytes) : '' +} + +/** + * A command's log as it grows. Each call gives what was added since the last, read from where that one stopped. A + * burst longer than the model ever sees is skipped to its newest `OUTPUT_TAIL` bytes. + */ +export function logFollower(log: string): () => string { + let shown = 0 + let decoder = new TextDecoder() + return () => { + if (!existsSync(log)) return '' + const { bytes, start, end } = readEnd(log, shown) + // A skip starts mid-stream, so a character cut there is not joined to the one before. + if (start > shown) decoder = new TextDecoder() + shown = end + return decoder.decode(bytes, { stream: true }) + } } /** Longer than the watch takes to end a command after its sidecar ends. */ @@ -202,11 +236,10 @@ export function agentTools({ host, projectOf, signedIn, loginSaves }: AgentHost) if (existsSync(exited)) return { exitCode: Number(readFileSync(exited, 'utf8')), output: tailOf(log) } mkdirSync(dirname(op.receipt), { recursive: true }) await api.memo('started', true, context) - let shown = 0 + const added = logFollower(log) const show = () => { - const bytes = existsSync(log) ? readFileSync(log) : new Uint8Array() - if (bytes.length > shown) api.output(new TextDecoder().decode(bytes.subarray(shown))) - shown = bytes.length + const text = added() + if (text !== '') api.output(text) } const poll = setInterval(show, LOG_POLL_MS) const ran = await env diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 819874b..6ff2060 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { once, operationId } from '../src/agent' +import { logFollower, once, operationId, tailOf } from '../src/agent' import type { ChatEntry, ChatView } from '../src/protocol' import { spawnSidecar } from './sidecar' @@ -123,6 +123,27 @@ describe('the run tool', () => { }, 30_000) }) +describe("a command's log", () => { + test('is followed from where the last read stopped, and a long burst or a recovery gets only its end', () => { + const log = join(mkdtempSync(join(tmpdir(), 'm3d-log-')), 'op.json.log') + const added = logFollower(log) + expect(added()).toBe('') + writeFileSync(log, 'first\n') + expect(added()).toBe('first\n') + expect(added()).toBe('') + appendFileSync(log, 'second\n') + expect(added()).toBe('second\n') + const burst = `${'x'.repeat(1_000_000)}end\n` + appendFileSync(log, burst) + const shown = added() + expect(shown).toHaveLength(20_000) + expect(shown.endsWith('xend\n')).toBe(true) + expect(added()).toBe('') + expect(tailOf(log)).toBe(shown) + expect(tailOf(join(log, '..', 'missing.log'))).toBe('') + }) +}) + describe('the file tools', () => { test('never follow a symbolic link out of the project', async () => { const where = scratch() From 025b2bf4726d234b827d933df6cd5e3858dd2e7d Mon Sep 17 00:00:00 2001 From: Ossie Irondi Date: Sat, 7 Nov 2026 10:45:31 -0600 Subject: [PATCH 17/17] fix(sidecar): stopped turns stay stopped, retries need a new login, and calls keep their own receipts From CodeRabbit's review, each with a test that failed first: - A call id a provider uses again in a later step no longer takes an earlier call's receipt or slice: the operation id now hashes the call's durable task with it. - A turn stopped while it waited on a sign-in stays stopped when the person signs in. - A restart tries a waiting turn again only with a login saved since it went out, not the refused one. - A failure read after a newer send no longer puts a wait on the new turn. - A 403, a login that lacks access, shows as an error to retry, not a sign-in to redo. - A name that starts with two dots stays in the project, for the file tools and pictures. - A picture that is a pipe, not a file, is refused instead of holding the sidecar. - A send that repeats needs no picture file, since nothing is read for it. Co-Authored-By: Claude Opus 5.5 --- sidecar/src/agent.ts | 17 +++++++++---- sidecar/src/chat.ts | 43 ++++++++++++++++++++++++++------ sidecar/src/inference.ts | 7 ++++-- sidecar/test/inference.test.ts | 13 +++++++++- sidecar/test/tools.test.ts | 34 ++++++++++++++++++++++--- sidecar/test/waiting.test.ts | 45 ++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 19 deletions(-) diff --git a/sidecar/src/agent.ts b/sidecar/src/agent.ts index b15929f..a584835 100644 --- a/sidecar/src/agent.ts +++ b/sidecar/src/agent.ts @@ -9,7 +9,7 @@ */ import { createHash } from 'node:crypto' import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, writeFileSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import type { Context, JsonValue } from '@earendil-works/chord' import { AssistantEntry, @@ -136,6 +136,9 @@ function realPath(path: string, links = 0): string { } } +/** Whether a path relative to a folder leads out of it. A name that only starts with two dots, `..notes`, stays in. */ +export const leaves = (rel: string) => rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) + /** One project's files and processes. A path that leads out of the project folder is refused, for every file tool. */ export class ProjectEnv extends NodeExecutionEnv { override async absolutePath(path: string, context: Context): Promise> { @@ -148,7 +151,7 @@ export class ProjectEnv extends NodeExecutionEnv { return err(new FileError('permission_denied', error instanceof Error ? error.message : String(error), path)) } const inside = relative(realpathSync.native(this.cwd), real) - if (inside.startsWith('..') || isAbsolute(inside)) { + if (leaves(inside)) { return err(new FileError('permission_denied', `${path} is outside the project folder`, path)) } return resolved @@ -192,15 +195,19 @@ export interface AgentHost { loginSaves(provider: string): Promise } -/** A call's operation id: a hash of the call id, since an id the model made can hold any character, and no two may share a receipt. */ -export const operationId = (callId: string) => createHash('sha256').update(callId).digest('hex').slice(0, 32) +/** + * A call's operation id: a hash of its durable task and its call id. The task tells apart calls a provider gave the + * same id in different steps, and stays the same when a crash replays the call. The hash holds any character an id + * the model made can hold, and no two calls share a receipt. + */ +export const operationId = (taskId: string, callId: string) => createHash('sha256').update(`${taskId}\0${callId}`).digest('hex').slice(0, 32) /** A tool call's operation: its id, its receipt file in the project's hidden folder, and Pi's memo of it. */ function operation(api: ToolExecutionApi, context: Context) { const env = api.env // Astra's conversation is not a project, so the folder is the one its tools work in, which it shares. if (!(env instanceof ProjectEnv)) throw new Error('this conversation has no project folder') - const id = operationId(api.callId) + const id = operationId(String(api.taskId), api.callId) const memo: Memo = { get: () => api.memo('receipt', context), set: (value) => api.memo('receipt', value, context) } return { env, id, memo, receipt: join(env.cwd, '.m3d', 'ops', `${id}.json`) } } diff --git a/sidecar/src/chat.ts b/sidecar/src/chat.ts index 2d9f9ae..520a619 100644 --- a/sidecar/src/chat.ts +++ b/sidecar/src/chat.ts @@ -22,7 +22,7 @@ import { type WatchHandle, } from '@earendil-works/pi-durable' import type { ConversationView } from '@earendil-works/pi-durable' -import { WORKFLOW, WORKFLOW_VERSION } from './agent' +import { leaves, WORKFLOW, WORKFLOW_VERSION } from './agent' import type { ChatEntry, ChatView, SidecarEvent, Turn, Waiting } from './protocol' import { isLoginFailure } from './inference' import { DraftsDoc, LoginSavesDoc, SettingsDoc, type Store, TurnDoc, WaitingDoc } from './store' @@ -43,11 +43,14 @@ export interface ChatsOptions { emit(event: SidecarEvent): void } -/** A path the window named for an attachment, inside the project folder where it really leads, links followed. */ +/** + * A path the window named for an attachment: a regular file inside the project folder where it really leads, links + * followed. Anything else, such as a pipe that would hold every read, is refused. + */ function inside(folder: string, rel: string): string { const path = join(folder, rel) const back = relative(realpathSync.native(folder), realpathSync.native(path)) - if (back === '' || back.startsWith('..') || isAbsolute(back)) throw new Error(`${rel} is not a file in the project folder`) + if (back === '' || leaves(back) || !statSync(path).isFile()) throw new Error(`${rel} is not a file in the project folder`) return path } @@ -168,6 +171,8 @@ export class Chats { ? { kind: 'login', provider, name, fix: `Sign in to ${name}. The message picks up where it stopped.` } : { kind: 'error', detail, fix: 'Try again' } await store.harness.commit(async (tx) => { + // A send that came after this failure started a new turn, which this failure must not hold. + if ((await tx.doc(TurnDoc, conversation.id)).turn?.requestId !== turn?.requestId) return const record = await tx.doc(WaitingDoc, conversation.id) record.failure = failure.taskId record.tool = false @@ -185,11 +190,16 @@ export class Chats { /** * Sends a message. The message, the settings its turn runs with, and the draft's removal commit together. A send - * whose `requestId` was already committed changes nothing, so the window can send again after any failure. + * whose `requestId` was already committed changes nothing, so the window can send again after any failure, even + * once a picture it sent is gone. */ async send(project: string, requestId: string, text: string, attachments: string[]): Promise { const { store, extension, tools, version } = this.#o const conversation = await this.#conversation(project) + if ((await store.harness.commit((tx) => tx.submissionByRequest(conversation.id, requestId), context)) !== undefined) { + await this.#watch(project, conversation) + return + } const folder = store.projectFolder(project) const content = userContent(folder, text, attachments) try { @@ -303,8 +313,16 @@ export class Chats { return agent.tools.map((tool) => tool.name) } + /** Stops the turn. What it waited on is forgotten, so a later sign-in does not start it again. */ async stop(project: string): Promise { - await (await this.#conversation(project)).abort(context) + const conversation = await this.#conversation(project) + await conversation.abort(context) + await this.#o.store.harness.commit(async (tx) => { + const record = await tx.doc(WaitingDoc, conversation.id) + record.waiting = null + record.tool = false + }, context) + await this.#show(project) } /** A turn that started on another version of the app waits for the person to go on with this one. */ @@ -378,13 +396,22 @@ export class Chats { }, context) continue } - const waiting = record?.waiting - if (waiting?.kind !== 'login' || record?.tool || running) continue + if (record === undefined || record.waiting?.kind !== 'login' || record.tool || running) continue + const waiting = record.waiting if (state.turn !== null && state.turn.app !== version) await this.#outdated(conversation.id) - else if ((await models.getAuth(waiting.provider).catch(() => undefined)) !== undefined) await this.#continue(project.id) + else if (await this.#savedSince(record, waiting.provider)) await this.#continue(project.id) } } + /** + * Whether a login the turn did not go out with is there: one saved since it last went out, as a sign-in that a crash + * cut off before it let the turn go. The login it went out with was missing or refused, so trying it again cannot help. + */ + async #savedSince(record: { saves: number | null }, provider: string): Promise { + if (record.saves !== null) return (await this.#o.store.loginSaves(provider)) > record.saves + return (await this.#o.models.getAuth(provider).catch(() => undefined)) !== undefined + } + async close(): Promise { await Promise.all([...this.#watches.values()].map((watch) => watch.stop())) } diff --git a/sidecar/src/inference.ts b/sidecar/src/inference.ts index 768540d..4b434f0 100644 --- a/sidecar/src/inference.ts +++ b/sidecar/src/inference.ts @@ -60,8 +60,11 @@ const credential = compile( ]), ) -/** A provider's answer that means the login itself is missing or refused, not that the request failed. */ -const LOGIN_FAILURE = /not configured|\b(401|403)\b|unauthori[sz]ed|authentication|invalid (api )?(key|token)|expired/i +/** + * A provider's answer that means the login itself is missing or refused, not that the request failed. A 403 alone is + * not one: it says the login works but lacks access, which a new sign-in to the same account does not change. + */ +const LOGIN_FAILURE = /not configured|\b401\b|unauthori[sz]ed|authentication|invalid (api )?(key|token)|expired/i export const isLoginFailure = (detail: string) => LOGIN_FAILURE.test(detail) diff --git a/sidecar/test/inference.test.ts b/sidecar/test/inference.test.ts index e65225e..ea792e5 100644 --- a/sidecar/test/inference.test.ts +++ b/sidecar/test/inference.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { InMemoryCredentialStore, type OAuthAuth } from '@earendil-works/pi-ai' -import { inferenceModels, KeychainCredentials, keyCredential, Logins } from '../src/inference' +import { inferenceModels, isLoginFailure, KeychainCredentials, keyCredential, Logins } from '../src/inference' import type { LoginEvent, LoginKind } from '../src/protocol' import { spawnSidecar } from './sidecar' @@ -51,6 +51,17 @@ describe('logins', () => { }) }) +describe('a refused request', () => { + test('waits for a sign-in only when the login itself was refused, not when the login lacks access', () => { + expect(isLoginFailure('401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}')).toBe(true) + expect(isLoginFailure('Anthropic is not configured')).toBe(true) + expect(isLoginFailure('OAuth token expired')).toBe(true) + expect( + isLoginFailure('403 {"type":"error","error":{"type":"permission_error","message":"Your API key does not have permission to use the specified resource."}}'), + ).toBe(false) + }) +}) + describe('subscription sign-ins', () => { /** The app's models with Anthropic's sign-in replaced by `login`, and every login event the window would get. */ function withSignIn(login: OAuthAuth['login']) { diff --git a/sidecar/test/tools.test.ts b/sidecar/test/tools.test.ts index 6ff2060..68d060e 100644 --- a/sidecar/test/tools.test.ts +++ b/sidecar/test/tools.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { logFollower, once, operationId, tailOf } from '../src/agent' @@ -59,8 +59,10 @@ describe('an outside action', () => { }) test('calls whose ids differ only in characters a file name cannot hold keep separate receipts', () => { - expect(operationId('call/a')).not.toBe(operationId('call:a')) - expect(operationId('call/a')).toBe(operationId('call/a')) + expect(operationId('t1', 'call/a')).not.toBe(operationId('t1', 'call:a')) + expect(operationId('t1', 'call/a')).toBe(operationId('t1', 'call/a')) + // Some providers number their calls in each message, so a call id comes back in a later step: its task tells them apart. + expect(operationId('t1', 'call_0')).not.toBe(operationId('t2', 'call_0')) }) }) @@ -185,6 +187,32 @@ describe('a picture sent with a message', () => { ) await app.quit() }) + + test('may have a name that starts with two dots, and a send that repeats needs no file', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + const photo = join(where.projects, project, '..photo.png') + writeFileSync(photo, 'png') + await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + await answered(app, project, 'answered by faux-a') + rmSync(photo) + await app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['..photo.png'] }) + await app.quit() + }) + + test('that is not a regular file is refused, and never holds the sidecar', async () => { + const where = scratch() + const app = start(where, 'models') + await app.next() + const project = await newProject(app) + expect(Bun.spawnSync(['mkfifo', join(where.projects, project, 'pipe.png')]).exitCode).toBe(0) + await expect(app.call('chat.send', { project, requestId: 'r1', text: 'like this', attachments: ['pipe.png'] })).rejects.toThrow( + 'is not a file in the project folder', + ) + await app.quit() + }) }) describe('Astra', () => { diff --git a/sidecar/test/waiting.test.ts b/sidecar/test/waiting.test.ts index 09d0844..fed4464 100644 --- a/sidecar/test/waiting.test.ts +++ b/sidecar/test/waiting.test.ts @@ -134,6 +134,51 @@ describe('a turn whose provider is not signed in', () => { proxy.stop() }, 20_000) + test('a stopped turn that waited on a sign-in stays stopped when the person signs in', async () => { + const proxy = anthropicProxy('never') + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const app = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), new Map()) + await app.next() + const { id: project } = (await app.call('projects.create', { title: 'A cube' })) as { id: string } + await app.call('chat.open', { project }) + await app.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await app.until((event) => event.type === 'chat' && event.waiting !== null) + await app.call('chat.stop', { project }) + await app.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(0) + expect(((await app.call('chat.open', { project })) as ChatView).waiting).toBeNull() + await app.quit() + proxy.stop() + }, 20_000) + + test('a restart does not try a refused login again, only one saved after it', async () => { + const proxy = anthropicProxy('Hello after the restart', { token: 'old-token', release: Promise.resolve() }) + const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-')) + const keychain = new Map() + const first = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await first.next() + const { id: project } = (await first.call('projects.create', { title: 'A cube' })) as { id: string } + await first.call('chat.open', { project }) + await first.call('auth.setKey', { provider: 'anthropic', key: 'old-token', baseUrl: proxy.url }) + await first.call('chat.send', { project, requestId: 'r1', text: 'A 20 mm cube', attachments: [] }) + await first.until((event) => event.type === 'chat' && event.waiting !== null) + await first.quit() + const second = spawnSidecar(join(dir, 'state'), join(dir, 'projects'), keychain) + await second.next() + await Bun.sleep(500) + expect(proxy.seen).toHaveLength(1) + await second.call('chat.open', { project }) + await second.call('auth.setKey', { provider: 'anthropic', key: 'new-token', baseUrl: proxy.url }) + await second.until( + (event) => event.type === 'chat' && (event.entries as ChatEntry[]).some((entry) => entry.kind === 'assistant'), + 10_000, + ) + expect(proxy.seen.map((call) => call.authorization)).toEqual(['Bearer old-token', 'Bearer new-token']) + await second.quit() + proxy.stop() + }, 30_000) + test('a refused login with no newer one saved waits for a sign-in, and is not tried again', async () => { const proxy = anthropicProxy('never', { token: 'old-token', release: Promise.resolve() }) const dir = mkdtempSync(join(tmpdir(), 'm3d-wait-'))