mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-10 04:27:42 +08:00
573 lines
24 KiB
TypeScript
573 lines
24 KiB
TypeScript
/** 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 { 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<ForgeWorktreeState, 'cwd'> = { request: null, created: null, locked: false, stepped: false, restoring: false }
|
|
|
|
const worktreeSchema: z.ZodType<ForgeSessionWorktree> = 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<WorktreeRecord> = 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<ForgeWorktreeState> = 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 `created` record of an
|
|
* open request applies until the first `step/start`. 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: 4,
|
|
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 !== 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<void>
|
|
|
|
/** 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<void> }> | 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<Session, ForgeWorktreeCreation>()
|
|
private readonly restorations = new Map<Session, ForgeWorktreeRestoration>()
|
|
private readonly failures = new Map<Session, ForgeWorktreeFailure>()
|
|
private readonly holds = new Map<Session, Hold>()
|
|
private readonly lifetime = new AbortController()
|
|
private readonly pending = new Set<Promise<unknown>>()
|
|
/** Stop admission, cancel operations, and wait for their final callbacks. */
|
|
readonly dispose: () => Promise<void>
|
|
|
|
/**
|
|
* @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<ForgeWorktreeState>,
|
|
private readonly prefix: string,
|
|
private readonly changed: (session: Session) => void,
|
|
) {
|
|
ctx.on('agent/pre-step', async ({ agent, step, signal }, next): Promise<PreStepDecision> => 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<ForgeMissingWorktree | null>): 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<void> | undefined {
|
|
const hold = this.holds.get(session)
|
|
if (hold?.kind !== 'restoration' || hold.next === undefined) return undefined
|
|
const done = Promise.withResolvers<void>()
|
|
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<boolean>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<CreatedWorktree> {
|
|
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<CreatedWorktree> {
|
|
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()
|
|
const worktree: ForgeSessionWorktree = {
|
|
path: created.path, branch: created.branch, from, baseCommit: created.baseCommit, repositoryRoot: created.repositoryRoot,
|
|
}
|
|
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree })
|
|
return created
|
|
} finally {
|
|
// After the 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<H extends Hold>(agent: Agent, hold: H, body: (hold: H, signal: AbortSignal) => Promise<void>): void {
|
|
if (this.lifetime.signal.aborted) return
|
|
const session = agent.session
|
|
if (this.holds.has(session)) return
|
|
let task: Promise<void>
|
|
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<T>(signal: AbortSignal, body: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
const operation = AbortSignal.any([signal, this.lifetime.signal])
|
|
operation.throwIfAborted()
|
|
return await this.join(body(operation))
|
|
}
|
|
|
|
private async join<T>(task: Promise<T>): Promise<T> {
|
|
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<void> {
|
|
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<ForgeMissingWorktree | null>,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
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<T>(hold: { next: PromiseWithResolvers<T> | undefined }, signal: AbortSignal): Promise<T> {
|
|
signal.throwIfAborted()
|
|
const next = Promise.withResolvers<T>()
|
|
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<PreStepDecision>): Promise<PreStepDecision> {
|
|
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] }
|
|
}
|
|
}
|