@kodrunhq/opencode-autopilot 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -13
- package/assets/commands/oc-update-docs.md +1 -1
- package/package.json +1 -1
- package/src/agents/index.ts +0 -12
- package/src/agents/pipeline/index.ts +0 -4
- package/src/autonomy/completion.ts +52 -0
- package/src/autonomy/controller.ts +144 -0
- package/src/autonomy/index.ts +25 -0
- package/src/autonomy/injector.ts +49 -0
- package/src/autonomy/state.ts +91 -0
- package/src/autonomy/types.ts +30 -0
- package/src/autonomy/verification.ts +86 -0
- package/src/background/database.ts +170 -0
- package/src/background/executor.ts +174 -0
- package/src/background/index.ts +8 -0
- package/src/background/manager.ts +232 -0
- package/src/background/repository.ts +174 -0
- package/src/background/schema.ts +24 -0
- package/src/background/sdk-runner.ts +40 -0
- package/src/background/slot-manager.ts +41 -0
- package/src/background/state-machine.ts +19 -0
- package/src/context/budget.ts +45 -0
- package/src/context/compaction-handler.ts +58 -0
- package/src/context/discovery.ts +94 -0
- package/src/context/index.ts +14 -0
- package/src/context/injector.ts +119 -0
- package/src/context/types.ts +24 -0
- package/src/health/checks.ts +145 -2
- package/src/health/index.ts +7 -1
- package/src/health/runner.ts +6 -0
- package/src/index.ts +113 -6
- package/src/installer.ts +13 -0
- package/src/kernel/index.ts +6 -0
- package/src/kernel/migrations.ts +50 -0
- package/src/kernel/retry.ts +49 -0
- package/src/kernel/schema.ts +9 -1
- package/src/kernel/transaction.ts +40 -12
- package/src/logging/forensic-writer.ts +6 -2
- package/src/logging/index.ts +2 -0
- package/src/mcp/index.ts +34 -0
- package/src/mcp/manager.ts +206 -0
- package/src/mcp/scope-filter.ts +44 -0
- package/src/mcp/types.ts +38 -0
- package/src/orchestrator/arena.ts +7 -1
- package/src/orchestrator/fallback/event-handler.ts +12 -1
- package/src/orchestrator/handlers/challenge.ts +8 -1
- package/src/orchestrator/handlers/plan.ts +8 -1
- package/src/orchestrator/handlers/recon.ts +8 -1
- package/src/orchestrator/handlers/types.ts +2 -2
- package/src/orchestrator/lesson-memory.ts +6 -1
- package/src/orchestrator/orchestration-logger.ts +15 -3
- package/src/orchestrator/skill-injection.ts +7 -1
- package/src/orchestrator/state.ts +6 -1
- package/src/recovery/classifier.ts +127 -0
- package/src/recovery/event-handler.ts +263 -0
- package/src/recovery/index.ts +20 -0
- package/src/recovery/orchestrator.ts +180 -0
- package/src/recovery/persistence.ts +87 -0
- package/src/recovery/strategies.ts +107 -0
- package/src/recovery/types.ts +31 -0
- package/src/registry/model-groups.ts +2 -19
- package/src/registry/resolver.ts +38 -9
- package/src/review/agent-catalog.ts +83 -251
- package/src/review/agents/architecture-verifier.ts +41 -0
- package/src/review/agents/code-hygiene-auditor.ts +40 -0
- package/src/review/agents/correctness-auditor.ts +41 -0
- package/src/review/agents/frontend-auditor.ts +39 -0
- package/src/review/agents/index.ts +15 -42
- package/src/review/agents/language-idioms-auditor.ts +39 -0
- package/src/review/agents/security-auditor.ts +12 -8
- package/src/review/stack-gate.ts +2 -6
- package/src/routing/categories.ts +111 -0
- package/src/routing/classifier.ts +152 -0
- package/src/routing/engine.ts +89 -0
- package/src/routing/index.ts +4 -0
- package/src/routing/types.ts +14 -0
- package/src/skills/adaptive-injector.ts +34 -3
- package/src/skills/loader.ts +4 -0
- package/src/tools/background.ts +196 -0
- package/src/tools/delegate.ts +205 -0
- package/src/tools/loop.ts +94 -0
- package/src/tools/recover.ts +172 -0
- package/src/types/recovery.ts +10 -0
- package/src/ux/context-warnings.ts +81 -0
- package/src/ux/error-hints.ts +38 -0
- package/src/ux/index.ts +7 -0
- package/src/ux/notifications.ts +67 -0
- package/src/ux/progress.ts +77 -0
- package/src/ux/session-summary.ts +67 -0
- package/src/ux/task-status.ts +109 -0
- package/src/ux/types.ts +24 -0
- package/src/agents/db-specialist.ts +0 -295
- package/src/agents/devops.ts +0 -352
- package/src/agents/documenter.ts +0 -44
- package/src/agents/frontend-engineer.ts +0 -541
- package/src/agents/pipeline/oc-explorer.ts +0 -46
- package/src/agents/pipeline/oc-retrospector.ts +0 -42
- package/src/review/agents/auth-flow-verifier.ts +0 -47
- package/src/review/agents/concurrency-checker.ts +0 -47
- package/src/review/agents/dead-code-scanner.ts +0 -47
- package/src/review/agents/go-idioms-auditor.ts +0 -46
- package/src/review/agents/python-django-auditor.ts +0 -46
- package/src/review/agents/react-patterns-auditor.ts +0 -46
- package/src/review/agents/rust-safety-auditor.ts +0 -46
- package/src/review/agents/scope-intent-verifier.ts +0 -45
- package/src/review/agents/silent-failure-hunter.ts +0 -45
- package/src/review/agents/spec-checker.ts +0 -45
- package/src/review/agents/state-mgmt-auditor.ts +0 -46
- package/src/review/agents/type-soundness.ts +0 -46
- package/src/review/agents/wiring-inspector.ts +0 -46
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import type { LoopContext, VerificationCheck, VerificationResult } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface CommandExecutionResult {
|
|
5
|
+
readonly exitCode: number;
|
|
6
|
+
readonly output: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface VerificationHandlerDeps {
|
|
10
|
+
readonly runCommand?: (command: string) => Promise<CommandExecutionResult>;
|
|
11
|
+
readonly artifactPaths?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createCheck(name: string, passed: boolean, message: string): VerificationCheck {
|
|
15
|
+
return Object.freeze({ name, passed, message });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class VerificationHandler {
|
|
19
|
+
constructor(private readonly deps: VerificationHandlerDeps = {}) {}
|
|
20
|
+
|
|
21
|
+
async verify(_context: LoopContext): Promise<VerificationResult> {
|
|
22
|
+
const checks = await Promise.all([
|
|
23
|
+
this.checkTestsPass(),
|
|
24
|
+
this.checkLintClean(),
|
|
25
|
+
this.checkArtifactsExist(this.deps.artifactPaths ?? []),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
passed: checks.every((check) => check.passed),
|
|
30
|
+
checks: Object.freeze(checks),
|
|
31
|
+
timestamp: new Date().toISOString(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async checkTestsPass(): Promise<VerificationCheck> {
|
|
36
|
+
return this.runCommandCheck("tests", "bun test");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async checkLintClean(): Promise<VerificationCheck> {
|
|
40
|
+
return this.runCommandCheck("lint", "bun run lint");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async checkArtifactsExist(paths: readonly string[]): Promise<VerificationCheck> {
|
|
44
|
+
if (paths.length === 0) {
|
|
45
|
+
return createCheck("artifacts", true, "No artifact paths configured");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const missingPaths: string[] = [];
|
|
50
|
+
for (const path of paths) {
|
|
51
|
+
try {
|
|
52
|
+
await access(path);
|
|
53
|
+
} catch {
|
|
54
|
+
missingPaths.push(path);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (missingPaths.length > 0) {
|
|
59
|
+
return createCheck("artifacts", false, `Missing artifacts: ${missingPaths.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return createCheck("artifacts", true, `Verified ${paths.length} artifact(s)`);
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
return createCheck("artifacts", false, `Artifact check failed: ${message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async runCommandCheck(name: string, command: string): Promise<VerificationCheck> {
|
|
70
|
+
if (!this.deps.runCommand) {
|
|
71
|
+
return createCheck(name, true, `Skipped ${command}: no command runner configured`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const result = await this.deps.runCommand(command);
|
|
76
|
+
if (result.exitCode === 0) {
|
|
77
|
+
return createCheck(name, true, `${command} passed`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return createCheck(name, false, `${command} failed: ${result.output}`);
|
|
81
|
+
} catch (error: unknown) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return createCheck(name, false, `${command} failed: ${message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { type TaskStatus, TaskStatusSchema } from "../types/background";
|
|
4
|
+
|
|
5
|
+
export const ACTIVE_TASK_STATUSES = Object.freeze(["pending", "running"] as const);
|
|
6
|
+
export const TERMINAL_TASK_STATUSES = Object.freeze(["completed", "failed", "cancelled"] as const);
|
|
7
|
+
|
|
8
|
+
export interface BackgroundTaskRecord {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly sessionId: string;
|
|
11
|
+
readonly description: string;
|
|
12
|
+
readonly category: string | null;
|
|
13
|
+
readonly status: TaskStatus;
|
|
14
|
+
readonly result: string | null;
|
|
15
|
+
readonly error: string | null;
|
|
16
|
+
readonly agent: string | null;
|
|
17
|
+
readonly model: string | null;
|
|
18
|
+
readonly priority: number;
|
|
19
|
+
readonly createdAt: string;
|
|
20
|
+
readonly updatedAt: string;
|
|
21
|
+
readonly startedAt: string | null;
|
|
22
|
+
readonly completedAt: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CreateBackgroundTaskInput {
|
|
26
|
+
readonly id?: string;
|
|
27
|
+
readonly sessionId: string;
|
|
28
|
+
readonly description: string;
|
|
29
|
+
readonly category?: string | null;
|
|
30
|
+
readonly status?: TaskStatus;
|
|
31
|
+
readonly result?: string | null;
|
|
32
|
+
readonly error?: string | null;
|
|
33
|
+
readonly agent?: string | null;
|
|
34
|
+
readonly model?: string | null;
|
|
35
|
+
readonly priority?: number;
|
|
36
|
+
readonly createdAt?: string;
|
|
37
|
+
readonly updatedAt?: string;
|
|
38
|
+
readonly startedAt?: string | null;
|
|
39
|
+
readonly completedAt?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BackgroundTaskResultRecord {
|
|
43
|
+
readonly status: TaskStatus;
|
|
44
|
+
readonly result: string | null;
|
|
45
|
+
readonly error: string | null;
|
|
46
|
+
readonly completedAt: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BackgroundTaskRow {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly session_id: string;
|
|
52
|
+
readonly description: string;
|
|
53
|
+
readonly category: string | null;
|
|
54
|
+
readonly status: string;
|
|
55
|
+
readonly result: string | null;
|
|
56
|
+
readonly error: string | null;
|
|
57
|
+
readonly agent: string | null;
|
|
58
|
+
readonly model: string | null;
|
|
59
|
+
readonly priority: number;
|
|
60
|
+
readonly created_at: string;
|
|
61
|
+
readonly updated_at: string;
|
|
62
|
+
readonly started_at: string | null;
|
|
63
|
+
readonly completed_at: string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isClosedDatabaseError(error: unknown): boolean {
|
|
67
|
+
return (
|
|
68
|
+
error instanceof RangeError &&
|
|
69
|
+
typeof error.message === "string" &&
|
|
70
|
+
error.message.includes("closed database")
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createTaskId(): string {
|
|
75
|
+
return randomUUID();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createTimestamp(now: () => string = () => new Date().toISOString()): string {
|
|
79
|
+
return now();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseTaskStatus(value: unknown): TaskStatus {
|
|
83
|
+
return TaskStatusSchema.parse(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function rowToBackgroundTask(row: BackgroundTaskRow): BackgroundTaskRecord {
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
id: row.id,
|
|
89
|
+
sessionId: row.session_id,
|
|
90
|
+
description: row.description,
|
|
91
|
+
category: row.category,
|
|
92
|
+
status: parseTaskStatus(row.status),
|
|
93
|
+
result: row.result,
|
|
94
|
+
error: row.error,
|
|
95
|
+
agent: row.agent,
|
|
96
|
+
model: row.model,
|
|
97
|
+
priority: row.priority,
|
|
98
|
+
createdAt: row.created_at,
|
|
99
|
+
updatedAt: row.updated_at,
|
|
100
|
+
startedAt: row.started_at,
|
|
101
|
+
completedAt: row.completed_at,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function getTaskByIdRow(db: Database, taskId: string): BackgroundTaskRecord | null {
|
|
106
|
+
try {
|
|
107
|
+
const row = db
|
|
108
|
+
.query("SELECT * FROM background_tasks WHERE id = ?")
|
|
109
|
+
.get(taskId) as BackgroundTaskRow | null;
|
|
110
|
+
return row ? rowToBackgroundTask(row) : null;
|
|
111
|
+
} catch (error: unknown) {
|
|
112
|
+
if (isClosedDatabaseError(error)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ListBackgroundTasksFilters {
|
|
120
|
+
readonly sessionId?: string;
|
|
121
|
+
readonly status?: TaskStatus;
|
|
122
|
+
readonly statuses?: readonly TaskStatus[];
|
|
123
|
+
readonly limit?: number;
|
|
124
|
+
readonly prioritizePending?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function listBackgroundTasks(
|
|
128
|
+
db: Database,
|
|
129
|
+
filters: ListBackgroundTasksFilters = {},
|
|
130
|
+
): readonly BackgroundTaskRecord[] {
|
|
131
|
+
const conditions: string[] = [];
|
|
132
|
+
const params: Array<string | number> = [];
|
|
133
|
+
|
|
134
|
+
if (filters.sessionId) {
|
|
135
|
+
conditions.push("session_id = ?");
|
|
136
|
+
params.push(filters.sessionId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (filters.status) {
|
|
140
|
+
conditions.push("status = ?");
|
|
141
|
+
params.push(filters.status);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (filters.statuses && filters.statuses.length > 0) {
|
|
145
|
+
const placeholders = filters.statuses.map(() => "?").join(", ");
|
|
146
|
+
conditions.push(`status IN (${placeholders})`);
|
|
147
|
+
params.push(...filters.statuses);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
151
|
+
const orderBy = filters.prioritizePending
|
|
152
|
+
? "ORDER BY priority DESC, created_at ASC, id ASC"
|
|
153
|
+
: "ORDER BY created_at DESC, id DESC";
|
|
154
|
+
const limitClause = typeof filters.limit === "number" ? " LIMIT ?" : "";
|
|
155
|
+
const query = `SELECT * FROM background_tasks ${whereClause} ${orderBy}${limitClause}`;
|
|
156
|
+
|
|
157
|
+
if (typeof filters.limit === "number") {
|
|
158
|
+
params.push(filters.limit);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const rows = db.query(query).all(...params) as BackgroundTaskRow[];
|
|
163
|
+
return Object.freeze(rows.map(rowToBackgroundTask));
|
|
164
|
+
} catch (error: unknown) {
|
|
165
|
+
if (isClosedDatabaseError(error)) {
|
|
166
|
+
return Object.freeze([]);
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Logger } from "../logging/types";
|
|
2
|
+
import type { TaskStatus } from "../types/background";
|
|
3
|
+
import type { BackgroundTaskRecord } from "./database";
|
|
4
|
+
|
|
5
|
+
export interface ExecuteTaskStatusPayload {
|
|
6
|
+
readonly result?: string | null;
|
|
7
|
+
readonly error?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ExecuteTaskDependencies {
|
|
11
|
+
readonly updateStatus: (
|
|
12
|
+
taskId: string,
|
|
13
|
+
status: TaskStatus,
|
|
14
|
+
payload?: ExecuteTaskStatusPayload,
|
|
15
|
+
) => void | Promise<void>;
|
|
16
|
+
readonly logger?: Logger;
|
|
17
|
+
readonly wait?: (durationMs: number, signal: AbortSignal) => Promise<void>;
|
|
18
|
+
readonly run?: (
|
|
19
|
+
task: BackgroundTaskRecord,
|
|
20
|
+
signal: AbortSignal,
|
|
21
|
+
) => Promise<string | null | undefined>;
|
|
22
|
+
readonly getTaskById?: (taskId: string) => BackgroundTaskRecord | null;
|
|
23
|
+
readonly timeoutMs?: number;
|
|
24
|
+
readonly executionDelayMs?: number;
|
|
25
|
+
readonly pollIntervalMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ExecuteTaskResult {
|
|
29
|
+
readonly taskId: string;
|
|
30
|
+
readonly status: TaskStatus;
|
|
31
|
+
readonly result?: string;
|
|
32
|
+
readonly error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createAbortError(message: string): Error {
|
|
36
|
+
const error = new Error(message);
|
|
37
|
+
Object.defineProperty(error, "name", { value: "AbortError" });
|
|
38
|
+
return error;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isAbortError(error: unknown): boolean {
|
|
42
|
+
return error instanceof Error && error.name === "AbortError";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getDefaultRunner(
|
|
46
|
+
wait: (durationMs: number, signal: AbortSignal) => Promise<void>,
|
|
47
|
+
delayMs: number,
|
|
48
|
+
): (task: BackgroundTaskRecord, signal: AbortSignal) => Promise<string> {
|
|
49
|
+
return async (task, signal) => {
|
|
50
|
+
await wait(delayMs, signal);
|
|
51
|
+
const agentSuffix = task.agent ? ` [agent=${task.agent}]` : "";
|
|
52
|
+
const modelSuffix = task.model ? ` [model=${task.model}]` : "";
|
|
53
|
+
return `Completed background task: ${task.description}${agentSuffix}${modelSuffix}`;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function defaultWait(durationMs: number, signal: AbortSignal): Promise<void> {
|
|
58
|
+
await new Promise<void>((resolve, reject) => {
|
|
59
|
+
if (signal.aborted) {
|
|
60
|
+
reject(signal.reason ?? createAbortError("Aborted"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const timeoutId = setTimeout(() => {
|
|
65
|
+
signal.removeEventListener("abort", onAbort);
|
|
66
|
+
resolve();
|
|
67
|
+
}, durationMs);
|
|
68
|
+
|
|
69
|
+
const onAbort = () => {
|
|
70
|
+
clearTimeout(timeoutId);
|
|
71
|
+
reject(signal.reason ?? createAbortError("Aborted"));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getCancellationState(
|
|
79
|
+
taskId: string,
|
|
80
|
+
getTaskById?: (taskId: string) => BackgroundTaskRecord | null,
|
|
81
|
+
): BackgroundTaskRecord | null {
|
|
82
|
+
if (!getTaskById) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const currentTask = getTaskById(taskId);
|
|
87
|
+
return currentTask?.status === "cancelled" ? currentTask : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function executeTask(
|
|
91
|
+
task: BackgroundTaskRecord,
|
|
92
|
+
deps: ExecuteTaskDependencies,
|
|
93
|
+
): Promise<ExecuteTaskResult> {
|
|
94
|
+
const logger = deps.logger;
|
|
95
|
+
const cancelledBeforeStart = getCancellationState(task.id, deps.getTaskById);
|
|
96
|
+
if (cancelledBeforeStart) {
|
|
97
|
+
logger?.info("Background task already cancelled before execution", {
|
|
98
|
+
backgroundTaskId: task.id,
|
|
99
|
+
});
|
|
100
|
+
return { taskId: task.id, status: "cancelled" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await deps.updateStatus(task.id, "running");
|
|
104
|
+
logger?.info("Background task started", { backgroundTaskId: task.id });
|
|
105
|
+
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const timeoutMs = deps.timeoutMs ?? 1_000;
|
|
108
|
+
const executionDelayMs = deps.executionDelayMs ?? 10;
|
|
109
|
+
const wait = deps.wait ?? defaultWait;
|
|
110
|
+
const run = deps.run ?? getDefaultRunner(wait, executionDelayMs);
|
|
111
|
+
|
|
112
|
+
let timedOut = false;
|
|
113
|
+
const timeoutId = setTimeout(() => {
|
|
114
|
+
timedOut = true;
|
|
115
|
+
controller.abort(createAbortError(`Background task timed out after ${timeoutMs}ms`));
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
|
|
118
|
+
const pollIntervalMs = deps.pollIntervalMs ?? 10;
|
|
119
|
+
const cancellationIntervalId = deps.getTaskById
|
|
120
|
+
? setInterval(() => {
|
|
121
|
+
const cancelledTask = getCancellationState(task.id, deps.getTaskById);
|
|
122
|
+
if (cancelledTask && !controller.signal.aborted) {
|
|
123
|
+
controller.abort(createAbortError("Background task cancelled"));
|
|
124
|
+
}
|
|
125
|
+
}, pollIntervalMs)
|
|
126
|
+
: null;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const result = await run(task, controller.signal);
|
|
130
|
+
const cancelledTask = getCancellationState(task.id, deps.getTaskById);
|
|
131
|
+
if (cancelledTask) {
|
|
132
|
+
logger?.info("Background task cancelled during execution", {
|
|
133
|
+
backgroundTaskId: task.id,
|
|
134
|
+
});
|
|
135
|
+
return { taskId: task.id, status: "cancelled" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const message = result ?? `Completed background task: ${task.description}`;
|
|
139
|
+
await deps.updateStatus(task.id, "completed", { result: message });
|
|
140
|
+
logger?.info("Background task completed", { backgroundTaskId: task.id });
|
|
141
|
+
return { taskId: task.id, status: "completed", result: message };
|
|
142
|
+
} catch (error: unknown) {
|
|
143
|
+
const cancelledTask = getCancellationState(task.id, deps.getTaskById);
|
|
144
|
+
if (cancelledTask) {
|
|
145
|
+
logger?.info("Background task observed cancellation", {
|
|
146
|
+
backgroundTaskId: task.id,
|
|
147
|
+
});
|
|
148
|
+
return { taskId: task.id, status: "cancelled" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (timedOut && isAbortError(error)) {
|
|
152
|
+
const message = error instanceof Error ? error.message : "Background task timed out";
|
|
153
|
+
await deps.updateStatus(task.id, "failed", { error: message });
|
|
154
|
+
logger?.error("Background task timed out", {
|
|
155
|
+
backgroundTaskId: task.id,
|
|
156
|
+
error: message,
|
|
157
|
+
});
|
|
158
|
+
return { taskId: task.id, status: "failed", error: message };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
await deps.updateStatus(task.id, "failed", { error: message });
|
|
163
|
+
logger?.error("Background task failed", {
|
|
164
|
+
backgroundTaskId: task.id,
|
|
165
|
+
error: message,
|
|
166
|
+
});
|
|
167
|
+
return { taskId: task.id, status: "failed", error: message };
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timeoutId);
|
|
170
|
+
if (cancellationIntervalId !== null) {
|
|
171
|
+
clearInterval(cancellationIntervalId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { getLogger } from "../logging/domains";
|
|
3
|
+
import type { Logger } from "../logging/types";
|
|
4
|
+
import type { TaskStatus } from "../types/background";
|
|
5
|
+
import type { BackgroundTaskRecord, BackgroundTaskResultRecord } from "./database";
|
|
6
|
+
import { listBackgroundTasks } from "./database";
|
|
7
|
+
import { type ExecuteTaskDependencies, type ExecuteTaskResult, executeTask } from "./executor";
|
|
8
|
+
import {
|
|
9
|
+
cancelTask,
|
|
10
|
+
createTask,
|
|
11
|
+
getActiveTasks,
|
|
12
|
+
getTaskById,
|
|
13
|
+
getTaskResult,
|
|
14
|
+
listTasks,
|
|
15
|
+
updateStatus,
|
|
16
|
+
} from "./repository";
|
|
17
|
+
import { SlotManager } from "./slot-manager";
|
|
18
|
+
|
|
19
|
+
function isClosedDatabaseError(error: unknown): boolean {
|
|
20
|
+
return (
|
|
21
|
+
error instanceof RangeError &&
|
|
22
|
+
typeof error.message === "string" &&
|
|
23
|
+
error.message.includes("closed database")
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BackgroundSpawnOptions {
|
|
28
|
+
readonly category?: string;
|
|
29
|
+
readonly agent?: string;
|
|
30
|
+
readonly model?: string;
|
|
31
|
+
readonly priority?: number;
|
|
32
|
+
readonly timeoutMs?: number;
|
|
33
|
+
readonly executionDelayMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BackgroundManagerOptions {
|
|
37
|
+
readonly db: Database;
|
|
38
|
+
readonly maxConcurrent?: number;
|
|
39
|
+
readonly logger?: Logger;
|
|
40
|
+
readonly executor?: (
|
|
41
|
+
task: BackgroundTaskRecord,
|
|
42
|
+
deps: ExecuteTaskDependencies,
|
|
43
|
+
) => Promise<ExecuteTaskResult>;
|
|
44
|
+
readonly runTask?: ExecuteTaskDependencies["run"];
|
|
45
|
+
readonly wait?: ExecuteTaskDependencies["wait"];
|
|
46
|
+
readonly timeoutMs?: number;
|
|
47
|
+
readonly executionDelayMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface RunningTaskEntry {
|
|
51
|
+
readonly slotId: string;
|
|
52
|
+
readonly promise: Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class BackgroundManager {
|
|
56
|
+
private readonly maxConcurrent: number;
|
|
57
|
+
private readonly logger: Logger;
|
|
58
|
+
private readonly slotManager: SlotManager;
|
|
59
|
+
private readonly runningTasks = new Map<string, RunningTaskEntry>();
|
|
60
|
+
private readonly execute: (
|
|
61
|
+
task: BackgroundTaskRecord,
|
|
62
|
+
deps: ExecuteTaskDependencies,
|
|
63
|
+
) => Promise<ExecuteTaskResult>;
|
|
64
|
+
private disposed = false;
|
|
65
|
+
private isPumping = false;
|
|
66
|
+
|
|
67
|
+
constructor(private readonly options: BackgroundManagerOptions) {
|
|
68
|
+
this.maxConcurrent = options.maxConcurrent ?? 5;
|
|
69
|
+
this.logger = options.logger ?? getLogger("background", "manager");
|
|
70
|
+
this.slotManager = new SlotManager(this.maxConcurrent);
|
|
71
|
+
this.execute = options.executor ?? executeTask;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
spawn(sessionId: string, description: string, options: BackgroundSpawnOptions = {}): string {
|
|
75
|
+
if (this.disposed) {
|
|
76
|
+
throw new Error("BackgroundManager is disposed");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const task = createTask(this.options.db, {
|
|
80
|
+
sessionId,
|
|
81
|
+
description,
|
|
82
|
+
category: options.category,
|
|
83
|
+
agent: options.agent,
|
|
84
|
+
model: options.model,
|
|
85
|
+
priority: options.priority,
|
|
86
|
+
});
|
|
87
|
+
this.logger.info("Background task queued", {
|
|
88
|
+
backgroundTaskId: task.id,
|
|
89
|
+
sessionId,
|
|
90
|
+
description,
|
|
91
|
+
});
|
|
92
|
+
queueMicrotask(() => {
|
|
93
|
+
void this.pumpQueue();
|
|
94
|
+
});
|
|
95
|
+
return task.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async waitForIdle(timeoutMs = 1_000): Promise<void> {
|
|
99
|
+
const startedAt = Date.now();
|
|
100
|
+
while (true) {
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
const hasPendingTasks =
|
|
103
|
+
listBackgroundTasks(this.options.db, { status: "pending", limit: 1 }).length > 0;
|
|
104
|
+
const isIdle = this.runningTasks.size === 0 && !hasPendingTasks;
|
|
105
|
+
if (isIdle) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
110
|
+
throw new Error("Timed out waiting for background manager to become idle");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async dispose(): Promise<void> {
|
|
118
|
+
await this.waitForIdle();
|
|
119
|
+
this.disposed = true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cancel(taskId: string): boolean {
|
|
123
|
+
const task = getTaskById(this.options.db, taskId);
|
|
124
|
+
if (!task) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (task.status !== "pending" && task.status !== "running") {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
cancelTask(this.options.db, taskId);
|
|
133
|
+
this.logger.info("Background task cancelled", { backgroundTaskId: taskId });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
cancelAll(sessionId?: string): number {
|
|
138
|
+
const activeTasks = getActiveTasks(this.options.db, sessionId);
|
|
139
|
+
let cancelledCount = 0;
|
|
140
|
+
for (const task of activeTasks) {
|
|
141
|
+
if (this.cancel(task.id)) {
|
|
142
|
+
cancelledCount += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return cancelledCount;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getStatus(taskId: string): BackgroundTaskRecord | null {
|
|
149
|
+
return getTaskById(this.options.db, taskId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
list(sessionId?: string, statusFilter?: TaskStatus): readonly BackgroundTaskRecord[] {
|
|
153
|
+
return listTasks(this.options.db, { sessionId, status: statusFilter });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
getResult(taskId: string): BackgroundTaskResultRecord | null {
|
|
157
|
+
return getTaskResult(this.options.db, taskId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async pumpQueue(): Promise<void> {
|
|
161
|
+
if (this.disposed || this.isPumping) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.isPumping = true;
|
|
166
|
+
try {
|
|
167
|
+
while (!this.slotManager.isFull()) {
|
|
168
|
+
const nextTask = listBackgroundTasks(this.options.db, {
|
|
169
|
+
status: "pending",
|
|
170
|
+
limit: 1,
|
|
171
|
+
prioritizePending: true,
|
|
172
|
+
})[0];
|
|
173
|
+
if (!nextTask || this.runningTasks.has(nextTask.id)) {
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const slotId = this.slotManager.acquire();
|
|
178
|
+
if (!slotId) {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const promise = this.runTask(nextTask, slotId);
|
|
183
|
+
this.runningTasks.set(nextTask.id, { slotId, promise });
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
this.isPumping = false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async runTask(task: BackgroundTaskRecord, slotId: string): Promise<void> {
|
|
191
|
+
try {
|
|
192
|
+
await this.execute(task, {
|
|
193
|
+
updateStatus: (taskId, status, payload) => {
|
|
194
|
+
try {
|
|
195
|
+
updateStatus(this.options.db, taskId, status, payload);
|
|
196
|
+
} catch (error: unknown) {
|
|
197
|
+
if (!isClosedDatabaseError(error)) {
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
getTaskById: (taskId) => {
|
|
203
|
+
try {
|
|
204
|
+
return getTaskById(this.options.db, taskId);
|
|
205
|
+
} catch (error: unknown) {
|
|
206
|
+
if (isClosedDatabaseError(error)) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
logger: this.logger.child({ backgroundTaskId: task.id, slotId }),
|
|
213
|
+
run: this.options.runTask,
|
|
214
|
+
wait: this.options.wait,
|
|
215
|
+
timeoutMs: this.options.timeoutMs,
|
|
216
|
+
executionDelayMs: this.options.executionDelayMs,
|
|
217
|
+
});
|
|
218
|
+
} catch (error: unknown) {
|
|
219
|
+
if (!isClosedDatabaseError(error)) {
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
this.runningTasks.delete(task.id);
|
|
224
|
+
this.slotManager.release(slotId);
|
|
225
|
+
queueMicrotask(() => {
|
|
226
|
+
if (!this.disposed) {
|
|
227
|
+
void this.pumpQueue();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|