astrocode-workflow 0.2.0 → 0.2.2
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/dist/index.js +6 -0
- package/dist/state/db.d.ts +1 -1
- package/dist/state/db.js +62 -4
- package/dist/state/repo-lock.d.ts +3 -0
- package/dist/state/repo-lock.js +29 -0
- package/dist/tools/workflow.d.ts +1 -1
- package/dist/tools/workflow.js +224 -209
- package/dist/ui/inject.d.ts +9 -17
- package/dist/ui/inject.js +79 -102
- package/dist/workflow/state-machine.d.ts +32 -32
- package/dist/workflow/state-machine.js +85 -170
- package/package.json +1 -1
- package/src/index.ts +8 -0
- package/src/state/db.ts +63 -4
- package/src/state/repo-lock.ts +26 -0
- package/src/tools/workflow.ts +159 -142
- package/src/ui/inject.ts +98 -105
- package/src/workflow/state-machine.ts +123 -227
package/dist/ui/inject.js
CHANGED
|
@@ -1,118 +1,95 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// - Retries with backoff
|
|
7
|
-
// - flush() lets callers wait until injections are actually sent
|
|
8
|
-
//
|
|
9
|
-
// IMPORTANT: Callers who need reliability must `await injectChatPrompt(...)`
|
|
10
|
-
// or `await flushChatPrompts()` after enqueueing.
|
|
11
|
-
const injectionQueue = [];
|
|
12
|
-
let workerRunning = false;
|
|
13
|
-
// Used to let callers await "queue drained"
|
|
14
|
-
let drainWaiters = [];
|
|
1
|
+
const MAX_ATTEMPTS = 4;
|
|
2
|
+
const RETRY_DELAYS_MS = [250, 500, 1000, 2000]; // attempt 1..4
|
|
3
|
+
// Per-session queues so one stuck session doesn't block others
|
|
4
|
+
const queues = new Map();
|
|
5
|
+
const running = new Set();
|
|
15
6
|
function sleep(ms) {
|
|
16
|
-
return new Promise((
|
|
7
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
17
8
|
}
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
for (const w of waiters)
|
|
26
|
-
w();
|
|
27
|
-
}
|
|
28
|
-
function getPromptApi(ctx) {
|
|
29
|
-
const fn = ctx?.client?.session?.prompt;
|
|
30
|
-
return typeof fn === "function" ? fn.bind(ctx.client.session) : null;
|
|
9
|
+
function getPromptInvoker(ctx) {
|
|
10
|
+
const session = ctx?.client?.session;
|
|
11
|
+
const prompt = session?.prompt;
|
|
12
|
+
if (!session || typeof prompt !== "function") {
|
|
13
|
+
throw new Error("API not available (ctx.client.session.prompt)");
|
|
14
|
+
}
|
|
15
|
+
return { session, prompt };
|
|
31
16
|
}
|
|
32
|
-
async function
|
|
33
|
-
const { ctx, sessionId, text } =
|
|
34
|
-
const agent = opts.agent ?? "Astro";
|
|
17
|
+
async function tryInjectOnce(item) {
|
|
18
|
+
const { ctx, sessionId, text, agent = "Astro" } = item;
|
|
35
19
|
const prefixedText = `[${agent}]\n\n${text}`;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
const maxAttempts = 3;
|
|
46
|
-
let attempt = 0;
|
|
47
|
-
while (attempt < maxAttempts) {
|
|
48
|
-
attempt += 1;
|
|
49
|
-
try {
|
|
50
|
-
await prompt({
|
|
51
|
-
path: { id: sessionId },
|
|
52
|
-
body: {
|
|
53
|
-
parts: [{ type: "text", text: prefixedText }],
|
|
54
|
-
agent,
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
61
|
-
const isLast = attempt >= maxAttempts;
|
|
62
|
-
if (isLast) {
|
|
63
|
-
console.warn(`[Astrocode] Injection failed (final): ${msg}`);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
// Exponential backoff + jitter
|
|
67
|
-
const base = 150 * Math.pow(2, attempt - 1); // 150, 300, 600
|
|
68
|
-
const jitter = Math.floor(Math.random() * 120);
|
|
69
|
-
await sleep(base + jitter);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
20
|
+
const { session, prompt } = getPromptInvoker(ctx);
|
|
21
|
+
// IMPORTANT: force correct `this` binding
|
|
22
|
+
await prompt.call(session, {
|
|
23
|
+
path: { id: sessionId },
|
|
24
|
+
body: {
|
|
25
|
+
parts: [{ type: "text", text: prefixedText }],
|
|
26
|
+
agent,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
72
29
|
}
|
|
73
|
-
async function
|
|
74
|
-
if (
|
|
30
|
+
async function runSessionQueue(sessionId) {
|
|
31
|
+
if (running.has(sessionId))
|
|
75
32
|
return;
|
|
76
|
-
|
|
33
|
+
running.add(sessionId);
|
|
77
34
|
try {
|
|
78
|
-
//
|
|
79
|
-
while (
|
|
80
|
-
const
|
|
81
|
-
if (!
|
|
82
|
-
|
|
83
|
-
|
|
35
|
+
// eslint-disable-next-line no-constant-condition
|
|
36
|
+
while (true) {
|
|
37
|
+
const q = queues.get(sessionId);
|
|
38
|
+
if (!q || q.length === 0)
|
|
39
|
+
break;
|
|
40
|
+
const item = q.shift();
|
|
41
|
+
try {
|
|
42
|
+
await tryInjectOnce(item);
|
|
43
|
+
item.resolve();
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
item.attempts += 1;
|
|
47
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
+
const delay = RETRY_DELAYS_MS[Math.min(item.attempts - 1, RETRY_DELAYS_MS.length - 1)] ?? 2000;
|
|
49
|
+
if (item.attempts >= MAX_ATTEMPTS) {
|
|
50
|
+
console.warn(`[Astrocode] Injection failed permanently after ${item.attempts} attempts: ${msg}`);
|
|
51
|
+
item.reject(err);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
console.warn(`[Astrocode] Injection attempt ${item.attempts}/${MAX_ATTEMPTS} failed: ${msg}; retrying in ${delay}ms`);
|
|
55
|
+
await sleep(delay);
|
|
56
|
+
// Requeue at front to preserve order (and avoid starving later messages)
|
|
57
|
+
const q2 = queues.get(sessionId) ?? [];
|
|
58
|
+
q2.unshift(item);
|
|
59
|
+
queues.set(sessionId, q2);
|
|
60
|
+
}
|
|
84
61
|
}
|
|
85
62
|
}
|
|
86
63
|
finally {
|
|
87
|
-
|
|
88
|
-
resolveDrainWaitersIfIdle();
|
|
64
|
+
running.delete(sessionId);
|
|
89
65
|
}
|
|
90
66
|
}
|
|
91
67
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
injectionQueue.push(opts);
|
|
97
|
-
// Kick worker
|
|
98
|
-
void runWorkerLoop();
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Wait until all queued injections have been processed (sent or exhausted retries).
|
|
102
|
-
*/
|
|
103
|
-
export function flushChatPrompts() {
|
|
104
|
-
if (!workerRunning && injectionQueue.length === 0)
|
|
105
|
-
return Promise.resolve();
|
|
106
|
-
return new Promise((resolve) => {
|
|
107
|
-
drainWaiters.push(resolve);
|
|
108
|
-
// Ensure worker is running (in case someone enqueued without kick)
|
|
109
|
-
void runWorkerLoop();
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Deterministic helper: enqueue + flush (recommended for stage boundaries).
|
|
68
|
+
* Inject a visible prompt into the conversation.
|
|
69
|
+
* - Deterministic ordering per session
|
|
70
|
+
* - Correct SDK binding (prevents `this._client` undefined)
|
|
71
|
+
* - Awaitable: resolves when delivered, rejects after max retries
|
|
114
72
|
*/
|
|
115
73
|
export async function injectChatPrompt(opts) {
|
|
116
|
-
|
|
117
|
-
|
|
74
|
+
const sessionId = opts.sessionId ?? opts.ctx?.sessionID;
|
|
75
|
+
if (!sessionId) {
|
|
76
|
+
console.warn("[Astrocode] Skipping injection: No sessionId provided");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const item = {
|
|
81
|
+
ctx: opts.ctx,
|
|
82
|
+
sessionId,
|
|
83
|
+
text: opts.text,
|
|
84
|
+
agent: opts.agent,
|
|
85
|
+
attempts: 0,
|
|
86
|
+
resolve,
|
|
87
|
+
reject,
|
|
88
|
+
};
|
|
89
|
+
const q = queues.get(sessionId) ?? [];
|
|
90
|
+
q.push(item);
|
|
91
|
+
queues.set(sessionId, q);
|
|
92
|
+
// Fire worker (don't await here; caller awaits the returned Promise)
|
|
93
|
+
void runSessionQueue(sessionId);
|
|
94
|
+
});
|
|
118
95
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AstrocodeConfig } from "../config/schema";
|
|
2
2
|
import type { SqliteDb } from "../state/db";
|
|
3
3
|
import type { RunRow, StageKey, StageRunRow, StoryRow } from "../state/types";
|
|
4
|
-
import type { ToastOptions } from "../ui/toasts";
|
|
5
4
|
export declare const EVENT_TYPES: {
|
|
6
5
|
readonly RUN_STARTED: "run.started";
|
|
7
6
|
readonly RUN_COMPLETED: "run.completed";
|
|
@@ -11,28 +10,39 @@ export declare const EVENT_TYPES: {
|
|
|
11
10
|
readonly STAGE_STARTED: "stage.started";
|
|
12
11
|
readonly WORKFLOW_PROCEED: "workflow.proceed";
|
|
13
12
|
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
export type UiEmitEvent = {
|
|
14
|
+
kind: "stage_started";
|
|
15
|
+
run_id: string;
|
|
16
|
+
stage_key: StageKey;
|
|
17
|
+
agent_name?: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: "run_completed";
|
|
20
|
+
run_id: string;
|
|
21
|
+
story_key: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "run_failed";
|
|
24
|
+
run_id: string;
|
|
25
|
+
story_key: string;
|
|
26
|
+
stage_key: StageKey;
|
|
27
|
+
error_text: string;
|
|
28
28
|
};
|
|
29
|
+
export type UiEmit = (e: UiEmitEvent) => void;
|
|
29
30
|
/**
|
|
30
31
|
* PLANNING-FIRST REDESIGN
|
|
31
32
|
* ----------------------
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Never mutate story title/body.
|
|
34
|
+
*
|
|
35
|
+
* Deterministic trigger:
|
|
36
|
+
* - config.workflow.genesis_planning:
|
|
37
|
+
* - "off" => never attach directive
|
|
38
|
+
* - "first_story_only"=> only when story_key === "S-0001"
|
|
39
|
+
* - "always" => attach for every run
|
|
40
|
+
*
|
|
41
|
+
* Contract: DB is already initialized before workflow is used:
|
|
42
|
+
* - schema tables exist
|
|
43
|
+
* - repo_state singleton row (id=1) exists
|
|
44
|
+
*
|
|
45
|
+
* IMPORTANT: Do NOT call withTx() in here. The caller owns transaction boundaries.
|
|
36
46
|
*/
|
|
37
47
|
export type NextAction = {
|
|
38
48
|
kind: "idle";
|
|
@@ -67,20 +77,10 @@ export declare function decideNextAction(db: SqliteDb, config: AstrocodeConfig):
|
|
|
67
77
|
export declare function createRunForStory(db: SqliteDb, config: AstrocodeConfig, storyKey: string): {
|
|
68
78
|
run_id: string;
|
|
69
79
|
};
|
|
70
|
-
/**
|
|
71
|
-
* STAGE MOVEMENT (START) — now async so UI injection is deterministic.
|
|
72
|
-
*/
|
|
73
80
|
export declare function startStage(db: SqliteDb, runId: string, stageKey: StageKey, meta?: {
|
|
74
81
|
subagent_type?: string;
|
|
75
82
|
subagent_session_id?: string;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
* STAGE CLOSED (RUN COMPLETED) — now async so UI injection is deterministic.
|
|
80
|
-
*/
|
|
81
|
-
export declare function completeRun(db: SqliteDb, runId: string, ui?: WorkflowUi): Promise<void>;
|
|
82
|
-
/**
|
|
83
|
-
* STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
|
|
84
|
-
*/
|
|
85
|
-
export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, ui?: WorkflowUi): Promise<void>;
|
|
83
|
+
}, emit?: UiEmit): void;
|
|
84
|
+
export declare function completeRun(db: SqliteDb, runId: string, emit?: UiEmit): void;
|
|
85
|
+
export declare function failRun(db: SqliteDb, runId: string, stageKey: StageKey, errorText: string, emit?: UiEmit): void;
|
|
86
86
|
export declare function abortRun(db: SqliteDb, runId: string, reason: string): void;
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { withTx } from "../state/db";
|
|
2
1
|
import { nowISO } from "../shared/time";
|
|
3
2
|
import { newEventId, newRunId, newStageRunId } from "../state/ids";
|
|
4
3
|
import { warn } from "../shared/log";
|
|
5
4
|
import { sha256Hex } from "../shared/hash";
|
|
6
5
|
import { SCHEMA_VERSION } from "../state/schema";
|
|
7
|
-
import { injectChatPrompt } from "../ui/inject";
|
|
8
6
|
export const EVENT_TYPES = {
|
|
9
7
|
RUN_STARTED: "run.started",
|
|
10
8
|
RUN_COMPLETED: "run.completed",
|
|
@@ -14,31 +12,6 @@ export const EVENT_TYPES = {
|
|
|
14
12
|
STAGE_STARTED: "stage.started",
|
|
15
13
|
WORKFLOW_PROCEED: "workflow.proceed",
|
|
16
14
|
};
|
|
17
|
-
async function emitUi(ui, text, toast) {
|
|
18
|
-
if (!ui)
|
|
19
|
-
return;
|
|
20
|
-
// Prefer toast (if provided) AND also inject chat (for audit trail / visibility).
|
|
21
|
-
// If you want toast-only, pass a toast function and omit ctx/sessionId.
|
|
22
|
-
if (toast && ui.toast) {
|
|
23
|
-
try {
|
|
24
|
-
await ui.toast(toast);
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// non-fatal
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
await injectChatPrompt({
|
|
32
|
-
ctx: ui.ctx,
|
|
33
|
-
sessionId: ui.sessionId,
|
|
34
|
-
text,
|
|
35
|
-
agent: ui.agentName ?? "Astro",
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
// non-fatal (workflow correctness is DB-based)
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
15
|
function tableExists(db, tableName) {
|
|
43
16
|
try {
|
|
44
17
|
const row = db
|
|
@@ -90,7 +63,7 @@ export function decideNextAction(db, config) {
|
|
|
90
63
|
if (current.status === "failed") {
|
|
91
64
|
return { kind: "failed", run_id: activeRun.run_id, stage_key: current.stage_key, error_text: current.error_text ?? "stage failed" };
|
|
92
65
|
}
|
|
93
|
-
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.
|
|
66
|
+
warn("Unexpected stage status in decideNextAction", { status: current.status, stage_key: current.stage_key });
|
|
94
67
|
return { kind: "await_stage_completion", run_id: activeRun.run_id, stage_key: current.stage_key, stage_run_id: current.stage_run_id };
|
|
95
68
|
}
|
|
96
69
|
function getPipelineFromConfig(config) {
|
|
@@ -153,149 +126,91 @@ function attachRunPlanningDirective(db, runId, story, pipeline) {
|
|
|
153
126
|
}
|
|
154
127
|
}
|
|
155
128
|
export function createRunForStory(db, config, storyKey) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
|
|
170
|
-
});
|
|
171
|
-
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
|
|
172
|
-
if (shouldAttachPlanningDirective(config, story)) {
|
|
173
|
-
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
174
|
-
}
|
|
175
|
-
db.prepare(`
|
|
176
|
-
INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
|
|
177
|
-
VALUES (1, ?, ?, ?, ?, ?, ?)
|
|
178
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
179
|
-
last_run_id=excluded.last_run_id,
|
|
180
|
-
last_story_key=excluded.last_story_key,
|
|
181
|
-
last_event_at=excluded.last_event_at,
|
|
182
|
-
updated_at=excluded.updated_at
|
|
183
|
-
`).run(SCHEMA_VERSION, now, now, run_id, storyKey, now);
|
|
184
|
-
return { run_id };
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* STAGE MOVEMENT (START) — now async so UI injection is deterministic.
|
|
189
|
-
*/
|
|
190
|
-
export async function startStage(db, runId, stageKey, meta) {
|
|
191
|
-
// Do DB work inside tx, capture what we need for UI outside.
|
|
192
|
-
const payload = withTx(db, () => {
|
|
193
|
-
const now = nowISO();
|
|
194
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
195
|
-
if (!run)
|
|
196
|
-
throw new Error(`Run not found: ${runId}`);
|
|
197
|
-
if (run.status !== "running")
|
|
198
|
-
throw new Error(`Run is not running: ${runId} (status=${run.status})`);
|
|
199
|
-
const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
|
|
200
|
-
if (!stage)
|
|
201
|
-
throw new Error(`Stage run not found: ${runId}/${stageKey}`);
|
|
202
|
-
if (stage.status !== "pending")
|
|
203
|
-
throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
|
|
204
|
-
db.prepare("UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
|
|
205
|
-
db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
|
|
206
|
-
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
|
|
207
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
208
|
-
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
|
|
209
|
-
return {
|
|
210
|
-
now,
|
|
211
|
-
story_key: run.story_key,
|
|
212
|
-
story_title: story?.title ?? "",
|
|
213
|
-
};
|
|
214
|
-
});
|
|
215
|
-
// Deterministic UI emission AFTER commit (never inside tx).
|
|
216
|
-
await emitUi(meta?.ui, [
|
|
217
|
-
`🟦 Stage started`,
|
|
218
|
-
`- Run: \`${runId}\``,
|
|
219
|
-
`- Stage: \`${stageKey}\``,
|
|
220
|
-
`- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
|
|
221
|
-
].join("\n"), {
|
|
222
|
-
title: "Stage started",
|
|
223
|
-
message: `${stageKey} (${payload.story_key})`,
|
|
224
|
-
variant: "info",
|
|
225
|
-
durationMs: 2500,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* STAGE CLOSED (RUN COMPLETED) — now async so UI injection is deterministic.
|
|
230
|
-
*/
|
|
231
|
-
export async function completeRun(db, runId, ui) {
|
|
232
|
-
const payload = withTx(db, () => {
|
|
233
|
-
const now = nowISO();
|
|
234
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
235
|
-
if (!run)
|
|
236
|
-
throw new Error(`Run not found: ${runId}`);
|
|
237
|
-
if (run.status !== "running")
|
|
238
|
-
throw new Error(`Run not running: ${runId} (status=${run.status})`);
|
|
239
|
-
const stageRuns = getStageRuns(db, runId);
|
|
240
|
-
const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
|
|
241
|
-
if (incomplete)
|
|
242
|
-
throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
|
|
243
|
-
db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
|
|
244
|
-
db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
|
|
245
|
-
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
|
|
246
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
247
|
-
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
|
|
248
|
-
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
249
|
-
});
|
|
250
|
-
await emitUi(ui, [
|
|
251
|
-
`✅ Run completed`,
|
|
252
|
-
`- Run: \`${runId}\``,
|
|
253
|
-
`- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
|
|
254
|
-
].join("\n"), {
|
|
255
|
-
title: "Run completed",
|
|
256
|
-
message: `${payload.story_key} — done`,
|
|
257
|
-
variant: "success",
|
|
258
|
-
durationMs: 3000,
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* STAGE CLOSED (RUN FAILED) — now async so UI injection is deterministic.
|
|
263
|
-
*/
|
|
264
|
-
export async function failRun(db, runId, stageKey, errorText, ui) {
|
|
265
|
-
const payload = withTx(db, () => {
|
|
266
|
-
const now = nowISO();
|
|
267
|
-
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
268
|
-
if (!run)
|
|
269
|
-
throw new Error(`Run not found: ${runId}`);
|
|
270
|
-
db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
|
|
271
|
-
db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
|
|
272
|
-
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
|
|
273
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
274
|
-
const story = db.prepare("SELECT story_key, title FROM stories WHERE story_key=?").get(run.story_key);
|
|
275
|
-
return { now, story_key: run.story_key, story_title: story?.title ?? "" };
|
|
276
|
-
});
|
|
277
|
-
await emitUi(ui, [
|
|
278
|
-
`⛔ Run failed`,
|
|
279
|
-
`- Run: \`${runId}\``,
|
|
280
|
-
`- Stage: \`${stageKey}\``,
|
|
281
|
-
`- Story: \`${payload.story_key}\` — ${payload.story_title || "(untitled)"}`,
|
|
282
|
-
`- Error: ${errorText}`,
|
|
283
|
-
].join("\n"), {
|
|
284
|
-
title: "Run failed",
|
|
285
|
-
message: `${stageKey}: ${errorText}`,
|
|
286
|
-
variant: "error",
|
|
287
|
-
durationMs: 4500,
|
|
129
|
+
const story = getStory(db, storyKey);
|
|
130
|
+
if (!story)
|
|
131
|
+
throw new Error(`Story not found: ${storyKey}`);
|
|
132
|
+
if (story.state !== "approved")
|
|
133
|
+
throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
|
|
134
|
+
const run_id = newRunId();
|
|
135
|
+
const now = nowISO();
|
|
136
|
+
const pipeline = getPipelineFromConfig(config);
|
|
137
|
+
db.prepare("UPDATE stories SET state='in_progress', in_progress=1, locked_by_run_id=?, locked_at=?, updated_at=? WHERE story_key=?").run(run_id, now, now, storyKey);
|
|
138
|
+
db.prepare("INSERT INTO runs (run_id, story_key, status, pipeline_stages_json, current_stage_key, created_at, started_at, updated_at) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)").run(run_id, storyKey, JSON.stringify(pipeline), pipeline[0] ?? null, now, now, now);
|
|
139
|
+
const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
|
|
140
|
+
pipeline.forEach((stageKey, idx) => {
|
|
141
|
+
insertStage.run(newStageRunId(), run_id, stageKey, idx, now, now);
|
|
288
142
|
});
|
|
143
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), run_id, EVENT_TYPES.RUN_STARTED, JSON.stringify({ story_key: storyKey, pipeline }), now);
|
|
144
|
+
if (shouldAttachPlanningDirective(config, story)) {
|
|
145
|
+
attachRunPlanningDirective(db, run_id, story, pipeline);
|
|
146
|
+
}
|
|
147
|
+
db.prepare(`
|
|
148
|
+
INSERT INTO repo_state (id, schema_version, created_at, updated_at, last_run_id, last_story_key, last_event_at)
|
|
149
|
+
VALUES (1, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
151
|
+
last_run_id=excluded.last_run_id,
|
|
152
|
+
last_story_key=excluded.last_story_key,
|
|
153
|
+
last_event_at=excluded.last_event_at,
|
|
154
|
+
updated_at=excluded.updated_at
|
|
155
|
+
`).run(SCHEMA_VERSION, now, now, now, run_id, storyKey, now);
|
|
156
|
+
return { run_id };
|
|
157
|
+
}
|
|
158
|
+
export function startStage(db, runId, stageKey, meta, emit) {
|
|
159
|
+
const now = nowISO();
|
|
160
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
161
|
+
if (!run)
|
|
162
|
+
throw new Error(`Run not found: ${runId}`);
|
|
163
|
+
if (run.status !== "running")
|
|
164
|
+
throw new Error(`Run is not running: ${runId} (status=${run.status})`);
|
|
165
|
+
const stage = db.prepare("SELECT * FROM stage_runs WHERE run_id=? AND stage_key=?").get(runId, stageKey);
|
|
166
|
+
if (!stage)
|
|
167
|
+
throw new Error(`Stage run not found: ${runId}/${stageKey}`);
|
|
168
|
+
if (stage.status !== "pending")
|
|
169
|
+
throw new Error(`Stage is not pending: ${stageKey} (status=${stage.status})`);
|
|
170
|
+
db.prepare("UPDATE stage_runs SET status='running', started_at=?, updated_at=?, subagent_type=COALESCE(?, subagent_type), subagent_session_id=COALESCE(?, subagent_session_id) WHERE stage_run_id=?").run(now, now, meta?.subagent_type ?? null, meta?.subagent_session_id ?? null, stage.stage_run_id);
|
|
171
|
+
db.prepare("UPDATE runs SET current_stage_key=?, updated_at=? WHERE run_id=?").run(stageKey, now, runId);
|
|
172
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.STAGE_STARTED, JSON.stringify({ subagent_type: meta?.subagent_type ?? null }), now);
|
|
173
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
174
|
+
// ✅ Explicit wiring point (requested): stage movement
|
|
175
|
+
emit?.({ kind: "stage_started", run_id: runId, stage_key: stageKey, agent_name: meta?.subagent_type });
|
|
176
|
+
}
|
|
177
|
+
export function completeRun(db, runId, emit) {
|
|
178
|
+
const now = nowISO();
|
|
179
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
180
|
+
if (!run)
|
|
181
|
+
throw new Error(`Run not found: ${runId}`);
|
|
182
|
+
if (run.status !== "running")
|
|
183
|
+
throw new Error(`Run not running: ${runId} (status=${run.status})`);
|
|
184
|
+
const stageRuns = getStageRuns(db, runId);
|
|
185
|
+
const incomplete = stageRuns.find((s) => s.status !== "completed" && s.status !== "skipped");
|
|
186
|
+
if (incomplete)
|
|
187
|
+
throw new Error(`Cannot complete run: stage ${incomplete.stage_key} is ${incomplete.status}`);
|
|
188
|
+
db.prepare("UPDATE runs SET status='completed', completed_at=?, updated_at=?, current_stage_key=NULL WHERE run_id=?").run(now, now, runId);
|
|
189
|
+
db.prepare("UPDATE stories SET state='done', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
|
|
190
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_COMPLETED, JSON.stringify({ story_key: run.story_key }), now);
|
|
191
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
192
|
+
// ✅ Explicit wiring point (requested): run closed success
|
|
193
|
+
emit?.({ kind: "run_completed", run_id: runId, story_key: run.story_key });
|
|
194
|
+
}
|
|
195
|
+
export function failRun(db, runId, stageKey, errorText, emit) {
|
|
196
|
+
const now = nowISO();
|
|
197
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
198
|
+
if (!run)
|
|
199
|
+
throw new Error(`Run not found: ${runId}`);
|
|
200
|
+
db.prepare("UPDATE runs SET status='failed', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(errorText, now, now, runId);
|
|
201
|
+
db.prepare("UPDATE stories SET state='blocked', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
|
|
202
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)").run(newEventId(), runId, stageKey, EVENT_TYPES.RUN_FAILED, JSON.stringify({ error_text: errorText }), now);
|
|
203
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
204
|
+
// ✅ Explicit wiring point (requested): run closed failure
|
|
205
|
+
emit?.({ kind: "run_failed", run_id: runId, story_key: run.story_key, stage_key: stageKey, error_text: errorText });
|
|
289
206
|
}
|
|
290
207
|
export function abortRun(db, runId, reason) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
300
|
-
});
|
|
208
|
+
const now = nowISO();
|
|
209
|
+
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
|
|
210
|
+
if (!run)
|
|
211
|
+
throw new Error(`Run not found: ${runId}`);
|
|
212
|
+
db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
|
|
213
|
+
db.prepare("UPDATE stories SET state='approved', in_progress=0, locked_by_run_id=NULL, locked_at=NULL, updated_at=? WHERE story_key=?").run(now, run.story_key);
|
|
214
|
+
db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, ?, NULL, ?, ?, ?)").run(newEventId(), runId, EVENT_TYPES.RUN_ABORTED, JSON.stringify({ reason }), now);
|
|
215
|
+
db.prepare("UPDATE repo_state SET last_event_at=?, updated_at=? WHERE id=1").run(now, now);
|
|
301
216
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { createToastManager, type ToastOptions } from "./ui/toasts";
|
|
|
13
13
|
import { createAstroAgents } from "./agents/registry";
|
|
14
14
|
import type { AgentConfig } from "@opencode-ai/sdk";
|
|
15
15
|
import { info, warn } from "./shared/log";
|
|
16
|
+
import { acquireRepoLock } from "./state/repo-lock";
|
|
16
17
|
|
|
17
18
|
// Type definitions for plugin components
|
|
18
19
|
type ConfigHandler = (config: Record<string, any>) => Promise<void>;
|
|
@@ -58,6 +59,10 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
58
59
|
}
|
|
59
60
|
const repoRoot = ctx.directory;
|
|
60
61
|
|
|
62
|
+
// Acquire exclusive repo lock to prevent multiple processes from corrupting the database
|
|
63
|
+
const lockPath = `${repoRoot}/.astro/astro.lock`;
|
|
64
|
+
const repoLock = acquireRepoLock(lockPath);
|
|
65
|
+
|
|
61
66
|
// Always load config first - this provides defaults even in limited mode
|
|
62
67
|
let pluginConfig: AstrocodeConfig;
|
|
63
68
|
try {
|
|
@@ -325,6 +330,9 @@ const Astrocode: Plugin = async (ctx) => {
|
|
|
325
330
|
|
|
326
331
|
// Best-effort cleanup
|
|
327
332
|
close: async () => {
|
|
333
|
+
// Release repo lock first (important for process termination)
|
|
334
|
+
repoLock.release();
|
|
335
|
+
|
|
328
336
|
if (db && typeof db.close === "function") {
|
|
329
337
|
try {
|
|
330
338
|
db.close();
|