Files
deepseek-harness/packages/experimental/worktree/src/index.ts
T
Yichen Jiang eea6d3b7ec feat(forge): hold a sent message until the Session's worktree is ready
The agent-controller admission hook was reverted on master, so the forge
panel now holds the next Turn with Agent maintenance started from the
agent/inbox/inserted notification. The message stays in the inbox until
the hold ends.

- A message sent before the requested worktree exists waits while the
  panel creates it. After a failed or stopped attempt the message keeps
  waiting until the card retries, through the new
  forgePanel.retryWorktree, or withdraws the request so the message goes
  on in the project directory.
- A message sent after the Session's worktree was removed waits on the
  missing card until the worktree is restored or the Session returns to
  the source checkout.
- Restoring a worktree reports its prepare and checkout steps. The card
  shows them in place of its actions, and the tab's restore action shows
  a spinner while it runs.
- The panel state names what a waiting message waits for and the running
  restoration; the agent-controller dependency is removed.
2026-10-22 17:17:55 +08:00

285 lines
14 KiB
TypeScript

/** Creates a new Git branch and checkout, then changes the calling Session's working directory; restores a removed checkout. */
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-fs'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-working-directory'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { PREPARE_DIRECTORY } from './directory.ts'
import { generatedWorktreeName } from './name.ts'
import { runCommand } from './process.ts'
import type { ProcessConfig } from './process.ts'
import type { CreatedWorktree, CreateWorktreePhase, CreateWorktreeRequest, RestoredWorktree, RestoreWorktreeRequest } from './types.ts'
export type { CreatedWorktree, CreateWorktreePhase, CreateWorktreeRequest, RestoredWorktree, RestoreWorktreeRequest } from './types.ts'
export { generatedWorktreeName } from './name.ts'
/** Worktree location, generated names, executables, and subprocess resource limits. */
export interface Config {
/** Relative checkout pool inside the source repository. */
directory?: string
/** Prefix for automatically generated branch and checkout names. */
namePrefix?: string
/** Executable name or absolute execution-world path of a Git release that honors `GIT_NO_LAZY_FETCH`. */
gitCommand?: string
/** Node executable used for sandboxed directory allocation. */
nodeCommand?: string
/** Deadline in milliseconds for one command. */
timeoutMs?: number
/** Subprocess termination grace in milliseconds. */
graceMs?: number
/** Maximum captured bytes per subprocess output stream. */
maxOutputBytes?: number
}
type ResolvedConfig = Required<Config> & ProcessConfig
interface WorktreeSpec {
branch: string
revision: string
}
/** Runs one confined Git command; output with an exit code outside `allowedExitCodes` throws. */
type GitRun = (directory: string, args: string[], allowedExitCodes?: number[], env?: NodeJS.ProcessEnv) => ReturnType<typeof runCommand>
declare module '@deepseek-ai/cordis' {
interface Context {
worktrees: WorktreeService
}
}
/** Reject path traversal independently of Git's branch-name grammar. */
function relativePath(value: string): boolean {
return value.length > 0 && !value.includes('\\') && !value.includes(':')
&& value.split('/').every(part => part.length > 0 && part !== '.' && part !== '..')
}
/** Creates retained Git worktrees under the mounted filesystem and sandbox providers. */
export class WorktreeService extends Service {
static inject = ['workingDirectory', 'fs', 'subprocess', 'sandbox', 'sandboxPolicy']
static Config: z<Config> = z.object({
directory: z.string().default('.agents/worktrees'),
namePrefix: z.string().default('dsh-'),
gitCommand: z.string().default('git'),
nodeCommand: z.string().default('node'),
timeoutMs: z.number().default(60_000),
graceMs: z.number().default(3_000),
maxOutputBytes: z.number().default(65_536),
})
private readonly config: ResolvedConfig
private readonly lifetime = new AbortController()
private readonly pending = new Set<Promise<unknown>>()
constructor(ctx: Context, config: Config) {
super(ctx, 'worktrees')
this.config = config as ResolvedConfig
if (!relativePath(this.config.directory)) throw new Error('worktree: directory must be a relative directory without dot segments')
if (!relativePath(`${this.config.namePrefix}name`) || this.config.namePrefix.startsWith('-')) {
throw new Error('worktree: namePrefix must produce a relative branch name')
}
for (const key of ['gitCommand', 'nodeCommand'] as const) {
if (this.config[key].trim().length === 0) throw new Error(`worktree: ${key} must not be empty`)
}
for (const key of ['timeoutMs', 'graceMs', 'maxOutputBytes'] as const) {
const value = this.config[key]
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`worktree: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
ctx.effect(() => async () => {
this.lifetime.abort(new Error('worktree service disposed'))
await Promise.allSettled(this.pending)
})
}
/**
* Create a fresh branch and checkout at a pinned local revision, then enter it.
* Existing branches or checkout paths fail. Uncommitted files stay in the source checkout.
* Checkout disables configured clean, smudge, and process filters without changing Git config.
* Repository-local replacement refs still apply; baseCommit reports the resolved object name.
* The new checkout becomes current only after Git setup succeeds. Failures may retain newly
* allocated Git/filesystem artifacts; no branch or checkout is removed automatically.
* @param agent - caller whose current directory selects the source repository and file policy.
* @param request - optional new name and local revision; defaults are generated name and HEAD.
* @param signal - cancellation of lookup, creation, and working-directory publication.
* @param onPhase - receives each stage before it begins; it must not throw.
* @returns canonical checkout path, branch name, pinned commit, and source repository root.
*/
async create(
agent: Agent,
request: CreateWorktreeRequest = {},
signal?: AbortSignal,
onPhase?: (phase: CreateWorktreePhase) => void,
): Promise<CreatedWorktree> {
const operationSignal = signal === undefined ? this.lifetime.signal : AbortSignal.any([this.lifetime.signal, signal])
operationSignal.throwIfAborted()
const operation = this.createCheckout(agent, this.resolve(agent, request), operationSignal, onPhase)
this.pending.add(operation)
try {
return await operation
} finally {
this.pending.delete(operation)
}
}
/**
* Check out a removed worktree again at its recorded directory: its branch
* when the branch still exists, otherwise a new branch of the same name at
* `baseCommit`. Stale worktree registrations are pruned first, and an
* existing directory fails. Changes that were never committed are not
* recovered. Checkout disables configured clean, smudge, and process filters,
* as creation does. The calling Session's working directory does not change.
* Failures may retain newly allocated Git/filesystem artifacts.
* @param agent - caller whose sandbox policy confines the commands.
* @param request - recorded directory, branch, base commit, and source repository root.
* @param signal - cancellation of the restoration.
* @param onPhase - receives each stage before it begins; it must not throw.
* @returns the directory and whether the branch was created again.
*/
async restore(
agent: Agent,
request: RestoreWorktreeRequest,
signal?: AbortSignal,
onPhase?: (phase: CreateWorktreePhase) => void,
): Promise<RestoredWorktree> {
const operationSignal = signal === undefined ? this.lifetime.signal : AbortSignal.any([this.lifetime.signal, signal])
operationSignal.throwIfAborted()
const operation = this.restoreCheckout(agent, request, operationSignal, onPhase)
this.pending.add(operation)
try {
return await operation
} finally {
this.pending.delete(operation)
}
}
private resolve(agent: Agent, request: CreateWorktreeRequest): WorktreeSpec {
const branch = request.name ?? generatedWorktreeName(this.config.namePrefix, agent.session.id)
if (!relativePath(branch) || branch.startsWith('-')) throw new Error('worktree name must be a relative Git branch name')
const revision = request.from ?? 'HEAD'
if (revision.length === 0) throw new Error('worktree from must name a local commit, branch, or tag')
return { branch, revision }
}
private async createCheckout(
agent: Agent,
{ branch, revision }: WorktreeSpec,
signal: AbortSignal,
onPhase: ((phase: CreateWorktreePhase) => void) | undefined,
): Promise<CreatedWorktree> {
onPhase?.('prepare')
const cwd = await this.ctx.workingDirectory.ensure(agent, signal)
const policy = this.ctx.sandboxPolicy.resolve({ session: agent.session })
const run = this.gitRunner(policy, signal)
await run(cwd, ['check-ref-format', `refs/heads/${branch}`])
const rootOutput = await run(cwd, ['rev-parse', '--show-toplevel'])
const rootTarget = await this.ctx.fs.resolve(rootOutput.stdout.replace(/\r?\n$/, ''), { cwd, signal })
const repositoryRoot = this.ctx.fs.processPath(rootTarget)
const base = await run(repositoryRoot, ['rev-parse', '--verify', '--end-of-options', `${revision}^{commit}`])
const baseCommit = base.stdout.trim()
const existing = await run(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], [0, 1])
if (existing.exitCode === 0) throw new Error(`worktree branch already exists: ${branch}`)
const pool = await this.ctx.fs.resolve(this.config.directory, { cwd: repositoryRoot, signal })
const poolPath = this.ctx.fs.processPath(pool)
const destination = await this.ctx.fs.resolve(branch, { cwd: poolPath, signal })
const path = this.ctx.fs.processPath(destination)
if (await this.ctx.fs.lstat(path, undefined, signal) !== undefined) throw new Error(`worktree path already exists: ${path}`)
try {
await this.prepareDirectory(policy, repositoryRoot, poolPath, path, signal)
await run(repositoryRoot, ['--work-tree', repositoryRoot, 'worktree', 'add', '--no-checkout', '-b', branch, '--', path, baseCommit])
const overrides = await filterOverrides(run, path)
onPhase?.('checkout')
await run(path, ['--work-tree', path, 'reset', '--hard', '--no-recurse-submodules', baseCommit], undefined, overrides)
const canonicalPath = await this.ctx.workingDirectory.set(agent, path, signal)
return { path: canonicalPath, branch, baseCommit, repositoryRoot }
} catch (error) {
throw new Error(`Could not finish worktree ${branch} at ${path}. Any created checkout and branch are retained. ${error instanceof Error ? error.message : String(error)}`, { cause: error })
}
}
private async restoreCheckout(
agent: Agent,
{ path, branch, baseCommit, repositoryRoot }: RestoreWorktreeRequest,
signal: AbortSignal,
onPhase: ((phase: CreateWorktreePhase) => void) | undefined,
): Promise<RestoredWorktree> {
onPhase?.('prepare')
const policy = this.ctx.sandboxPolicy.resolve({ session: agent.session })
const run = this.gitRunner(policy, signal)
if (await this.ctx.fs.lstat(path, undefined, signal) !== undefined) throw new Error(`worktree path already exists: ${path}`)
await run(repositoryRoot, ['worktree', 'prune'])
const existing = await run(repositoryRoot, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], [0, 1])
const branchRecreated = existing.exitCode !== 0
const pool = await this.ctx.fs.resolve(this.config.directory, { cwd: repositoryRoot, signal })
try {
await this.prepareDirectory(policy, repositoryRoot, this.ctx.fs.processPath(pool), path, signal)
await run(repositoryRoot, [
'--work-tree', repositoryRoot, 'worktree', 'add', '--no-checkout',
...branchRecreated ? ['-b', branch, '--', path, baseCommit] : ['--', path, branch],
])
const overrides = await filterOverrides(run, path)
onPhase?.('checkout')
await run(path, ['--work-tree', path, 'reset', '--hard', '--no-recurse-submodules', 'HEAD'], undefined, overrides)
return { path, branchRecreated }
} catch (error) {
throw new Error(`Could not restore worktree ${branch} at ${path}. Any created checkout and branch are retained. ${error instanceof Error ? error.message : String(error)}`, { cause: error })
}
}
/** Confine Git commands to the caller's sandbox policy, with hooks and the filesystem monitor disabled. */
private gitRunner(policy: SandboxExecutionPolicy, signal: AbortSignal): GitRun {
return (directory, args, allowedExitCodes, env) => runCommand(
this.ctx, this.config, policy, directory,
[this.config.gitCommand, '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', ...args],
signal, allowedExitCodes, env,
)
}
/** Allocate the checkout directory exclusively inside the pool, creating a self-ignoring pool when it is new. */
private async prepareDirectory(
policy: SandboxExecutionPolicy,
repositoryRoot: string,
poolPath: string,
path: string,
signal: AbortSignal,
): Promise<void> {
await runCommand(this.ctx, this.config, policy, repositoryRoot,
[this.config.nodeCommand, '--input-type=module', '-e', PREPARE_DIRECTORY, poolPath, path], signal)
}
}
/**
* Build the environment that disables every configured clean, smudge, and
* process filter of the checkout for one command, without changing Git config.
* @param run - confined Git runner.
* @param path - checkout directory.
* @returns `GIT_CONFIG_*` overrides.
*/
async function filterOverrides(run: GitRun, path: string): Promise<NodeJS.ProcessEnv> {
const configured = await run(path, ['config', '--null', '--name-only', '--get-regexp', '^filter\\.'], [0, 1])
const drivers = new Set<string>()
for (const key of configured.stdout.split('\0')) {
const separator = key.lastIndexOf('.')
if (separator > 'filter.'.length && ['clean', 'smudge', 'process', 'required'].includes(key.slice(separator + 1))) {
drivers.add(key.slice('filter.'.length, separator))
}
}
const overrides: NodeJS.ProcessEnv = { GIT_CONFIG_COUNT: String(drivers.size * 4) }
let index = 0
for (const driver of drivers) {
for (const attribute of ['clean', 'smudge', 'process', 'required']) {
overrides[`GIT_CONFIG_KEY_${index}`] = `filter.${driver}.${attribute}`
overrides[`GIT_CONFIG_VALUE_${index}`] = attribute === 'required' ? 'false' : ''
index++
}
}
return overrides
}
export default WorktreeService