/** The worktree a Session requests before it starts: its record, its projection, and its creation. */ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { CREATED_RECORD, createdWorktreeSchema, generatedWorktreeName } from '@deepseek-ai/dsh-experimental-worktree' import type { CreatedWorktree, CreateWorktreePhase } from '@deepseek-ai/dsh-experimental-worktree' import { appendPluginRecord, pluginRecordOf } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { assertNever } from '@deepseek-ai/dsh-util-values' import type {} from '@deepseek-ai/dsh-working-directory' import { z } from 'zod' import type { ForgeMissingWorktree, ForgeSessionWorktree, ForgeWorktreeCreation, ForgeWorktreeFailure, ForgeWorktreeRestoration, ForgeWorktreeState, ForgeWorktreeWaiting, WorktreeRecord, } from './types.ts' /** Plugin record type of worktree requests, withdrawals, and creations. */ export const WORKTREE_RECORD = 'plugin:client-ui-forge/worktree' const EMPTY: Omit = { request: null, created: null, locked: false, stepped: false, restoring: false } const worktreeSchema: z.ZodType = z.object({ path: z.string().min(1), branch: z.string().min(1), from: z.string().min(1).nullable(), baseCommit: z.string().min(1), repositoryRoot: z.string().min(1), }).strict() const recordSchema: z.ZodType = z.discriminatedUnion('action', [ z.object({ action: z.literal('request'), from: z.string().min(1).nullable() }).strict(), z.object({ action: z.literal('cancel') }).strict(), z.object({ action: z.literal('created'), worktree: worktreeSchema }).strict(), z.object({ action: z.literal('restoring') }).strict(), z.object({ action: z.literal('restored') }).strict(), ]) const stateSchema: z.ZodType = z.object({ request: z.object({ from: z.string().min(1).nullable() }).strict().nullable(), created: worktreeSchema.nullable(), locked: z.boolean(), stepped: z.boolean(), restoring: z.boolean(), cwd: z.string().min(1).nullable(), }).strict() /** * The `forgeWorktree` unit. `request` and `cancel` records change the request * until the first `turn/start` or creation; the first creation of an open * request applies until the first `step/start`, read from the worktree * service's `plugin:worktree/created` record, or from the `created` record that * earlier panels wrote. Records outside those periods leave the state * unchanged. The unit also follows the Session's working directory, so * clients, including the Session summary, see the created worktree only while * that directory is the worktree or lies below it. Restoration records retain * incomplete checkout recovery across reloads. */ export const forgeWorktreeProjection = { key: 'forgeWorktree', stateVersion: 5, stateSchema, init: header => ({ ...EMPTY, cwd: header.cwd ?? null }), apply, wire: { viewSchema: worktreeSchema.nullable(), view: state => state.created !== null && state.cwd !== null && within(state.created.path, state.cwd) ? state.created : null, }, } satisfies ProjectionDefinition<'forgeWorktree', ForgeWorktreeState> /** * Whether `path` is `root` or lies below it. * @param root - absolute directory. * @param path - absolute path. * @returns whether `root` contains `path`. */ export function within(root: string, path: string): boolean { const rest = relative(root, path) return rest === '' || (rest !== '..' && !rest.startsWith(`..${sep}`) && !isAbsolute(rest)) } /** * The text of a failure: an Error's message, or else the thrown value as a string. * @param error - what was thrown. * @returns the text. */ export function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } function apply(state: ForgeWorktreeState, event: SessionEvent): ForgeWorktreeState { switch (event.type) { case 'turn/start': return state.locked ? state : { ...state, locked: true } case 'step/start': return state.stepped ? state : { ...state, stepped: true } case 'working-directory/change': return state.cwd === event.data.cwd ? state : { ...state, cwd: event.data.cwd } // Every other event type, including plugin records, which sit outside SessionEventMap. default: return applyRecord(state, event) } } function applyRecord(state: ForgeWorktreeState, event: SessionEvent): ForgeWorktreeState { const record = pluginRecordOf(event) if (record?.type === CREATED_RECORD) { const created = createdWorktreeSchema.safeParse(record.data) return !created.success || state.created !== null || state.stepped || state.request === null ? state : { ...state, created: { ...created.data, from: state.request.from } } } if (record?.type !== WORKTREE_RECORD) return state const parsed = recordSchema.safeParse(record.data) if (!parsed.success) return state const data = parsed.data const settled = state.created !== null switch (data.action) { case 'request': return state.locked || settled ? state : { ...state, request: { from: data.from } } case 'cancel': return state.locked || settled || state.request === null ? state : { ...state, request: null } case 'created': return settled || state.stepped || state.request === null ? state : { ...state, created: data.worktree } case 'restoring': return !settled || state.restoring ? state : { ...state, restoring: true } case 'restored': return !state.restoring ? state : { ...state, restoring: false } /* v8 ignore next -- closed WorktreeRecord action union exhaustiveness guard */ default: return assertNever(data, 'WorktreeRecord.action') } } /** A failed attempt to create a requested worktree; `cause` holds the worktree service's error. */ export class CreationFailure extends Error { /** * @param branch - branch the attempt created. * @param phase - step that failed. * @param reason - the worktree service's failure text. * @param cause - the worktree service's error. */ constructor(readonly branch: string, readonly phase: CreateWorktreePhase, readonly reason: string, cause: unknown) { super(`The requested worktree ${branch} could not be created`, { cause }) this.name = 'CreationFailure' } } /** * The failure a waiting message shows for an attempt. * @param error - what the attempt threw. * @param from - base branch of the attempt, or null for HEAD. * @returns the failure. */ function failureOf(error: unknown, from: string | null): ForgeWorktreeFailure { return error instanceof CreationFailure ? { branch: error.branch, from, phase: error.phase, message: error.reason } : { branch: null, from, phase: 'prepare', message: messageOf(error) } } /** Work on a Session's checkout that runs as maintenance of its Agent. */ export type HeldWork = (agent: Agent, signal: AbortSignal) => Promise /** A message's wait for the creation of the requested worktree. */ interface CreationHold { readonly kind: 'creation' /** Stops the running attempt. */ stop: AbortController | undefined /** The wait for the next decision: another attempt, or reading the request again. */ next: PromiseWithResolvers<'retry' | 'recheck'> | undefined } /** A message's wait for the restoration or leaving of the removed worktree the Session works in. */ interface RestorationHold { readonly kind: 'restoration' /** Whether the check found the worktree gone. */ waiting: boolean /** The wait for the work that restores or leaves the worktree, with its outcome. */ next: PromiseWithResolvers<{ readonly work: HeldWork; readonly done: PromiseWithResolvers }> | undefined } type Hold = CreationHold | RestorationHold /** * Creates requested worktrees, one attempt with a new branch name per call, * and holds the turn of a message while the Session's worktree is not ready. * A hold is maintenance of the Session's Agent that starts while the message * enters the inbox, before the delivery wakes the Agent, so the Agent starts * the message's turn once the hold ends. A creation hold makes an attempt * and, after a failed or stopped one, waits for another attempt or for the * request to be withdrawn; a restoration hold checks the worktree the Session * works in and, when it is gone, waits until it is restored or left. The * Agent's cancellation and the panel's stop end a hold. The first step * proposal of a Session whose request is still open also creates the * worktree, which covers a message no hold took. The worktree service enters * the created checkout and queues its directory notice; at a step proposal * the notice joins the proposed messages so the first request already names * the new directory, a proposal without messages leaves it queued, and a * failure ends the turn with the request still open. A failed attempt may * leave its branch or checkout behind. */ export class WorktreeCreations { private readonly live = new Map() private readonly restorations = new Map() private readonly failures = new Map() private readonly holds = new Map() private readonly lifetime = new AbortController() private readonly pending = new Set>() /** Stop admission, cancel operations, and wait for their final callbacks. */ readonly dispose: () => Promise /** * @param ctx - context that optionally provides `worktrees`. * @param states - reads the `forgeWorktree` projection of a Session. * @param prefix - branch name prefix that `generatedWorktreeName` combines with the Session id and random digits. * @param changed - called after the creation or restoration of a Session starts, changes phase, or ends, after its * failure changes, and after a wait starts or ends. */ constructor( private readonly ctx: Context, private readonly states: ProjectionHandle, private readonly prefix: string, private readonly changed: (session: Session) => void, ) { ctx.on('agent/pre-step', async ({ agent, step, signal }, next): Promise => await this.preStep(agent, step, signal, next)) this.dispose = ctx.effect(() => async () => { this.lifetime.abort(new Error('The forge panel stopped')) await Promise.allSettled(this.pending) }, 'forge-panel: stop worktree operations') } /** * Read the running creation of a Session. * @param session - Session to read. * @returns the running creation, or null. */ of(session: Session): ForgeWorktreeCreation | null { return this.live.get(session) ?? null } /** * Read the running restoration of a Session. * @param session - Session to read. * @returns the running restoration, or null. */ restorationOf(session: Session): ForgeWorktreeRestoration | null { return this.restorations.get(session) ?? null } /** * Read the failed attempt that a waiting message saw last. * @param session - Session to read. * @returns the failure, or null. */ failureOf(session: Session): ForgeWorktreeFailure | null { return this.failures.get(session) ?? null } /** * Read what a message of a Session waits for before its turn starts. * @param session - Session to read. * @returns the creation or restoration the message waits for, or null while none waits. */ waitingOf(session: Session): ForgeWorktreeWaiting | null { const hold = this.holds.get(session) if (hold === undefined || (hold.kind === 'restoration' && !hold.waiting)) return null return hold.kind } /** * Forget the failure of a Session whose request changes or is withdrawn; the record of that change republishes the panel. * @param session - Session whose failure no longer applies. */ forget(session: Session): void { this.failures.delete(session) } /** * Hold the turn of a message entering the inbox of a Session whose * requested worktree does not exist yet, and create the worktree. Call it * while the message enters, before the delivery wakes the Agent. A Session * already held, or an Agent with other work, gets no new hold. * @param agent - Agent of the Session. */ holdCreation(agent: Agent): void { this.hold(agent, { kind: 'creation', stop: undefined, next: undefined }, (hold, signal) => this.runCreation(agent, hold, signal)) } /** * Hold the turn of a message entering the inbox of a Session that works in * its created worktree while `check` decides whether that worktree is gone, * and until it is restored or left when it is. Call it while the message * enters, before the delivery wakes the Agent. A Session already held, or an * Agent with other work, gets no new hold. * @param agent - Agent of the Session. * @param check - answers whether the worktree is gone; a rejection lets the message go on. */ holdRestoration(agent: Agent, check: (signal: AbortSignal) => Promise): void { this.hold(agent, { kind: 'restoration', waiting: false, next: undefined }, (hold, signal) => this.runRestoration(agent, hold, check, signal)) } /** * Make another attempt for the message that waits after a failed or stopped one. * @param session - Session whose message waits. * @returns whether a waiting message without a running attempt took the decision. */ retry(session: Session): boolean { const hold = this.holds.get(session) if (hold?.kind !== 'creation' || hold.next === undefined) return false hold.next.resolve('retry') return true } /** * Read the request again after it changed; a withdrawn request lets the waiting message go on. * @param session - Session whose request changed. */ recheck(session: Session): void { const hold = this.holds.get(session) if (hold?.kind === 'creation') hold.next?.resolve('recheck') } /** * Run work on the removed worktree a message waits for, inside the wait; its success lets the message go on. * @param session - Session whose message waits. * @param work - restores or leaves the worktree. * @returns the work's outcome, or undefined when no message waits for the removed worktree. */ within(session: Session, work: HeldWork): Promise | undefined { const hold = this.holds.get(session) if (hold?.kind !== 'restoration' || hold.next === undefined) return undefined const done = Promise.withResolvers() hold.next.resolve({ work, done }) return done.promise } /** * Stop the running attempt a message waits for; the message keeps waiting * with the request still open. Without such an attempt the Session is unchanged. * @param session - Session whose attempt stops. */ stop(session: Session): void { const hold = this.holds.get(session) if (hold?.kind === 'creation') hold.stop?.abort(new Error('The worktree creation was stopped')) } /** * Check the removed worktree of a Session out again through the worktree * service; the running restoration is readable through * {@link WorktreeCreations.restorationOf} until it ends. * @param agent - Agent of the Session. * @param worktrees - the worktree service. * @param created - the worktree the Session created. * @param signal - cancellation of the restoration. * @param restored - runs once the checkout is back, before the restoration ends, so both changes publish together. * @param available - checks whether the recorded checkout is already usable, within this operation's lifetime. * @throws the worktree service's error when the restoration fails; the cancellation reason once `signal` aborts. */ async restore( agent: Agent, worktrees: Context['worktrees'], created: ForgeSessionWorktree, signal: AbortSignal, restored: () => void, available: (signal: AbortSignal) => Promise, ): Promise { await this.run(signal, async (operation) => { const usable = await available(operation) operation.throwIfAborted() if (!usable) await this.restoreCheckout(agent, worktrees, created, operation, restored) }) } private async restoreCheckout( agent: Agent, worktrees: Context['worktrees'], created: ForgeSessionWorktree, signal: AbortSignal, restored: () => void, ): Promise { const session = agent.session appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' }) try { await worktrees.restore(agent, created, signal, (phase) => { this.restorations.set(session, { phase }) this.changed(session) }) signal.throwIfAborted() appendPluginRecord(session, WORKTREE_RECORD, { action: 'restored' }) restored() } finally { this.restorations.delete(session) this.changed(session) } } /** * Create a Session's requested worktree with a new branch name, enter it, and * record it on the Session. The running creation is readable through * {@link WorktreeCreations.of} until the record exists or the attempt fails. * @param agent - Agent of the Session. * @param from - base branch, or null for HEAD of the current checkout. * @param signal - cancellation of the attempt. * @param held - whether a message waits for the creation before its turn starts. * @returns the created worktree. * @throws {@link CreationFailure} when the attempt fails; the cancellation reason once `signal` aborts. */ async create(agent: Agent, from: string | null, signal: AbortSignal, held: boolean): Promise { return await this.run(signal, operation => this.createCheckout(agent, from, operation, held)) } private async createCheckout(agent: Agent, from: string | null, signal: AbortSignal, held: boolean): Promise { const worktrees = this.ctx.get('worktrees') if (worktrees === undefined) throw new Error('No worktree service is mounted') const session = agent.session let status: ForgeWorktreeCreation = { phase: 'prepare', branch: generatedWorktreeName(this.prefix, session.id), from, held } this.failures.delete(session) this.live.set(session, status) this.changed(session) try { let created: CreatedWorktree try { created = await worktrees.create(agent, { name: status.branch, ...from === null ? {} : { from } }, signal, (phase) => { status = { ...status, phase } this.live.set(session, status) this.changed(session) }) } catch (error) { signal.throwIfAborted() throw new CreationFailure(status.branch, status.phase, messageOf(error), error) } signal.throwIfAborted() return created } finally { // After the service's `plugin:worktree/created` record, so a follower never sees the creation end without its result. this.live.delete(session) this.changed(session) } } /** * Run a hold as maintenance of the Agent, which latches the delivery's wake until the hold ends. * @param agent - Agent of the Session. * @param hold - the hold's state. * @param body - waits for the worktree; settles once the message may go on. */ private hold(agent: Agent, hold: H, body: (hold: H, signal: AbortSignal) => Promise): void { if (this.lifetime.signal.aborted) return const session = agent.session if (this.holds.has(session)) return let task: Promise try { task = agent.runMaintenance(async (maintenance) => { // Leave the inbox mutation that announced the message before the hold touches the Session. await Promise.resolve() try { await body(hold, AbortSignal.any([maintenance, this.lifetime.signal])) } catch (error) { void error // Only the Agent's cancellation or disposal and the panel's stop reject a hold's waits; each ends it. } finally { this.holds.delete(session) this.failures.delete(session) this.changed(session) } }) } catch (error) { void error // A running turn or other maintenance owns the Agent, so the message takes its usual path. return } this.holds.set(session, hold) void this.join(task) } private async run(signal: AbortSignal, body: (signal: AbortSignal) => Promise): Promise { const operation = AbortSignal.any([signal, this.lifetime.signal]) operation.throwIfAborted() return await this.join(body(operation)) } private async join(task: Promise): Promise { this.pending.add(task) try { return await task } finally { this.pending.delete(task) } } /** * Make attempts until one creates the worktree or the request no longer asks * for one, waiting for a decision after each failed or stopped attempt. */ private async runCreation(agent: Agent, hold: CreationHold, signal: AbortSignal): Promise { const session = agent.session for (;;) { const state = this.states.stateOf(session) if (state.request === null || state.created !== null || state.locked) return const stop = new AbortController() hold.stop = stop try { await this.create(agent, state.request.from, AbortSignal.any([signal, stop.signal]), true) return } catch (error) { signal.throwIfAborted() if (stop.signal.aborted) this.failures.delete(session) else this.failures.set(session, failureOf(error, state.request.from)) this.changed(session) } finally { hold.stop = undefined } while (await this.wait(hold, signal) !== 'retry') { const current = this.states.stateOf(session) if (current.request === null || current.created !== null || current.locked) return } } } /** Check the worktree the Session works in and, when it is gone, run the work that restores or leaves it until one succeeds. */ private async runRestoration( agent: Agent, hold: RestorationHold, check: (signal: AbortSignal) => Promise, signal: AbortSignal, ): Promise { let missing: ForgeMissingWorktree | null try { missing = await check(signal) } catch (error) { // Without an answer from the filesystem or Git the panel offers no restoration, so the message goes on. signal.throwIfAborted() void error return } if (missing === null) return hold.waiting = true this.changed(agent.session) for (;;) { const { work, done } = await this.wait(hold, signal) try { await work(agent, signal) } catch (error) { done.reject(error) signal.throwIfAborted() continue } done.resolve() return } } /** Wait for the next decision on a hold; rejects with the abort reason once `signal` aborts. */ private async wait(hold: { next: PromiseWithResolvers | undefined }, signal: AbortSignal): Promise { signal.throwIfAborted() const next = Promise.withResolvers() hold.next = next const abort = (): void => { next.reject(signal.reason) } signal.addEventListener('abort', abort, { once: true }) try { return await next.promise } finally { signal.removeEventListener('abort', abort) hold.next = undefined } } private async preStep(agent: Agent, step: number, signal: AbortSignal, next: () => Promise): Promise { const state = this.states.stateOf(agent.session) if (step !== 1 || state.request === null || state.created !== null || state.stepped) return await next() await this.create(agent, state.request.from, signal, false) const decision = await next() if (decision.kind === 'reject' || decision.messages.length === 0) return decision const notices = agent.inbox.nextStep.filter(message => message.source.kind === 'working-directory') for (const notice of notices) agent.inbox.remove(notice.id) return notices.length === 0 ? decision : { ...decision, messages: [...decision.messages, ...notices] } } }