chore: push all design docs, V2 plan specs, and current working state
Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,22 @@ import { Database } from 'bun:sqlite'
|
||||
* 3. Mark agent.lost
|
||||
* 4. Preserve workspaces
|
||||
* 5. Orphan artifact scan → register or quarantine
|
||||
* 6. FK-off scan (8 invariants, DD §18.3)
|
||||
* 6. FK-off scan (8 invariants, DD §18.3) — performs REAL reparent/archive
|
||||
* 7. Workspace GC
|
||||
* 8. Rebuild queue
|
||||
*
|
||||
* INV-5: rebuild from SQLite, not EventBus replay.
|
||||
*
|
||||
* Round5 Wf-A rework A.6/A.7:
|
||||
* - fkChecks now matches DD §18.3 exactly: cross-table constraints across
|
||||
* tasks / task_attempts / agents / tool_runs / command_runs / workspaces /
|
||||
* diagnostics / evidence_refs — not the pre-rework blanket sessions parent.
|
||||
* - Violations trigger actual UPDATE/DELETE SQL: reparent by pointing the
|
||||
* orphan FK at the most recent valid row of the parent table (DD §18.3
|
||||
* "re-parents or archives"); if no valid parent exists the row is archived
|
||||
* (UPDATE status to the appropriate terminal value, falling back to DELETE
|
||||
* for tables that lack a status column).
|
||||
*
|
||||
* @module packages/runtime/src/storage/Recovery
|
||||
*/
|
||||
|
||||
@@ -28,6 +38,8 @@ export interface RecoveryOptions {
|
||||
artifactRoot: string
|
||||
dbPath: string
|
||||
projectRoot: string
|
||||
/** Optional existing Database instance to reuse (avoids Bun SQLite file locking issues) */
|
||||
db?: Database
|
||||
}
|
||||
|
||||
export interface OrphanArtifactReport {
|
||||
@@ -58,6 +70,35 @@ export interface RecoveryReport {
|
||||
completedAt: ISOTimeString
|
||||
}
|
||||
|
||||
/**
|
||||
* Single FK-off invariant per DD §18.3.
|
||||
*
|
||||
* `nullable` means the FK column permits NULL: when non-null the value must
|
||||
* reference a real row in `parent_table`; when null the row is never orphan.
|
||||
*
|
||||
* `action: 'reparent'` — point the orphan FK at the most recent valid row
|
||||
* of the parent table. Used when a same-table
|
||||
* replacement keeps the row meaningful (e.g. an
|
||||
* orphan agent still belongs to a real task in
|
||||
* the same session).
|
||||
* `action: 'archive'` — flag the orphan row as terminal (UPDATE status)
|
||||
* or DELETE if the table has no status column.
|
||||
*
|
||||
* `archive_status` is the terminal status value written for archive actions;
|
||||
* it must match db-schema-v1.md for the given table. `null` triggers DELETE.
|
||||
*/
|
||||
interface FkInvariant {
|
||||
table: string
|
||||
fk_column: string
|
||||
parent_table: string
|
||||
nullable: boolean
|
||||
action: 'reparent' | 'archive'
|
||||
archive_status?: string | null
|
||||
// SQL fragment to SELECT a fallback parent id; defaults to any row id
|
||||
// from `parent_table`. Subclasses / future tables can override.
|
||||
fallback_select?: string
|
||||
}
|
||||
|
||||
export class Recovery {
|
||||
private artifactRoot: string
|
||||
private _dbPath: string
|
||||
@@ -70,15 +111,20 @@ export class Recovery {
|
||||
this._dbPath = options.dbPath
|
||||
this.projectRoot = options.projectRoot
|
||||
this.quarantineDir = join(this.artifactRoot, 'tmp', 'orphans')
|
||||
this.open_db()
|
||||
// Use provided DB instance if given, otherwise try to open
|
||||
if (options.db) {
|
||||
this.db = options.db
|
||||
} else {
|
||||
this.open_db()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the session database for FK-off scan.
|
||||
* Open the session database for FK-off scan (read-write so we can repair).
|
||||
*/
|
||||
private open_db(): void {
|
||||
try {
|
||||
this.db = new Database(this._dbPath, { readonly: true })
|
||||
this.db = new Database(this._dbPath, { readonly: false })
|
||||
} catch {
|
||||
this.db = null
|
||||
}
|
||||
@@ -151,7 +197,29 @@ export class Recovery {
|
||||
|
||||
/**
|
||||
* FK-off scan — checks 8 invariants per DD §18.3.
|
||||
* Returns an OrphanReferenceReport with reparented/archived references.
|
||||
* Performs real reparent/archive (UPDATE / DELETE), not just report.
|
||||
*
|
||||
* The 8 invariants per DD §18.3 l.1325-1332:
|
||||
* 1. tasks.session_id → sessions.id
|
||||
* 2. task_attempts.task_id → tasks.id
|
||||
* 3. agents.task_id → tasks.id (nullable)
|
||||
* 4. tool_runs.task_id → tasks.id (nullable)
|
||||
* tool_runs.agent_id → agents.id (nullable)
|
||||
* 5. command_runs.task_id → tasks.id (nullable)
|
||||
* command_runs.agent_id → agents.id (nullable)
|
||||
* command_runs.tool_run_id → tool_runs.id (nullable)
|
||||
* 6. workspaces.task_id → tasks.id (nullable)
|
||||
* workspaces.agent_id → agents.id (nullable)
|
||||
* 7. diagnostics.command_run_id → command_runs.id (nullable)
|
||||
* diagnostics.artifact_id → artifacts.id (nullable)
|
||||
* 8. evidence_refs foreign columns (nullable):
|
||||
* task_id, agent_id, tool_run_id, command_run_id,
|
||||
* artifact_id, diagnostic_id, message_id
|
||||
* → corresponding parent tables
|
||||
*
|
||||
* Each invariant is modeled as an FkInvariant. Nullable columns are
|
||||
* skipped entirely when null (they cannot orphan). Non-null orphan values
|
||||
* are either reparented to the most-recent valid parent row, or archived.
|
||||
*/
|
||||
private async scanOrphanReferences(): Promise<OrphanReferenceReport> {
|
||||
const report: OrphanReferenceReport = {
|
||||
@@ -161,46 +229,215 @@ export class Recovery {
|
||||
errors: [],
|
||||
}
|
||||
|
||||
// 8 FK-off invariant checks (DD §18.3):
|
||||
const fkChecks = [
|
||||
{ table: 'tasks', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'messages', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'task_attempts', fk_column: 'task_id', parent_table: 'tasks' },
|
||||
{ table: 'agents', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'tool_runs', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'command_runs', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'artifacts', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
{ table: 'evidence_refs', fk_column: 'session_id', parent_table: 'sessions' },
|
||||
const fkChecks: FkInvariant[] = [
|
||||
// 1. tasks.session_id → sessions.id (NOT NULL per db-schema §7)
|
||||
{
|
||||
table: 'tasks', fk_column: 'session_id', parent_table: 'sessions',
|
||||
nullable: false, action: 'archive', archive_status: 'cancelled',
|
||||
},
|
||||
// 2. task_attempts.task_id → tasks.id (NOT NULL per db-schema §9)
|
||||
{
|
||||
table: 'task_attempts', fk_column: 'task_id', parent_table: 'tasks',
|
||||
nullable: false, action: 'archive', archive_status: 'cancelled',
|
||||
},
|
||||
// 3. agents.task_id → tasks.id (nullable)
|
||||
{
|
||||
table: 'agents', fk_column: 'task_id', parent_table: 'tasks',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
// 4. tool_runs.task_id / agent_id
|
||||
{
|
||||
table: 'tool_runs', fk_column: 'task_id', parent_table: 'tasks',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'tool_runs', fk_column: 'agent_id', parent_table: 'agents',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
// 5. command_runs.task_id / agent_id / tool_run_id
|
||||
{
|
||||
table: 'command_runs', fk_column: 'task_id', parent_table: 'tasks',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'command_runs', fk_column: 'agent_id', parent_table: 'agents',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'command_runs', fk_column: 'tool_run_id', parent_table: 'tool_runs',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
// 6. workspaces.task_id / agent_id
|
||||
{
|
||||
table: 'workspaces', fk_column: 'task_id', parent_table: 'tasks',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'workspaces', fk_column: 'agent_id', parent_table: 'agents',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
// 7. diagnostics.command_run_id / artifact_id
|
||||
{
|
||||
table: 'diagnostics', fk_column: 'command_run_id', parent_table: 'command_runs',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'diagnostics', fk_column: 'artifact_id', parent_table: 'artifacts',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
// 8. evidence_refs foreign columns (all nullable)
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'task_id', parent_table: 'tasks',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'agent_id', parent_table: 'agents',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'tool_run_id', parent_table: 'tool_runs',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'command_run_id', parent_table: 'command_runs',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'artifact_id', parent_table: 'artifacts',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'diagnostic_id', parent_table: 'diagnostics',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
{
|
||||
table: 'evidence_refs', fk_column: 'message_id', parent_table: 'messages',
|
||||
nullable: true, action: 'reparent',
|
||||
},
|
||||
]
|
||||
|
||||
if (!this.db) return report
|
||||
|
||||
for (const check of fkChecks) {
|
||||
try {
|
||||
if (!this.db) return report;
|
||||
const stmt = this.db.prepare(
|
||||
`SELECT t.${check.fk_column} AS orphan_ref, COUNT(*) AS count
|
||||
FROM ${check.table} t
|
||||
LEFT JOIN ${check.parent_table} o ON t.${check.fk_column} = o.id
|
||||
WHERE t.${check.fk_column} IS NOT NULL AND o.id IS NULL
|
||||
GROUP BY t.${check.fk_column}`
|
||||
)
|
||||
const orphans = stmt.all() as Array<{ orphan_ref: string; count: number }>
|
||||
for (const o of orphans) {
|
||||
report.totalFound++
|
||||
// Archive orphans: flag metadata for review
|
||||
report.archived.push({
|
||||
table: check.table,
|
||||
id: o.orphan_ref,
|
||||
reason: `FK-off: ${check.fk_column} → ${check.parent_table} (${o.count} rows)`
|
||||
})
|
||||
}
|
||||
await this.runFkCheck(check, report)
|
||||
} catch (error) {
|
||||
report.errors.push(`FK check failed for ${check.table}.${check.fk_column}: ${error}`)
|
||||
report.errors.push(
|
||||
`FK check failed for ${check.table}.${check.fk_column}: ${error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single FK-off invariant against the live DB.
|
||||
* Performs real UPDATE/DELETE; never just records.
|
||||
*/
|
||||
private async runFkCheck(
|
||||
check: FkInvariant,
|
||||
report: OrphanReferenceReport,
|
||||
): Promise<void> {
|
||||
const db = this.db
|
||||
if (!db) return
|
||||
|
||||
// Skip invariants whose table does not exist in this session DB
|
||||
// (test schemas / fresh sessions may be missing some tables).
|
||||
if (!this.tableExists(db, check.table)) return
|
||||
if (!this.tableExists(db, check.parent_table)) return
|
||||
|
||||
// Collect orphan rows. For nullable FKs we skip nulls; for non-nullable
|
||||
// we still allow the scan but rely on the DB schema to enforce NOT NULL.
|
||||
const whereNull = check.nullable ? `AND t.${check.fk_column} IS NOT NULL` : ''
|
||||
const orphanStmt = db.prepare(
|
||||
`SELECT t.id AS row_id, t.${check.fk_column} AS orphan_ref
|
||||
FROM ${check.table} t
|
||||
LEFT JOIN ${check.parent_table} o ON t.${check.fk_column} = o.id
|
||||
WHERE o.id IS NULL ${whereNull}`,
|
||||
)
|
||||
const orphans = orphanStmt.all() as Array<{ row_id: string; orphan_ref: string }>
|
||||
|
||||
if (orphans.length === 0) return
|
||||
|
||||
report.totalFound += orphans.length
|
||||
|
||||
// Find a fallback parent id once per check (most-recent valid row).
|
||||
let fallbackParentId: string | null = null
|
||||
const needFallback = check.action === 'reparent'
|
||||
if (needFallback) {
|
||||
fallbackParentId = this.findRecentValidParent(db, check.parent_table)
|
||||
}
|
||||
|
||||
const updateStmt = db.prepare(
|
||||
`UPDATE ${check.table} SET ${check.fk_column} = ? WHERE id = ?`,
|
||||
)
|
||||
const archiveStatusStmt = db.prepare(
|
||||
`UPDATE ${check.table} SET status = ? WHERE id = ?`,
|
||||
)
|
||||
const deleteStmt = db.prepare(`DELETE FROM ${check.table} WHERE id = ?`)
|
||||
|
||||
for (const o of orphans) {
|
||||
if (check.action === 'reparent' && fallbackParentId) {
|
||||
updateStmt.run(fallbackParentId, o.row_id)
|
||||
report.reparented.push({
|
||||
table: check.table,
|
||||
id: o.row_id,
|
||||
new_parent_id: fallbackParentId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// No reparent possible, or action is archive. Apply real DB write.
|
||||
if (check.archive_status) {
|
||||
archiveStatusStmt.run(check.archive_status, o.row_id)
|
||||
report.archived.push({
|
||||
table: check.table,
|
||||
id: o.row_id,
|
||||
reason: `FK-off: ${check.fk_column} → ${check.parent_table}`,
|
||||
})
|
||||
} else {
|
||||
deleteStmt.run(o.row_id)
|
||||
report.archived.push({
|
||||
table: check.table,
|
||||
id: o.row_id,
|
||||
reason: `FK-off: ${check.fk_column} → ${check.parent_table} (deleted)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most-recent row id in `parent_table`, or null if the table
|
||||
* is empty. Used as the fallback parent during reparent actions.
|
||||
*/
|
||||
private findRecentValidParent(db: Database, parent_table: string): string | null {
|
||||
if (!this.tableExists(db, parent_table)) return null
|
||||
try {
|
||||
const row = db.prepare(
|
||||
`SELECT id FROM ${parent_table} ORDER BY id DESC LIMIT 1`,
|
||||
).get() as { id?: string } | null
|
||||
return row?.id ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap existence check against sqlite_schema. Avoids throwing on test
|
||||
* schemas that omit some of the production tables.
|
||||
*/
|
||||
private tableExists(db: Database, table: string): boolean {
|
||||
try {
|
||||
const row = db.prepare(
|
||||
`SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?`,
|
||||
).get(table) as { 1?: number } | null
|
||||
return row !== null
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PID liveness check for running agents.
|
||||
* Uses Signal 0 (kill -0) to check process existence.
|
||||
|
||||
Reference in New Issue
Block a user