mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
* fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan <adnank11427@gmail.com> * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * wip(opencode): authorize native startup prompt intent at execution owner * fix(opencode): atomically replace status plugin entrypoints * fix(opencode): retain plugin permissions across restrictive umasks * test(opencode): resolve permission fixture from primary cwd * feat(opencode): install startup prompt plugin independently of status hooks * fix(opencode): wait for admitted startup intent and preserve failed-launch briefs * fix(opencode): unsubscribe hook settings during async host shutdown * STRICT launch CI contract correction * CAPS launch CI contract correction * INTENT launch CI contract correction * test: initialize Claude prompt state in output retention fixture * Wait for OpenCode location hydration in intent startup * Bind OpenCode startup readiness to the current location in intent startup * Collect retired source-scoped OpenCode configuration overlays conservatively Credit brennanb2025 for the original bounded, delayed overlay garbage-collection contribution in PR #7627. Preserve ambiguous legacy and shared-service state. * Correct inaccessible-source fixture without spying on native ESM exports * Keep delayed OpenCode cleanup within existing file limits * Reuse the overlay manifest module for existing owned-entry operations * Use the existing filesystem import in the ownership mock * test(opencode): keep overlay GC link tests portable --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Ahmed Nagy <ahmednagy25t@gmail.com> Co-authored-by: Adnan Khan <adnank11427@gmail.com> Co-authored-by: Orca startup hydration review <agents@stably.ai> Co-authored-by: OpenCode Campaign <opencode-campaign@users.noreply.github.com>
207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
import { lstat, readdir } from 'node:fs/promises'
|
|
import { join, resolve, sep } from 'node:path'
|
|
import { yieldToEventLoop } from '../../shared/event-loop-yield'
|
|
import { isSafeDescendCandidate, safeRemoveOverlay } from '../pty/overlay-mirror'
|
|
import {
|
|
OPENCODE_OVERLAY_MANIFEST_FILE,
|
|
readOpenCodeOverlayManifest
|
|
} from './opencode-overlay-manifest'
|
|
import { inspectSourceDirectory, resolveOwnedOverlaySource } from './overlay-source-ownership'
|
|
|
|
export const OPENCODE_DIR_GC_MIN_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
|
export const OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP = 500
|
|
|
|
export type OpenCodeDirGcOptions = {
|
|
overlayRoot: string
|
|
pluginFileName: string
|
|
referencedConfigDirs: ReadonlySet<string>
|
|
readLivePtyIds: () => Promise<readonly string[] | null>
|
|
now?: number
|
|
minAgeMs?: number
|
|
maxRemovals?: number
|
|
removeTree?: (directory: string, root: string) => void
|
|
yieldBetweenRemovals?: () => Promise<void>
|
|
}
|
|
|
|
export type OpenCodeDirGcResult = {
|
|
scanned: number
|
|
removed: number
|
|
failed: number
|
|
keptReferenced: number
|
|
keptYoung: number
|
|
keptSourcePresent: number
|
|
keptUnverifiable: number
|
|
}
|
|
|
|
function normalized(path: string): string {
|
|
const absolute = resolve(path)
|
|
return process.platform === 'win32' ? absolute.toLowerCase() : absolute
|
|
}
|
|
|
|
function isReferenced(directory: string, references: ReadonlySet<string>): boolean {
|
|
const candidate = normalized(directory)
|
|
for (const reference of references) {
|
|
const value = normalized(reference)
|
|
if (value === candidate || value.startsWith(candidate + sep)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function oldEnough(directory: string, plugin: string, now: number, minAge: number) {
|
|
let newest = 0
|
|
for (const path of [
|
|
directory,
|
|
join(directory, OPENCODE_OVERLAY_MANIFEST_FILE),
|
|
join(directory, 'plugins'),
|
|
join(directory, 'plugins', plugin)
|
|
]) {
|
|
try {
|
|
const stats = await lstat(path)
|
|
if (stats.isSymbolicLink()) {
|
|
return false
|
|
}
|
|
newest = Math.max(newest, stats.mtimeMs)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
return newest > 0 && now - newest >= minAge
|
|
}
|
|
|
|
async function hasNoServiceConfig(directory: string): Promise<boolean> {
|
|
try {
|
|
const names = await readdir(directory)
|
|
// Service registration lives in a different profile; config absence proves no PID verdict.
|
|
return !names.some((name) => /^service(?:-[\w.-]+)?\.json$/i.test(name))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function sweepOrphanedOpenCodeDirs(
|
|
options: OpenCodeDirGcOptions
|
|
): Promise<OpenCodeDirGcResult> {
|
|
const result: OpenCodeDirGcResult = {
|
|
scanned: 0,
|
|
removed: 0,
|
|
failed: 0,
|
|
keptReferenced: 0,
|
|
keptYoung: 0,
|
|
keptSourcePresent: 0,
|
|
keptUnverifiable: 0
|
|
}
|
|
if ((await inspectSourceDirectory(options.overlayRoot)) !== 'present') {
|
|
return result
|
|
}
|
|
const now = options.now ?? Date.now()
|
|
const minAge = options.minAgeMs ?? OPENCODE_DIR_GC_MIN_AGE_MS
|
|
const requestedLimit = options.maxRemovals ?? OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP
|
|
const limit = Number.isFinite(requestedLimit)
|
|
? Math.min(OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP, Math.max(0, Math.floor(requestedLimit)))
|
|
: OPENCODE_DIR_GC_MAX_REMOVALS_PER_SWEEP
|
|
const removeTree = options.removeTree ?? safeRemoveOverlay
|
|
const yieldBetween = options.yieldBetweenRemovals ?? yieldToEventLoop
|
|
let entries
|
|
try {
|
|
entries = await readdir(options.overlayRoot, { withFileTypes: true })
|
|
} catch {
|
|
return result
|
|
}
|
|
for (const entry of entries) {
|
|
if (result.removed + result.failed >= limit) {
|
|
break
|
|
}
|
|
result.scanned += 1
|
|
const directory = join(options.overlayRoot, entry.name)
|
|
if (!/^[0-9a-f]{32}$/.test(entry.name) || !isSafeDescendCandidate(entry)) {
|
|
result.keptUnverifiable += 1
|
|
continue
|
|
}
|
|
if (isReferenced(directory, options.referencedConfigDirs)) {
|
|
result.keptReferenced += 1
|
|
continue
|
|
}
|
|
try {
|
|
const manifestStats = await lstat(join(directory, OPENCODE_OVERLAY_MANIFEST_FILE))
|
|
if (
|
|
!manifestStats.isFile() ||
|
|
manifestStats.isSymbolicLink() ||
|
|
manifestStats.size > 64 * 1024
|
|
) {
|
|
result.keptUnverifiable += 1
|
|
continue
|
|
}
|
|
const source = await resolveOwnedOverlaySource(
|
|
directory,
|
|
readOpenCodeOverlayManifest(directory)
|
|
)
|
|
if (!source) {
|
|
result.keptUnverifiable += 1
|
|
continue
|
|
}
|
|
const presence = await inspectSourceDirectory(source)
|
|
if (presence === 'present') {
|
|
result.keptSourcePresent += 1
|
|
continue
|
|
}
|
|
if (presence !== 'absent' || !(await hasNoServiceConfig(directory))) {
|
|
result.keptUnverifiable += 1
|
|
continue
|
|
}
|
|
if (!(await oldEnough(directory, options.pluginFileName, now, minAge))) {
|
|
result.keptYoung += 1
|
|
continue
|
|
}
|
|
// A restarted app's empty cache cannot establish surviving daemon terminals are gone.
|
|
const liveIds = await options.readLivePtyIds().catch(() => null)
|
|
if (liveIds === null || liveIds.length !== 0) {
|
|
result.keptUnverifiable += 1
|
|
continue
|
|
}
|
|
if (
|
|
isReferenced(directory, options.referencedConfigDirs) ||
|
|
isReferenced(source, options.referencedConfigDirs)
|
|
) {
|
|
result.keptReferenced += 1
|
|
continue
|
|
}
|
|
if (
|
|
(await inspectSourceDirectory(source)) !== 'absent' ||
|
|
(await inspectSourceDirectory(options.overlayRoot)) !== 'present' ||
|
|
!(await hasNoServiceConfig(directory))
|
|
) {
|
|
result.keptUnverifiable += 1
|
|
continue
|
|
}
|
|
if (
|
|
isReferenced(directory, options.referencedConfigDirs) ||
|
|
isReferenced(source, options.referencedConfigDirs)
|
|
) {
|
|
result.keptReferenced += 1
|
|
continue
|
|
}
|
|
try {
|
|
removeTree(directory, options.overlayRoot)
|
|
try {
|
|
await lstat(directory)
|
|
result.failed += 1
|
|
} catch (error) {
|
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
result.removed += 1
|
|
} else {
|
|
result.failed += 1
|
|
}
|
|
}
|
|
} catch {
|
|
result.failed += 1
|
|
}
|
|
await yieldBetween()
|
|
} catch {
|
|
result.keptUnverifiable += 1
|
|
}
|
|
}
|
|
return result
|
|
}
|