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/ui/inject.js CHANGED
@@ -1,118 +1,95 @@
1
- // src/ui/inject.ts
2
- //
3
- // Deterministic chat injection:
4
- // - Always enqueue
5
- // - Process sequentially (per-process single worker)
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((resolve) => setTimeout(resolve, ms));
7
+ return new Promise((r) => setTimeout(r, ms));
17
8
  }
18
- function resolveDrainWaitersIfIdle() {
19
- if (workerRunning)
20
- return;
21
- if (injectionQueue.length !== 0)
22
- return;
23
- const waiters = drainWaiters;
24
- drainWaiters = [];
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 sendWithRetries(opts) {
33
- const { ctx, sessionId, text } = opts;
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
- if (!sessionId) {
37
- console.warn("[Astrocode] Injection skipped: missing sessionId");
38
- return;
39
- }
40
- const prompt = getPromptApi(ctx);
41
- if (!prompt) {
42
- console.warn("[Astrocode] Injection skipped: ctx.client.session.prompt unavailable");
43
- return;
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 runWorkerLoop() {
74
- if (workerRunning)
30
+ async function runSessionQueue(sessionId) {
31
+ if (running.has(sessionId))
75
32
  return;
76
- workerRunning = true;
33
+ running.add(sessionId);
77
34
  try {
78
- // Drain sequentially to preserve ordering
79
- while (injectionQueue.length > 0) {
80
- const item = injectionQueue.shift();
81
- if (!item)
82
- continue;
83
- await sendWithRetries(item);
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
- workerRunning = false;
88
- resolveDrainWaitersIfIdle();
64
+ running.delete(sessionId);
89
65
  }
90
66
  }
91
67
  /**
92
- * Enqueue an injection and ensure the worker is running.
93
- * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
94
- */
95
- export function enqueueChatPrompt(opts) {
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
- enqueueChatPrompt(opts);
117
- await flushChatPrompts();
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
- * UI HOOKS
16
- * --------
17
- * This workflow module is DB-first. UI emission is optional and happens AFTER the DB tx commits.
18
- *
19
- * Contract:
20
- * - If you pass ui.ctx + ui.sessionId, we will inject a visible chat message deterministically.
21
- * - If you pass ui.toast, we will also toast (throttling is handled by the toast manager).
22
- */
23
- export type WorkflowUi = {
24
- ctx: any;
25
- sessionId: string;
26
- agentName?: string;
27
- toast?: (t: ToastOptions) => Promise<void>;
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
- * - Never mutate story title/body.
33
- * - Planning-first becomes a run-scoped inject stored in `injects` (scope = run:<run_id>).
34
- * - Trigger is deterministic via config.workflow.genesis_planning:
35
- * - "off" | "first_story_only" | "always"
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
- ui?: WorkflowUi;
77
- }): Promise<void>;
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.status });
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
- return withTx(db, () => {
157
- const story = getStory(db, storyKey);
158
- if (!story)
159
- throw new Error(`Story not found: ${storyKey}`);
160
- if (story.state !== "approved")
161
- throw new Error(`Story must be approved to run: ${storyKey} (state=${story.state})`);
162
- const run_id = newRunId();
163
- const now = nowISO();
164
- const pipeline = getPipelineFromConfig(config);
165
- 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);
166
- 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);
167
- const insertStage = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
168
- pipeline.forEach((stageKey, idx) => {
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
- return withTx(db, () => {
292
- const now = nowISO();
293
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(runId);
294
- if (!run)
295
- throw new Error(`Run not found: ${runId}`);
296
- db.prepare("UPDATE runs SET status='aborted', error_text=?, updated_at=?, completed_at=? WHERE run_id=?").run(reason, now, now, runId);
297
- 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);
298
- 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);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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();