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/src/state/db.ts CHANGED
@@ -48,7 +48,32 @@ export function configurePragmas(db: SqliteDb, pragmas: Record<string, any>) {
48
48
  if (pragmas.temp_store) db.pragma(`temp_store = ${pragmas.temp_store}`);
49
49
  }
50
50
 
51
- /** BEGIN IMMEDIATE transaction helper. */
51
+ /**
52
+ * Re-entrant transaction helper.
53
+ *
54
+ * SQLite rejects BEGIN inside BEGIN. We use:
55
+ * - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
56
+ * - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
57
+ *
58
+ * This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
59
+ * without "cannot start a transaction within a transaction".
60
+ */
61
+ const TX_DEPTH = new WeakMap<object, number>();
62
+
63
+ function getDepth(db: SqliteDb): number {
64
+ return TX_DEPTH.get(db as any) ?? 0;
65
+ }
66
+
67
+ function setDepth(db: SqliteDb, depth: number) {
68
+ if (depth <= 0) TX_DEPTH.delete(db as any);
69
+ else TX_DEPTH.set(db as any, depth);
70
+ }
71
+
72
+ function savepointName(depth: number): string {
73
+ return `sp_${depth}`;
74
+ }
75
+
76
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
52
77
  export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean }): T {
53
78
  const adapter = createDatabaseAdapter();
54
79
  const available = adapter.isAvailable();
@@ -58,18 +83,52 @@ export function withTx<T>(db: SqliteDb, fn: () => T, opts?: { require?: boolean
58
83
  return fn();
59
84
  }
60
85
 
61
- db.exec("BEGIN IMMEDIATE");
86
+ const depth = getDepth(db);
87
+
88
+ if (depth === 0) {
89
+ db.exec("BEGIN IMMEDIATE");
90
+ setDepth(db, 1);
91
+ try {
92
+ const out = fn();
93
+ db.exec("COMMIT");
94
+ return out;
95
+ } catch (e) {
96
+ try {
97
+ db.exec("ROLLBACK");
98
+ } catch {
99
+ // ignore
100
+ }
101
+ throw e;
102
+ } finally {
103
+ setDepth(db, 0);
104
+ }
105
+ }
106
+
107
+ // Nested: use SAVEPOINT
108
+ const nextDepth = depth + 1;
109
+ const sp = savepointName(nextDepth);
110
+
111
+ db.exec(`SAVEPOINT ${sp}`);
112
+ setDepth(db, nextDepth);
113
+
62
114
  try {
63
115
  const out = fn();
64
- db.exec("COMMIT");
116
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
65
117
  return out;
66
118
  } catch (e) {
67
119
  try {
68
- db.exec("ROLLBACK");
120
+ db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
121
+ } catch {
122
+ // ignore
123
+ }
124
+ try {
125
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
69
126
  } catch {
70
127
  // ignore
71
128
  }
72
129
  throw e;
130
+ } finally {
131
+ setDepth(db, depth);
73
132
  }
74
133
  }
75
134
 
@@ -0,0 +1,26 @@
1
+ // src/state/repo-lock.ts
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export function acquireRepoLock(lockPath: string): { release: () => void } {
6
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
7
+
8
+ let fd: number;
9
+ try {
10
+ fd = fs.openSync(lockPath, "wx"); // exclusive create
11
+ } catch (e: any) {
12
+ const msg = e?.code === "EEXIST"
13
+ ? `Astrocode lock is already held (${lockPath}). Another opencode process is running in this repo.`
14
+ : `Failed to acquire lock (${lockPath}): ${e?.message ?? String(e)}`;
15
+ throw new Error(msg);
16
+ }
17
+
18
+ fs.writeFileSync(fd, `${(process as any).pid}\n`, "utf8");
19
+
20
+ return {
21
+ release: () => {
22
+ try { fs.closeSync(fd); } catch {}
23
+ try { fs.unlinkSync(lockPath); } catch {}
24
+ },
25
+ };
26
+ }
@@ -1,16 +1,28 @@
1
+ // src/tools/workflow.ts
1
2
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
3
  import type { AstrocodeConfig } from "../config/schema";
3
4
  import type { SqliteDb } from "../state/db";
4
5
  import { withTx } from "../state/db";
5
6
  import type { StageKey } from "../state/types";
7
+ import type { UiEmitEvent } from "../workflow/state-machine";
6
8
  import { buildContextSnapshot } from "../workflow/context";
7
- import { decideNextAction, createRunForStory, startStage, completeRun, getActiveRun, EVENT_TYPES } from "../workflow/state-machine";
9
+ import {
10
+ decideNextAction,
11
+ createRunForStory,
12
+ startStage,
13
+ completeRun,
14
+ failRun,
15
+ getActiveRun,
16
+ EVENT_TYPES,
17
+ } from "../workflow/state-machine";
8
18
  import { buildStageDirective, directiveHash } from "../workflow/directives";
9
19
  import { injectChatPrompt } from "../ui/inject";
10
20
  import { nowISO } from "../shared/time";
11
21
  import { newEventId } from "../state/ids";
12
22
  import { debug } from "../shared/log";
13
23
  import { createToastManager } from "../ui/toasts";
24
+ import type { AgentConfig } from "@opencode-ai/sdk";
25
+ import { acquireRepoLock } from "../state/repo-lock";
14
26
 
15
27
  // Agent name mapping for case-sensitive resolution
16
28
  export const STAGE_TO_AGENT_MAP: Record<string, string> = {
@@ -37,23 +49,17 @@ export function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, ag
37
49
  // Validate that the agent actually exists in the registry
38
50
  if (agents && !agents[candidate]) {
39
51
  const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
40
- if (warnings) {
41
- warnings.push(warning);
42
- } else {
43
- console.warn(`[Astrocode] ${warning}`);
44
- }
52
+ if (warnings) warnings.push(warning);
53
+ else console.warn(`[Astrocode] ${warning}`);
45
54
  candidate = "General";
46
55
  }
47
56
 
48
57
  // Final guard: ensure General exists, fallback to built-in "general" if not
49
58
  if (agents && !agents[candidate]) {
50
59
  const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
51
- if (warnings) {
52
- warnings.push(finalWarning);
53
- } else {
54
- console.warn(`[Astrocode] ${finalWarning}`);
55
- }
56
- return "general"; // built-in, guaranteed by OpenCode
60
+ if (warnings) warnings.push(finalWarning);
61
+ else console.warn(`[Astrocode] ${finalWarning}`);
62
+ return "general";
57
63
  }
58
64
 
59
65
  return candidate;
@@ -94,10 +100,6 @@ function stageConstraints(stage: StageKey, cfg: AstrocodeConfig): string[] {
94
100
  return common;
95
101
  }
96
102
 
97
- function agentNameForStage(stage: StageKey, cfg: AstrocodeConfig): string {
98
- return cfg.agents.stage_agent_names[stage];
99
- }
100
-
101
103
  function buildDelegationPrompt(opts: {
102
104
  stageDirective: string;
103
105
  run_id: string;
@@ -126,13 +128,50 @@ function buildDelegationPrompt(opts: {
126
128
  `Important: do NOT do any stage work yourself in orchestrator mode.`,
127
129
  ].join("\n").trim();
128
130
 
129
- // Debug log the delegation prompt to troubleshoot agent output issues
130
131
  debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
131
-
132
132
  return prompt;
133
133
  }
134
134
 
135
- import { AgentConfig } from "@opencode-ai/sdk";
135
+ function buildUiMessage(e: UiEmitEvent): { title: string; message: string; variant: "info" | "success" | "error"; chatText: string } {
136
+ switch (e.kind) {
137
+ case "stage_started": {
138
+ const agent = e.agent_name ? ` (${e.agent_name})` : "";
139
+ const title = "Astrocode";
140
+ const message = `Stage started: ${e.stage_key}${agent}`;
141
+ const chatText = [
142
+ `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
143
+ ``,
144
+ `Run: ${e.run_id}`,
145
+ `Stage: ${e.stage_key}${agent}`,
146
+ ].join("\n");
147
+ return { title, message, variant: "info", chatText };
148
+ }
149
+ case "run_completed": {
150
+ const title = "Astrocode";
151
+ const message = `Run completed: ${e.run_id}`;
152
+ const chatText = [
153
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
154
+ ``,
155
+ `Run: ${e.run_id}`,
156
+ `Story: ${e.story_key}`,
157
+ ].join("\n");
158
+ return { title, message, variant: "success", chatText };
159
+ }
160
+ case "run_failed": {
161
+ const title = "Astrocode";
162
+ const message = `Run failed: ${e.run_id} (${e.stage_key})`;
163
+ const chatText = [
164
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
165
+ ``,
166
+ `Run: ${e.run_id}`,
167
+ `Story: ${e.story_key}`,
168
+ `Stage: ${e.stage_key}`,
169
+ `Error: ${e.error_text}`,
170
+ ].join("\n");
171
+ return { title, message, variant: "error", chatText };
172
+ }
173
+ }
174
+ }
136
175
 
137
176
  export function createAstroWorkflowProceedTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb; agents?: Record<string, AgentConfig> }): ToolDefinition {
138
177
  const { ctx, config, db, agents } = opts;
@@ -146,13 +185,22 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
146
185
  max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
147
186
  },
148
187
  execute: async ({ mode, max_steps }) => {
149
- const sessionId = (ctx as any).sessionID as string | undefined;
150
- const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
188
+ // Acquire repo lock to ensure no concurrent workflow operations
189
+ const lockPath = `${(ctx as any).directory}/.astro/astro.lock`;
190
+ const repoLock = acquireRepoLock(lockPath);
191
+
192
+ try {
193
+ const sessionId = (ctx as any).sessionID as string | undefined;
194
+ const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
151
195
 
152
196
  const actions: string[] = [];
153
197
  const warnings: string[] = [];
154
198
  const startedAt = nowISO();
155
199
 
200
+ // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
201
+ const uiEvents: UiEmitEvent[] = [];
202
+ const emit = (e: UiEmitEvent) => uiEvents.push(e);
203
+
156
204
  for (let i = 0; i < steps; i++) {
157
205
  const next = decideNextAction(db, config);
158
206
 
@@ -162,72 +210,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
162
210
  }
163
211
 
164
212
  if (next.kind === "start_run") {
213
+ // SINGLE tx boundary: caller owns tx, state-machine is pure.
165
214
  const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
166
215
  actions.push(`started run ${run_id} for story ${next.story_key}`);
167
216
 
168
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
169
- await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
170
- }
171
-
172
217
  if (mode === "step") break;
173
218
  continue;
174
219
  }
175
220
 
176
221
  if (next.kind === "complete_run") {
177
- withTx(db, () => completeRun(db, next.run_id));
222
+ withTx(db, () => completeRun(db, next.run_id, emit));
178
223
  actions.push(`completed run ${next.run_id}`);
179
224
 
180
- if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
181
- await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
182
- }
183
-
184
- // Inject continuation directive for workflow resumption
185
- if (sessionId) {
186
- const continueDirective = [
187
- `[SYSTEM DIRECTIVE: ASTROCODE — CONTINUE]`,
188
- ``,
189
- `Run ${next.run_id} completed successfully.`,
190
- ``,
191
- `The Clara Forms implementation run has finished all stages. The spec has been analyzed and decomposed into prioritized implementation stories.`,
192
- ``,
193
- `Next actions: Review the generated stories and approve the next one to continue development.`,
194
- ].join("\n");
195
-
196
- await injectChatPrompt({
197
- ctx,
198
- sessionId,
199
- text: continueDirective,
200
- agent: "Astro"
201
- });
202
-
203
- actions.push(`injected continuation directive for completed run ${next.run_id}`);
204
- }
205
-
206
- // Check for next approved story to start
207
- const nextStory = db.prepare(
208
- "SELECT story_key, title FROM stories WHERE state = 'approved' ORDER BY priority DESC, created_at ASC LIMIT 1"
209
- ).get() as { story_key: string; title: string } | undefined;
210
-
211
- if (nextStory && sessionId) {
212
- const nextDirective = [
213
- `[SYSTEM DIRECTIVE: ASTROCODE — START_NEXT_STORY]`,
214
- ``,
215
- `The previous run completed successfully. Start the next approved story.`,
216
- ``,
217
- `Next Story: ${nextStory.story_key} — ${nextStory.title}`,
218
- ``,
219
- `Action: Call astro_story_approve with story_key="${nextStory.story_key}" to start it, or select a different story.`,
220
- ].join("\n");
221
-
222
- await injectChatPrompt({
223
- ctx,
224
- sessionId,
225
- text: nextDirective,
226
- agent: "Astro"
227
- });
225
+ if (mode === "step") break;
226
+ continue;
227
+ }
228
228
 
229
- actions.push(`injected directive to start next story ${nextStory.story_key}`);
230
- }
229
+ if (next.kind === "failed") {
230
+ // Ensure DB state reflects failure in one tx; emit UI event.
231
+ withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
232
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
231
233
 
232
234
  if (mode === "step") break;
233
235
  continue;
@@ -236,62 +238,45 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
236
238
  if (next.kind === "delegate_stage") {
237
239
  const active = getActiveRun(db);
238
240
  if (!active) throw new Error("Invariant: delegate_stage but no active run.");
241
+
239
242
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id) as any;
240
243
  const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
241
244
 
242
- // Mark stage started + set subagent_type to the stage agent.
243
245
  let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
244
246
 
245
- // Validate agent availability with fallback chain
246
- const systemConfig = config as any;
247
- // Check both the system config agent map (if present) OR the local agents map passed to the tool
248
247
  const agentExists = (name: string) => {
249
- // Check local agents map first (populated from src/index.ts)
250
- if (agents && agents[name]) {
251
- return true;
252
- }
253
- // Check system config agent map
254
- if (systemConfig.agent && systemConfig.agent[name]) {
255
- return true;
256
- }
257
- // For known stage agents, assume they exist (they are system-provided subagents)
258
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close"];
259
- if (knownStageAgents.includes(name)) {
260
- return true;
261
- }
262
- return false;
248
+ if (agents && agents[name]) return true;
249
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
250
+ if (knownStageAgents.includes(name)) return true;
251
+ return false;
263
252
  };
264
253
 
254
+ if (!agentExists(agentName)) {
255
+ const originalAgent = agentName;
256
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
257
+ agentName = config.agents?.orchestrator_name || "Astro";
265
258
  if (!agentExists(agentName)) {
266
- const originalAgent = agentName;
267
- console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
268
- // First fallback: orchestrator
269
- agentName = config.agents?.orchestrator_name || "Astro";
259
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
260
+ agentName = "General";
270
261
  if (!agentExists(agentName)) {
271
- console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
272
- // Final fallback: General (guaranteed to exist)
273
- agentName = "General";
274
- if (!agentExists(agentName)) {
275
- throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
276
- }
262
+ throw new Error(
263
+ `Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`
264
+ );
277
265
  }
278
266
  }
267
+ }
279
268
 
269
+ // NOTE: startStage owns its own tx (state-machine.ts).
280
270
  withTx(db, () => {
281
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
282
-
283
- // Log delegation observability
284
- if (config.debug?.telemetry?.enabled) {
285
- // eslint-disable-next-line no-console
286
- console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
287
- }
271
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
288
272
  });
289
273
 
290
- if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
291
- await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
292
- }
293
-
294
- const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: `delegate stage ${next.stage_key}` });
274
+ const context = buildContextSnapshot({
275
+ db,
276
+ config,
277
+ run_id: active.run_id,
278
+ next_action: `delegate stage ${next.stage_key}`,
279
+ });
295
280
 
296
281
  const stageDirective = buildStageDirective({
297
282
  config,
@@ -312,35 +297,35 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
312
297
  stage_agent_name: agentName,
313
298
  });
314
299
 
315
- // Record in continuations as a stage directive (dedupe by hash)
300
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
316
301
  const h = directiveHash(delegatePrompt);
317
302
  const now = nowISO();
318
303
  if (sessionId) {
304
+ // This assumes continuations table exists in vNext schema.
319
305
  db.prepare(
320
306
  "INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)"
321
307
  ).run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
322
308
  }
323
309
 
324
- // Visible injection so user can see state
325
- if (sessionId) {
326
- await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
310
+ // Visible injection so user can see state (awaited)
311
+ if (sessionId) {
312
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
327
313
 
328
- // Inject continuation guidance
329
- const continueMessage = [
330
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
331
- ``,
332
- `Stage ${next.stage_key} delegated to ${agentName}.`,
333
- ``,
334
- `When ${agentName} completes, call:`,
335
- `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
336
- ``,
337
- `This advances the workflow.`,
338
- ].join("\n");
314
+ const continueMessage = [
315
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
316
+ ``,
317
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
318
+ ``,
319
+ `When \`${agentName}\` completes, call:`,
320
+ `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
321
+ ``,
322
+ `This advances the workflow.`,
323
+ ].join("\n");
339
324
 
340
- await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
341
- }
325
+ await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
326
+ }
342
327
 
343
- actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
328
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
344
329
 
345
330
  // Stop here; subagent needs to run.
346
331
  break;
@@ -348,18 +333,25 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
348
333
 
349
334
  if (next.kind === "await_stage_completion") {
350
335
  actions.push(`await stage completion: ${next.stage_key}`);
351
- // Optionally nudge with a short directive
336
+
352
337
  if (sessionId) {
353
- const context = buildContextSnapshot({ db, config, run_id: next.run_id, next_action: `complete stage ${next.stage_key}` });
338
+ const context = buildContextSnapshot({
339
+ db,
340
+ config,
341
+ run_id: next.run_id,
342
+ next_action: `complete stage ${next.stage_key}`,
343
+ });
344
+
354
345
  const prompt = [
355
346
  `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
356
347
  ``,
357
- `Run ${next.run_id} is waiting for stage ${next.stage_key} output.`,
348
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
358
349
  `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
359
350
  ``,
360
351
  `Context snapshot:`,
361
352
  context,
362
353
  ].join("\n").trim();
354
+
363
355
  const h = directiveHash(prompt);
364
356
  const now = nowISO();
365
357
  db.prepare(
@@ -368,19 +360,40 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
368
360
 
369
361
  await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
370
362
  }
371
- break;
372
- }
373
363
 
374
- if (next.kind === "failed") {
375
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
376
364
  break;
377
365
  }
378
366
 
379
- // safety
380
367
  actions.push(`unhandled next action: ${(next as any).kind}`);
381
368
  break;
382
369
  }
383
370
 
371
+ // Flush UI events (toast + prompt) AFTER state transitions
372
+ if (uiEvents.length > 0) {
373
+ for (const e of uiEvents) {
374
+ const msg = buildUiMessage(e);
375
+
376
+ if (config.ui.toasts.enabled) {
377
+ await toasts.show({
378
+ title: msg.title,
379
+ message: msg.message,
380
+ variant: msg.variant,
381
+ });
382
+ }
383
+
384
+ if ((ctx as any)?.sessionID) {
385
+ await injectChatPrompt({
386
+ ctx,
387
+ sessionId: (ctx as any).sessionID,
388
+ text: msg.chatText,
389
+ agent: "Astro",
390
+ });
391
+ }
392
+ }
393
+
394
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
395
+ }
396
+
384
397
  // Housekeeping event
385
398
  db.prepare(
386
399
  "INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)"
@@ -401,6 +414,10 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
401
414
  }
402
415
 
403
416
  return lines.join("\n").trim();
417
+ } finally {
418
+ // Always release the lock
419
+ repoLock.release();
420
+ }
404
421
  },
405
422
  });
406
423
  }