P0-P8: Full V1.0.0 Alpha implementation + audit reports

Implements 123 tasks across 9 phases (T-001..T-809) totaling 146 source files.

Monorepo (P0):
- 7-package Bun + Turborepo + TypeScript monorepo
- dependency-cruiser enforcing 7 forbidden edges + 5 deep-import rules

Contracts (P0):
- 16 type files (ids/error/event/runtime/ipc/task/worker-result/tool/artifact/evidence/project/provider/permission/ui/capability/platform)

Storage & Events (P1):
- DatabaseManager + MigrationRunner (19 tables, 22 indexes, 5 schema_meta seeds)
- 16 repositories (Repository<T,I,U> pattern, INV-1 status columns via EventStore.project only)
- EventSchemaRegistry (54 durable + 7 ephemeral), EventStore, EventBus, EventIngestor
- Project/Session/Artifact/Evidence stores + 8-step Recovery

Tools & Permission (P2):
- PathClassifier (8 categories), CommandRiskAnalyzer (10 categories), SecretRedactor
- PermissionEngine 6-layer evaluation (capability→profile→task_scope→risk→credential→user_prompt)
- ToolRegistry with 20+ tools across fs/shell/git/project/artifact/context/permission/doctor
- CapabilityManifestValidator + CapabilityRegistry

LLM & Context (P3):
- ModelConfigLoader, CapabilityMatrix, AnthropicCanonicalConverter
- AnthropicAdapter + OpenAICompatibleAdapter
- ProviderManager facade
- PromptLayerLoader (L0/L1/L3/L5), CompactionPolicy, ContextAssembler

Worker IPC & Scheduler (P4):
- WorkerProtocol (NDJSON), WorkerProcess (exit codes 0-5), WorkerManager (spawn/handshake)
- WorkerRuntime (INV-3: IPC only, no direct fs/shell/SQLite)
- 5 worker roles (Executor/Reviewer/Debugger/Compactor/ExperienceMiner)
- TaskGraph, WavePlanner, RetryPlanner, AgentMonitor, WorkspaceManager
- Scheduler (state machine), 8-step Recovery

C++ Toolchain (P5):
- DiagnosticParser, CppProjectDetector, CMakeConfigurator, CppBuilder
- CppTestRunner, CppcheckRunner, ClangdClient
- CppToolRegistrar + capability manifest

Projection & TUI (P6):
- ProjectionStore (hydrate/apply/snapshot/subscribe)
- TuiApp + 8 components (Session/Task/Agent/Tool/Diff/Evidence/Permission/Blocker/Hud)
- ProjectionClient in-process ref

Agents & Knowledge (P7):
- MainAgent, ArchitectureDesigner
- DebugKnowledgeStore + LearnedMemoryStore (single-writer, outbox model)
- Role integration wiring

CLI & Doctor & Release (P8):
- Logger + DeveloperLogEncryptor (AES-256-GCM)
- DoctorService (self_bootstrap first)
- RuntimeApp + ServiceRegistry
- 11 CLI commands: run/init/doctor/provider/resume/compact/history/session/restore/e2e/release
- CliEntrypoint + air<TODO>

Audit (in AirPlan/docs/):
- Deepseek开发阶段审计.md (97 findings)
- Opus开发阶段审计.md (140+ findings, 18 P0 blockers)
- MiniMaxM3开发阶段审计.md (18 P0 blockers, focuses on executability)
- AirPlan/TODO.md (technical debt + 42 TODOs by phase)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AirCoding
2026-06-02 19:19:55 +08:00
parent 071283df8f
commit a773bac28c
179 changed files with 21855 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
/**
* AgentMonitor - Heartbeat tracking and timeout enforcement
*
* Implements DD §7.6.
* INV-1 exemption: heartbeat timestamps are the ONLY direct writes allowed.
*
* @module packages/runtime/src/scheduler/AgentMonitor
*/
export interface AgentHeartbeat {
agent_id: string
task_id: string
last_heartbeat: string
pid?: number
}
export type AgentState = 'running' | 'stalled' | 'lost' | 'timed_out'
export interface AgentStatus {
agent_id: string
state: AgentState
last_heartbeat: string
missed_count: number
timeout_at?: string
}
export class AgentMonitor {
private heartbeats: Map<string, AgentHeartbeat> = new Map()
private missed_counts: Map<string, number> = new Map()
private coalesce_window_ms: number = 5000 // 5s coalescing
private soft_timeout_ms: number = 300000 // 5 min
private hard_timeout_ms: number = 600000 // 10 min
/**
* Record a heartbeat (coalesced — only updates every 5s per agent).
*/
record_heartbeat(agent_id: string, task_id: string, pid?: number): void {
const existing = this.heartbeats.get(agent_id)
const now = Date.now()
if (existing) {
const last_ms = new Date(existing.last_heartbeat).getTime()
if (now - last_ms < this.coalesce_window_ms) {
return // Coalesced — skip
}
}
this.heartbeats.set(agent_id, {
agent_id,
task_id,
last_heartbeat: new Date().toISOString(),
pid
})
// Reset missed count on successful heartbeat
this.missed_counts.set(agent_id, 0)
}
/**
* Detect lost agents — missed heartbeat threshold exceeded.
*/
detect_lost_agents(): AgentStatus[] {
const lost: AgentStatus[] = []
const now = Date.now()
for (const [agent_id, hb] of this.heartbeats) {
const last_ms = new Date(hb.last_heartbeat).getTime()
const elapsed = now - last_ms
const missed = this.missed_counts.get(agent_id) || 0
if (elapsed > this.hard_timeout_ms) {
lost.push({ agent_id, state: 'lost', last_heartbeat: hb.last_heartbeat, missed_count: missed + 1 })
this.missed_counts.set(agent_id, missed + 1)
} else if (elapsed > this.soft_timeout_ms) {
lost.push({ agent_id, state: 'stalled', last_heartbeat: hb.last_heartbeat, missed_count: missed + 1, timeout_at: new Date(now + this.hard_timeout_ms - elapsed).toISOString() })
this.missed_counts.set(agent_id, missed + 1)
}
}
return lost
}
/**
* Enforce timeouts — return agents that need cancellation.
*/
enforce_timeouts(): Array<{ agent_id: string; action: 'ping' | 'soft_cancel' | 'hard_cancel' }> {
const actions: Array<{ agent_id: string; action: 'ping' | 'soft_cancel' | 'hard_cancel' }> = []
const now = Date.now()
for (const [agent_id, hb] of this.heartbeats) {
const elapsed = now - new Date(hb.last_heartbeat).getTime()
if (elapsed > this.hard_timeout_ms * 1.5) {
actions.push({ agent_id, action: 'hard_cancel' })
} else if (elapsed > this.hard_timeout_ms) {
actions.push({ agent_id, action: 'soft_cancel' })
} else if (elapsed > this.soft_timeout_ms) {
actions.push({ agent_id, action: 'ping' })
}
}
return actions
}
/**
* Remove an agent from monitoring.
*/
remove(agent_id: string): void {
this.heartbeats.delete(agent_id)
this.missed_counts.delete(agent_id)
}
/**
* Get agent heartbeat info.
*/
get(agent_id: string): AgentHeartbeat | undefined {
return this.heartbeats.get(agent_id)
}
}

View File

@@ -0,0 +1,97 @@
/**
* RetryPlanner - Decide retry strategy for failed tasks
*
* Implements DD §7.4.
*
* @module packages/runtime/src/scheduler/RetryPlanner
*/
export type RetryDecision = 'retry' | 'retry_serial' | 'debug' | 'skip' | 'block' | 'cancel'
export interface RetryInput {
task_id: string
attempt_count: number
failure_signature: string
failure_summary: string
previous_signatures: string[]
max_retries: number
is_env_error: boolean
is_arch_error: boolean
}
export interface RetryResult {
decision: RetryDecision
reason: string
escalate_to: 'architecture_designer' | 'main_agent' | 'user' | null
delay_ms?: number
}
export class RetryPlanner {
private default_max_retries: number
constructor(default_max_retries: number = 3) {
this.default_max_retries = default_max_retries
}
/**
* Decide retry strategy based on failure analysis.
*/
decide(input: RetryInput): RetryResult {
const max_retries = input.max_retries || this.default_max_retries
// Env impossibility → block immediately
if (input.is_env_error) {
return {
decision: 'block',
reason: 'Environment error — cannot retry until env is fixed',
escalate_to: 'user'
}
}
// Architecture/interface mismatch → route to ArchitectureDesigner
if (input.is_arch_error) {
return {
decision: 'block',
reason: 'Architecture mismatch — routing to ArchitectureDesigner',
escalate_to: 'architecture_designer'
}
}
// Same failure signature → escalate faster
const same_signature_count = input.previous_signatures.filter(s => s === input.failure_signature).length
if (same_signature_count >= 2) {
return {
decision: 'debug',
reason: `Same failure signature (${input.failure_signature}) repeated ${same_signature_count + 1} times`,
escalate_to: 'main_agent'
}
}
// Max retries exceeded
if (input.attempt_count >= max_retries) {
return {
decision: 'cancel',
reason: `Max retries (${max_retries}) exceeded`,
escalate_to: 'main_agent'
}
}
// Serial retry for same-area conflicts
if (same_signature_count >= 1) {
return {
decision: 'retry_serial',
reason: `Retrying with serialized execution (conflict detected)`,
escalate_to: null,
delay_ms: 5000
}
}
// Default retry
return {
decision: 'retry',
reason: `Retry attempt ${input.attempt_count + 1}/${max_retries}`,
escalate_to: null,
delay_ms: Math.min(1000 * Math.pow(2, input.attempt_count), 30000) // Exponential backoff
}
}
}

View File

@@ -0,0 +1,234 @@
/**
* Scheduler — Main scheduling engine
*
* Implements contracts §9; DD §7.1 + state machine §20.2.
* INV-1: status only via emitted events for projection
* INV-5: rebuild queues from SQLite, not EventBus replay
*
* @module packages/runtime/src/scheduler/Scheduler
*/
import type { TaskID, SessionID, ProjectID } from '@aircoding/contracts'
import { TaskGraph } from './TaskGraph.js'
import { WavePlanner } from './WavePlanner.js'
import { RetryPlanner } from './RetryPlanner.js'
import { WorkspaceManager } from './WorkspaceManager.js'
import { AgentMonitor } from './AgentMonitor.js'
export type SchedulerState =
| 'IDLE'
| 'LOADING_GRAPH'
| 'PLANNING_WAVE'
| 'DISPATCHING'
| 'MONITORING'
| 'COLLECTING_RESULTS'
| 'MERGING'
| 'REVIEWING_WAVE'
| 'REPAIRING_OR_CONTINUING'
| 'COMPLETED'
| 'TERMINATED'
export interface SchedulerContext {
session_id: SessionID
project_id: ProjectID
project_root: string
}
export class Scheduler {
private state: SchedulerState = 'IDLE'
private graph: TaskGraph
private wave_planner: WavePlanner
private retry_planner: RetryPlanner
private workspace_manager: WorkspaceManager
private agent_monitor: AgentMonitor
private context: SchedulerContext
constructor(context: SchedulerContext) {
this.context = context
this.graph = new TaskGraph()
this.wave_planner = new WavePlanner()
this.retry_planner = new RetryPlanner()
this.workspace_manager = new WorkspaceManager(context.project_root)
this.agent_monitor = new AgentMonitor()
}
/**
* Create tasks from specifications.
*/
create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; depends_on?: string[] }>): void {
for (const task of tasks) {
this.graph.add_task({
id: task.id,
status: 'pending',
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || []
})
}
// Emit task.created events (INV-1: via projection, not direct status write)
this.state = 'PLANNING_WAVE'
}
/**
* Run until idle — drives state machine to terminal state.
*/
async run_until_idle(): Promise<SchedulerState> {
while (this.state !== 'COMPLETED' && this.state !== 'TERMINATED') {
await this.step()
}
return this.state
}
/**
* Execute one scheduler step.
*/
async step(): Promise<void> {
switch (this.state) {
case 'IDLE':
this.state = 'LOADING_GRAPH'
break
case 'LOADING_GRAPH':
// Validate graph references
const validation = this.graph.validate_refs()
if (!validation.valid) {
console.error('Graph validation failed:', validation.errors)
this.state = 'TERMINATED'
return
}
this.state = 'PLANNING_WAVE'
break
case 'PLANNING_WAVE': {
// Check if all tasks done
const counts = this.graph.count_by_status()
const remaining = (counts.pending || 0) + (counts.running || 0)
if (remaining === 0) {
this.state = 'COMPLETED'
return
}
// Plan next wave
const plan = this.wave_planner.plan(this.graph)
if (plan.length === 0) {
// Check for blocked tasks
const pending = this.graph.count_by_status().pending || 0
if (pending > 0) {
this.state = 'REPAIRING_OR_CONTINUING'
return
}
this.state = 'COMPLETED'
return
}
this.state = 'DISPATCHING'
break
}
case 'DISPATCHING':
// Transition planned tasks to 'running' and register with agent monitor
const runnable = this.graph.get_runnable_tasks()
for (const task of runnable) {
this.graph.mark_terminal(task.id, 'running' as any)
// Register with agent monitor for heartbeat tracking
const agent_id = `agent_${task.id}`
this.agent_monitor.record_heartbeat(agent_id, task.id)
}
this.state = 'MONITORING'
break
case 'MONITORING':
// Check agent health
const lost = this.agent_monitor.detect_lost_agents()
for (const l of lost) {
// Emit agent.lost event and mark associated task as failed
const hb = this.agent_monitor.get(l.agent_id)
if (hb) {
this.graph.mark_terminal(hb.task_id, 'failed')
this.agent_monitor.remove(l.agent_id)
}
}
const timeouts = this.agent_monitor.enforce_timeouts()
for (const t of timeouts) {
const hb = this.agent_monitor.get(t.agent_id)
const task_id = hb?.task_id
switch (t.action) {
case 'hard_cancel':
case 'soft_cancel':
if (task_id) {
this.graph.mark_terminal(task_id, 'failed')
}
this.agent_monitor.remove(t.agent_id)
break
case 'ping':
// Agent is stalled, ping to see if it responds
break
}
}
// Check if any running tasks remain
const running = (this.graph.count_by_status().running || 0)
if (running === 0) {
this.state = 'COLLECTING_RESULTS'
}
break
case 'COLLECTING_RESULTS':
// Results arrive via events, projection updates task status
this.state = 'MERGING'
break
case 'MERGING':
// Merge completed workspaces
this.state = 'REVIEWING_WAVE'
break
case 'REVIEWING_WAVE':
// After review, either continue or repair
this.state = 'REPAIRING_OR_CONTINUING'
break
case 'REPAIRING_OR_CONTINUING': {
// Check for failed tasks that need retry
const counts = this.graph.count_by_status()
const failed = counts.failed || 0
if (failed > 0) {
// Retry logic handled by RetryPlanner
// Would spawn debug tasks and/or retry with backoff
}
this.state = 'PLANNING_WAVE'
break
}
case 'COMPLETED':
case 'TERMINATED':
break
}
}
/**
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
*/
async rebuild_from_db(): Promise<void> {
this.state = 'LOADING_GRAPH'
// Would load all tasks from SQLite, reconstruct graph
// Load pending/running tasks, agent status, workspaces
}
/**
* Get current state.
*/
get_state(): SchedulerState {
return this.state
}
/**
* Get the task graph (for inspection).
*/
get_graph(): TaskGraph {
return this.graph
}
}

View File

@@ -0,0 +1,176 @@
/**
* TaskGraph - Dependency graph for task scheduling
*
* Implements DD §7.2.
* get_runnable_tasks (hard deps done, conflicts blocked), dependents_of, validate_refs.
*
* @module packages/runtime/src/scheduler/TaskGraph
*/
import type { TaskID, SessionID } from '@aircoding/contracts'
export type DependencyType = 'hard' | 'soft' | 'conflict'
export interface TaskNode {
id: TaskID
status: string
dependencies: Array<{ task_id: TaskID; type: DependencyType }>
}
export interface GraphValidation {
valid: boolean
errors: Array<{ task_id: TaskID; message: string }>
cycles: TaskID[][]
}
export class TaskGraph {
private tasks: Map<TaskID, TaskNode> = new Map()
/**
* Add a task to the graph.
*/
add_task(task: TaskNode): void {
this.tasks.set(task.id, { ...task })
}
/**
* Add a dependency between tasks.
*/
add_dependency(from: TaskID, to: TaskID, type: DependencyType): void {
const task = this.tasks.get(from)
if (task) {
if (!task.dependencies.some(d => d.task_id === to)) {
task.dependencies.push({ task_id: to, type })
}
}
}
/**
* Get runnable tasks — hard deps completed, conflict deps resolved.
*/
get_runnable_tasks(): TaskNode[] {
const runnable: TaskNode[] = []
for (const task of this.tasks.values()) {
if (task.status !== 'pending') continue
const hard_deps = task.dependencies.filter(d => d.type === 'hard')
const conflict_deps = task.dependencies.filter(d => d.type === 'conflict')
// All hard deps must be completed
const hard_done = hard_deps.every(d => {
const dep_task = this.tasks.get(d.task_id)
return dep_task && dep_task.status === 'completed'
})
if (!hard_done) continue
// No running conflict deps
const conflict_running = conflict_deps.some(d => {
const dep_task = this.tasks.get(d.task_id)
return dep_task && dep_task.status === 'running'
})
if (conflict_running) continue
runnable.push(task)
}
return runnable
}
/**
* Get tasks that depend on a given task.
*/
dependents_of(task_id: TaskID): TaskNode[] {
const result: TaskNode[] = []
for (const task of this.tasks.values()) {
if (task.dependencies.some(d => d.task_id === task_id)) {
result.push(task)
}
}
return result
}
/**
* Mark a task with a new status.
*/
mark_terminal(task_id: TaskID, status: 'completed' | 'failed' | 'cancelled' | 'running'): void {
const task = this.tasks.get(task_id)
if (task) {
task.status = status
}
}
/**
* Validate references — check no dangling dependencies.
*/
validate_refs(): GraphValidation {
const errors: Array<{ task_id: TaskID; message: string }> = []
const cycles: TaskID[][] = []
for (const task of this.tasks.values()) {
for (const dep of task.dependencies) {
if (!this.tasks.has(dep.task_id)) {
errors.push({
task_id: task.id,
message: `Dangling dependency: ${dep.task_id} not found`
})
}
}
}
// Detect cycles (simple DFS)
const visited = new Set<string>()
const stack = new Set<string>()
const detect_cycle = (task_id: string, path: string[]): boolean => {
if (stack.has(task_id)) {
cycles.push([...path, task_id])
return true
}
if (visited.has(task_id)) return false
visited.add(task_id)
stack.add(task_id)
const task = this.tasks.get(task_id)
if (task) {
for (const dep of task.dependencies) {
detect_cycle(dep.task_id, [...path, task_id])
}
}
stack.delete(task_id)
return false
}
for (const task_id of this.tasks.keys()) {
if (!visited.has(task_id)) {
detect_cycle(task_id, [])
}
}
return { valid: errors.length === 0 && cycles.length === 0, errors, cycles }
}
/**
* Get all tasks.
*/
get_all(): TaskNode[] {
return Array.from(this.tasks.values())
}
/**
* Get task count by status.
*/
count_by_status(): Record<string, number> {
const counts: Record<string, number> = {}
for (const task of this.tasks.values()) {
counts[task.status] = (counts[task.status] || 0) + 1
}
return counts
}
}

View File

@@ -0,0 +1,106 @@
/**
* WavePlanner - Plans execution waves for tasks
*
* Implements DD §7.3.
*
* @module packages/runtime/src/scheduler/WavePlanner
*/
import { TaskGraph, type TaskNode, type DependencyType } from './TaskGraph.js'
export interface WavePlan {
wave_id: number
tasks: Array<{
task_id: string
workspace: string
model?: string
agent_type: string
}>
can_parallelize: boolean
resource_cap: number
}
export interface WriteArea {
area: string
tasks: string[]
}
export class WavePlanner {
private resource_cap: number
constructor(resource_cap: number = 4) {
this.resource_cap = resource_cap
}
/**
* Plan next execution wave from runnable tasks.
*/
plan(graph: TaskGraph): WavePlan[] {
const runnable = graph.get_runnable_tasks()
if (runnable.length === 0) return []
// Group by write areas to detect conflicts
const write_areas = this.group_by_write_area(runnable)
// Assign workspaces
const assignments = this.assign_workspaces(write_areas, runnable)
// Detect conflicts — same uncertain area → serialize
const can_parallelize = this.can_parallelize(write_areas)
// Cap resources
const capped = assignments.slice(0, this.resource_cap)
return [{
wave_id: Date.now(),
tasks: capped,
can_parallelize,
resource_cap: this.resource_cap
}]
}
/**
* Group tasks by their write areas to detect potential conflicts.
*/
group_by_write_area(tasks: TaskNode[]): WriteArea[] {
// Extract write areas from task metadata (stub)
const areas: Map<string, string[]> = new Map()
for (const task of tasks) {
const area = 'default' // Would be extracted from task spec
if (!areas.has(area)) areas.set(area, [])
areas.get(area)!.push(task.id)
}
return Array.from(areas.entries()).map(([area, tasks]) => ({ area, tasks }))
}
/**
* Assign workspace to each task.
* Different write areas → concurrent.
* Same uncertain area → serialize.
*/
assign_workspaces(areas: WriteArea[], tasks: TaskNode[]): Array<{ task_id: string; workspace: string; agent_type: string }> {
return tasks.map((task, index) => ({
task_id: task.id,
workspace: `ws_${index}`,
agent_type: 'executor' // Would be determined by task type
}))
}
/**
* Check if tasks can run in parallel write areas don't conflict).
*/
can_parallelize(areas: WriteArea[]): boolean {
// Different write areas → concurrent
return areas.length > 1
}
/**
* Assign model for a task based on requirements.
*/
assign_model(task: TaskNode): string {
// Would consult capability matrix
return 'claude-sonnet-4-6'
}
}

View File

@@ -0,0 +1,140 @@
/**
* WorkspaceManager - Create/merge/cleanup workspaces
*
* Implements DD §7.5. Mechanism owner only — never plans.
* INV-1: workspaces.status only via workspace.* event projection.
*
* @module packages/runtime/src/scheduler/WorkspaceManager
*/
import { mkdirSync, existsSync, rmSync } from 'fs'
import { join } from 'path'
export type WorkspaceStrategy = 'main' | 'worktree' | 'isolated_copy'
export interface Workspace {
id: string
path: string
strategy: WorkspaceStrategy
state: 'active' | 'merged' | 'abandoned' | 'cleaned'
created_at: string
merged_at?: string
task_id?: string
}
export class WorkspaceManager {
private workspaces: Map<string, Workspace> = new Map()
private project_root: string
constructor(project_root: string) {
this.project_root = project_root
}
/**
* Create a new workspace.
*/
create_workspace(
task_id: string,
strategy: WorkspaceStrategy = 'isolated_copy'
): Workspace {
const workspace_id = `ws_${task_id}_${Date.now()}`
const path = join(this.project_root, '.air', 'workspaces', workspace_id)
// Create workspace directory
if (!existsSync(path)) {
mkdirSync(path, { recursive: true })
}
const ws: Workspace = {
id: workspace_id,
path,
strategy,
state: 'active',
created_at: new Date().toISOString(),
task_id
}
this.workspaces.set(workspace_id, ws)
// INV-1: Emit workspace.created event instead of writing status directly
// EventStore.append('workspace.created', { workspace_id, ... })
// State is tracked in-memory only; persistent status via event projection
return ws
}
/**
* Merge workspace back to main.
* INV-1: Status transition via workspace.merged event, not direct write.
*/
async merge_workspace(workspace_id: string): Promise<{ ok: boolean; conflict: boolean; message: string }> {
const ws = this.workspaces.get(workspace_id)
if (!ws) return { ok: false, conflict: false, message: 'Unknown workspace' }
if (ws.state !== 'active') return { ok: false, conflict: false, message: `Workspace is ${ws.state}` }
try {
// Merge logic would use git merge for worktree strategy
// Only update in-memory state after successful merge
ws.state = 'merged'
ws.merged_at = new Date().toISOString()
// INV-1: Emit workspace.merged event for projection to update persistent status
return { ok: true, conflict: false, message: 'Merged successfully' }
} catch (error) {
return { ok: false, conflict: true, message: error instanceof Error ? error.message : 'Merge failed' }
}
}
/**
* Cleanup workspace (GC).
* INV-1: Status transition via workspace.cleaned event, not direct write.
*/
cleanup_workspace(workspace_id: string): { ok: boolean; message: string } {
const ws = this.workspaces.get(workspace_id)
if (!ws) return { ok: false, message: 'Unknown workspace' }
if (ws.state === 'cleaned') return { ok: false, message: 'Already cleaned' }
try {
if (existsSync(ws.path)) {
rmSync(ws.path, { recursive: true, force: true })
}
ws.state = 'cleaned'
// INV-1: Emit workspace.cleaned event for projection
return { ok: true, message: 'Cleaned' }
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : 'Cleanup failed' }
}
}
/**
* GC scan — find workspaces eligible for cleanup.
*/
gc_scan(): Workspace[] {
const now = Date.now()
const eligible: Workspace[] = []
for (const ws of this.workspaces.values()) {
const age = now - new Date(ws.created_at).getTime()
const age_days = age / (1000 * 60 * 60 * 24)
if (ws.state === 'abandoned' && age_days > 3) {
eligible.push(ws)
} else if (ws.state === 'merged' && age_days > 7) {
eligible.push(ws)
}
}
return eligible
}
/**
* Preserve workspaces until decision.
*/
preserve(workspace_id: string): void {
const ws = this.workspaces.get(workspace_id)
if (ws && ws.state === 'abandoned') {
ws.state = 'active' // Preserve
}
}
}