Files
deepseek-harness/packages/experimental/worktree/src/archive.ts
T
Yichen Jiang 481641b1b9 feat(worktrees): add the retain-on-archive switch
`retainOnArchive`, a volatile field of the worktree service's Config, skips
cleanup admission while it is on; it is read at each archive, so a change
applies to later archives only. The service keeps its instance off the
generated Settings pages, and the Forge client draws the switch as the
`plugins.row.config` page of the bundle's `worktree` row, staged through
the shared Plugins form until one explicit save.

Docs: the worktree, Forge, and bundle READMEs, the archive-worktrees
upgrade guide, the config and client catalogs, and the
explicit-worktree-creation note.
2026-11-05 16:51:29 +08:00

254 lines
12 KiB
TypeScript

/**
* Archive-triggered reclamation of checkouts this service created. Installed by
* the service constructor, as the job and Agent registries install their own
* archive-admission families, so it holds for every Session the Workspace archives.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import type {} from '@deepseek-ai/dsh-session-query'
import type {} from '@deepseek-ai/dsh-working-directory'
import type {} from '@deepseek-ai/dsh-workspace'
import type { WorktreeProjectionState } from './projection.ts'
import type { CreatedWorktree, WorktreeCleanup, WorktreeCreation } from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A cleanup attempt changed state. Fires once at admission with
* `scheduled` and once at settlement with the final status.
* @param info - the archived Session, the checkout, and the attempt state.
* @mode emit
*/
'worktree/cleanup'(info: { sessionId: SessionId; path: string; cleanup: WorktreeCleanup }): void
}
}
/** The service operations reclamation needs; kept narrow so the archive module never reaches Git directly. */
export interface ReclamationHost {
/** Whether the deployment keeps every checkout on archive; read at each admission. */
retainOnArchive(): boolean
/** Run `body` after every operation on `path` that was requested earlier, and before any requested later. */
exclusiveCheckout<T>(path: string, body: () => Promise<T>): Promise<T>
/** Inspect and, when eligible and admitted, remove the checkout without force. Rejects on Git failure. */
removeCheckout(session: Session, request: CreatedWorktree, signal: AbortSignal, admit: () => boolean): Promise<boolean>
}
interface Attempt {
readonly worktree: CreatedWorktree
readonly revision: number
status: WorktreeCleanup['status']
cancelled: boolean
done: Promise<void>
}
/**
* Whether `path` is `root` or lies below it. Both are canonical absolute
* directories of one execution world without a trailing separator, so string
* prefixing is exact.
*/
function within(root: string, path: string): boolean {
return path === root || path.startsWith(`${root}/`)
}
/** Owns pending cleanup, gates directory entry into a checkout being removed, and drains before the service is released. */
export class WorktreeReclamation {
private readonly attempts = new Map<SessionId, Attempt>()
private readonly removing = new Map<string, Promise<void>>()
private readonly pending = new Set<Promise<unknown>>()
private readonly lifetime = new AbortController()
private revision = 0
/**
* @param ctx - scope injecting the Workspace registry, Session query, store, working-directory, and
* sandbox-policy providers; the projection cache is read when mounted.
* @param host - the service operations the module runs through.
* @param projection - the `worktree` unit, read live and through the persisted cache.
*/
constructor(
private readonly ctx: Context,
private readonly host: ReclamationHost,
private readonly projection: ProjectionHandle<WorktreeProjectionState>,
) {
// Listeners withdraw before the drain so a disposing service never fails
// unrelated directory access or an unarchive with its own abort.
ctx.on('working-directory/access', ({ agent, path, signal }, next) => {
const target = path ?? ctx.workingDirectory.get(agent.session)
const blocked = [...this.removing.keys()].find(root => within(root, target))
if (blocked === undefined) return next()
return this.host.exclusiveCheckout(blocked, async () => {
signal.throwIfAborted()
return await next()
})
})
ctx.on('workspace/session-archived', ({ sessionId }) => { this.schedule(sessionId) })
ctx.on('workspace/session-unarchiving', async ({ sessionId }) => {
const attempt = this.attempts.get(sessionId)
if (attempt === undefined) return
attempt.cancelled = true
await attempt.done
this.attempts.delete(sessionId)
})
ctx.effect(() => async () => {
this.lifetime.abort(new Error('worktree reclamation stopped'))
for (const attempt of this.attempts.values()) attempt.cancelled = true
await Promise.allSettled(this.pending)
this.attempts.clear()
}, 'worktree: drain archive cleanup')
}
/**
* Read the most recent cleanup attempt of a Session; waiting never starts one.
* @param session - archived Session whose attempt is read.
* @param revision - null reads at once; a matching revision waits for that attempt to settle.
* @returns the attempt state, or null when none exists, the revision was superseded, or the service is stopping.
*/
async cleanupOf(session: Session, revision: number | null): Promise<WorktreeCleanup | null> {
const attempt = this.attempts.get(session.id)
if (attempt === undefined || (revision !== null && revision !== attempt.revision)) return null
if (revision !== null) await attempt.done
// A cold Session without creations withdraws its placeholder while it settles.
if (this.lifetime.signal.aborted || this.attempts.get(session.id) !== attempt) return null
return { revision: attempt.revision, status: attempt.status }
}
/** Register attempts for the Session's own creations and return; every inspection runs in the background. */
private schedule(sessionId: SessionId): void {
if (this.host.retainOnArchive()) return
const live = this.ctx.sessions.get(sessionId)
// A cold Session's creations come from the persisted cache or its log, below.
const owned = live === undefined ? undefined : this.projection.stateOf(live).created.filter(creation => creation.owned)
if (owned !== undefined && owned.length === 0) return
const attempt: Attempt = {
worktree: owned?.[0]?.worktree ?? { path: '', branch: '', baseCommit: '', repositoryRoot: '' },
revision: ++this.revision, status: 'scheduled', cancelled: false, done: Promise.resolve(),
}
this.attempts.set(sessionId, attempt)
// run() settles every outcome into the attempt, so the task never rejects.
const task = this.run(sessionId, attempt, owned)
this.pending.add(task)
attempt.done = task.then(() => { this.pending.delete(task) })
}
private async run(sessionId: SessionId, attempt: Attempt, owned: readonly WorktreeCreation[] | undefined): Promise<void> {
const ctx = this.ctx
try {
const creations = owned ?? (await this.creationsOf(sessionId, undefined)).filter(creation => creation.owned)
const candidate = creations[0]?.worktree
if (candidate === undefined) {
this.attempts.delete(sessionId)
return
}
Object.assign(attempt, { worktree: candidate })
ctx.emit('worktree/cleanup', {
sessionId, path: candidate.path, cleanup: { revision: attempt.revision, status: attempt.status },
})
const session = await this.creatorSession(sessionId)
if (session === undefined || attempt.cancelled || ctx.sandboxPolicy.resolve({ session }).mode === 'read-only'
|| !await this.unused(candidate, sessionId)) {
attempt.status = 'retained'
} else {
attempt.status = await this.remove(session, candidate, attempt) ? 'removed' : 'retained'
}
} catch (error: unknown) {
attempt.status = 'failed'
ctx.logger.warn(
'Session %s archived; cleanup of its worktree %s failed and its branch was retained: %s', sessionId, attempt.worktree.path, error,
)
}
ctx.emit('worktree/cleanup', {
sessionId, path: attempt.worktree.path, cleanup: { revision: attempt.revision, status: attempt.status },
})
}
/**
* Remove under the path lock; a second use check runs inside it, after
* competing operations settled. A Session has one attempt and unarchiving
* awaits it, so removals of one path never overlap.
*/
private async remove(session: Session, worktree: CreatedWorktree, attempt: Attempt): Promise<boolean> {
const operation = this.host.exclusiveCheckout(worktree.path, async () => {
if (attempt.cancelled || !await this.unused(worktree, undefined)) return false
return await this.host.removeCheckout(session, worktree, this.lifetime.signal, () => !attempt.cancelled)
})
this.removing.set(worktree.path, operation.then(() => {}, () => {}))
try {
return await operation
} finally {
this.removing.delete(worktree.path)
}
}
/**
* Whether no unarchived Session still refers to the checkout: by a recorded
* creation, inherited or own, or by a working directory inside it. A Session
* beneath an archived subagent ancestor counts as archived, as its own
* archive admission does. Processes are not consulted: Git refuses a
* removal that would discard changes, and an idle process in a removed
* directory fails its next command.
*/
private async unused(worktree: CreatedWorktree, archivingId: SessionId | undefined): Promise<boolean> {
const ctx = this.ctx
const signal = this.lifetime.signal
signal.throwIfAborted()
const records = await ctx.sessionQuery.listSessions(signal)
const headers = new Map(records.map(record => [record.header.id, record.header] as const))
const archived = new Set(ctx.workspaceRegistry.archivedSessionIds)
if (archivingId !== undefined) archived.add(archivingId)
for (const record of records) {
const id = record.header.id
if (archived.has(id) || ancestorArchived(record.header, headers, archived)) continue
const live = ctx.sessions.get(id)
const cwd = live === undefined ? await this.directoryOf(record.header) : ctx.workingDirectory.get(live)
if (cwd !== null && within(worktree.path, cwd)) return false
const creations = live === undefined ? await this.creationsOf(id, record.header) : this.projection.stateOf(live).created
if (creations.some(creation => creation.worktree.path === worktree.path)) return false
signal.throwIfAborted()
}
return true
}
/** The archived Session itself, prepared cold when not live, whose sandbox policy confines the Git commands. */
private async creatorSession(sessionId: SessionId): Promise<Session | undefined> {
const live = this.ctx.sessions.get(sessionId)
if (live !== undefined) return live
return await this.ctx.sessionQuery.inspectSession(sessionId, session => session, this.lifetime.signal)
}
/** The recorded directory of a cold Session, null when its log recorded none: the persisted cache when it has one, else its log. */
private async directoryOf(header: SessionHeader): Promise<string | null> {
const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header, ['workingDirectory'])?.values.workingDirectory
if (cached !== undefined) return cached
return await this.ctx.sessionQuery.inspectSession(header.id, session => this.ctx.workingDirectory.get(session), this.lifetime.signal)
}
/** The recorded creations of a cold Session: the persisted cache when it has one, else its log. */
private async creationsOf(
sessionId: SessionId, header: SessionHeader | undefined,
): Promise<readonly WorktreeCreation[]> {
const cached = header === undefined
? undefined
: this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header, ['worktree'])?.values.worktree
if (cached !== undefined) return cached
return await this.ctx.sessionQuery.inspectSession(sessionId, session => this.projection.stateOf(session).created, this.lifetime.signal)
}
}
/** Whether any subagent ancestor of `header`, followed through the listed headers, is archived. */
function ancestorArchived(
header: SessionHeader, headers: ReadonlyMap<SessionId, SessionHeader>, archived: ReadonlySet<SessionId>,
): boolean {
const visited = new Set<SessionId>([header.id])
let current = header
while (current.origin === 'subagent' && current.parentSession !== undefined && !visited.has(current.parentSession)) {
if (archived.has(current.parentSession)) return true
visited.add(current.parentSession)
const parent = headers.get(current.parentSession)
if (parent === undefined) return false
current = parent
}
return false
}