astrocode-workflow 0.2.1 → 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 CHANGED
@@ -9,6 +9,7 @@ import { createInjectProvider } from "./hooks/inject-provider";
9
9
  import { createToastManager } from "./ui/toasts";
10
10
  import { createAstroAgents } from "./agents/registry";
11
11
  import { info, warn } from "./shared/log";
12
+ import { acquireRepoLock } from "./state/repo-lock";
12
13
  // Safe config cloning with structuredClone preference (fallback for older Node versions)
13
14
  // CONTRACT: Config is guaranteed JSON-serializable (enforced by loadAstrocodeConfig validation)
14
15
  const cloneConfig = (v) => {
@@ -37,6 +38,9 @@ const Astrocode = async (ctx) => {
37
38
  throw new Error("Astrocode requires ctx.directory to be a string repo root.");
38
39
  }
39
40
  const repoRoot = ctx.directory;
41
+ // Acquire exclusive repo lock to prevent multiple processes from corrupting the database
42
+ const lockPath = `${repoRoot}/.astro/astro.lock`;
43
+ const repoLock = acquireRepoLock(lockPath);
40
44
  // Always load config first - this provides defaults even in limited mode
41
45
  let pluginConfig;
42
46
  try {
@@ -280,6 +284,8 @@ const Astrocode = async (ctx) => {
280
284
  },
281
285
  // Best-effort cleanup
282
286
  close: async () => {
287
+ // Release repo lock first (important for process termination)
288
+ repoLock.release();
283
289
  if (db && typeof db.close === "function") {
284
290
  try {
285
291
  db.close();
@@ -0,0 +1,3 @@
1
+ export declare function acquireRepoLock(lockPath: string): {
2
+ release: () => void;
3
+ };
@@ -0,0 +1,29 @@
1
+ // src/state/repo-lock.ts
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ export function acquireRepoLock(lockPath) {
5
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
6
+ let fd;
7
+ try {
8
+ fd = fs.openSync(lockPath, "wx"); // exclusive create
9
+ }
10
+ catch (e) {
11
+ const msg = e?.code === "EEXIST"
12
+ ? `Astrocode lock is already held (${lockPath}). Another opencode process is running in this repo.`
13
+ : `Failed to acquire lock (${lockPath}): ${e?.message ?? String(e)}`;
14
+ throw new Error(msg);
15
+ }
16
+ fs.writeFileSync(fd, `${process.pid}\n`, "utf8");
17
+ return {
18
+ release: () => {
19
+ try {
20
+ fs.closeSync(fd);
21
+ }
22
+ catch { }
23
+ try {
24
+ fs.unlinkSync(lockPath);
25
+ }
26
+ catch { }
27
+ },
28
+ };
29
+ }
@@ -9,6 +9,7 @@ import { nowISO } from "../shared/time";
9
9
  import { newEventId } from "../state/ids";
10
10
  import { debug } from "../shared/log";
11
11
  import { createToastManager } from "../ui/toasts";
12
+ import { acquireRepoLock } from "../state/repo-lock";
12
13
  // Agent name mapping for case-sensitive resolution
13
14
  export const STAGE_TO_AGENT_MAP = {
14
15
  frame: "Frame",
@@ -155,191 +156,200 @@ export function createAstroWorkflowProceedTool(opts) {
155
156
  max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
156
157
  },
157
158
  execute: async ({ mode, max_steps }) => {
158
- const sessionId = ctx.sessionID;
159
- const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
160
- const actions = [];
161
- const warnings = [];
162
- const startedAt = nowISO();
163
- // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
164
- const uiEvents = [];
165
- const emit = (e) => uiEvents.push(e);
166
- for (let i = 0; i < steps; i++) {
167
- const next = decideNextAction(db, config);
168
- if (next.kind === "idle") {
169
- actions.push("idle: no approved stories");
170
- break;
171
- }
172
- if (next.kind === "start_run") {
173
- // SINGLE tx boundary: caller owns tx, state-machine is pure.
174
- const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
175
- actions.push(`started run ${run_id} for story ${next.story_key}`);
176
- if (mode === "step")
177
- break;
178
- continue;
179
- }
180
- if (next.kind === "complete_run") {
181
- withTx(db, () => completeRun(db, next.run_id, emit));
182
- actions.push(`completed run ${next.run_id}`);
183
- if (mode === "step")
159
+ // Acquire repo lock to ensure no concurrent workflow operations
160
+ const lockPath = `${ctx.directory}/.astro/astro.lock`;
161
+ const repoLock = acquireRepoLock(lockPath);
162
+ try {
163
+ const sessionId = ctx.sessionID;
164
+ const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
165
+ const actions = [];
166
+ const warnings = [];
167
+ const startedAt = nowISO();
168
+ // Collect UI events emitted inside state-machine functions, then flush AFTER tx.
169
+ const uiEvents = [];
170
+ const emit = (e) => uiEvents.push(e);
171
+ for (let i = 0; i < steps; i++) {
172
+ const next = decideNextAction(db, config);
173
+ if (next.kind === "idle") {
174
+ actions.push("idle: no approved stories");
184
175
  break;
185
- continue;
186
- }
187
- if (next.kind === "failed") {
188
- // Ensure DB state reflects failure in one tx; emit UI event.
189
- withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
190
- actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
191
- if (mode === "step")
192
- break;
193
- continue;
194
- }
195
- if (next.kind === "delegate_stage") {
196
- const active = getActiveRun(db);
197
- if (!active)
198
- throw new Error("Invariant: delegate_stage but no active run.");
199
- const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
200
- const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
201
- let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
202
- const agentExists = (name) => {
203
- if (agents && agents[name])
204
- return true;
205
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
206
- if (knownStageAgents.includes(name))
207
- return true;
208
- return false;
209
- };
210
- if (!agentExists(agentName)) {
211
- const originalAgent = agentName;
212
- console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
213
- agentName = config.agents?.orchestrator_name || "Astro";
176
+ }
177
+ if (next.kind === "start_run") {
178
+ // SINGLE tx boundary: caller owns tx, state-machine is pure.
179
+ const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
180
+ actions.push(`started run ${run_id} for story ${next.story_key}`);
181
+ if (mode === "step")
182
+ break;
183
+ continue;
184
+ }
185
+ if (next.kind === "complete_run") {
186
+ withTx(db, () => completeRun(db, next.run_id, emit));
187
+ actions.push(`completed run ${next.run_id}`);
188
+ if (mode === "step")
189
+ break;
190
+ continue;
191
+ }
192
+ if (next.kind === "failed") {
193
+ // Ensure DB state reflects failure in one tx; emit UI event.
194
+ withTx(db, () => failRun(db, next.run_id, next.stage_key, next.error_text, emit));
195
+ actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
196
+ if (mode === "step")
197
+ break;
198
+ continue;
199
+ }
200
+ if (next.kind === "delegate_stage") {
201
+ const active = getActiveRun(db);
202
+ if (!active)
203
+ throw new Error("Invariant: delegate_stage but no active run.");
204
+ const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
205
+ const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
206
+ let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
207
+ const agentExists = (name) => {
208
+ if (agents && agents[name])
209
+ return true;
210
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
211
+ if (knownStageAgents.includes(name))
212
+ return true;
213
+ return false;
214
+ };
214
215
  if (!agentExists(agentName)) {
215
- console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
216
- agentName = "General";
216
+ const originalAgent = agentName;
217
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
218
+ agentName = config.agents?.orchestrator_name || "Astro";
217
219
  if (!agentExists(agentName)) {
218
- throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
220
+ console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
221
+ agentName = "General";
222
+ if (!agentExists(agentName)) {
223
+ throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
224
+ }
219
225
  }
220
226
  }
221
- }
222
- // NOTE: startStage owns its own tx (state-machine.ts).
223
- withTx(db, () => {
224
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
225
- });
226
- const context = buildContextSnapshot({
227
- db,
228
- config,
229
- run_id: active.run_id,
230
- next_action: `delegate stage ${next.stage_key}`,
231
- });
232
- const stageDirective = buildStageDirective({
233
- config,
234
- stage_key: next.stage_key,
235
- run_id: active.run_id,
236
- story_key: run.story_key,
237
- story_title: story?.title ?? "(missing)",
238
- stage_agent_name: agentName,
239
- stage_goal: stageGoal(next.stage_key, config),
240
- stage_constraints: stageConstraints(next.stage_key, config),
241
- context_snapshot_md: context,
242
- }).body;
243
- const delegatePrompt = buildDelegationPrompt({
244
- stageDirective,
245
- run_id: active.run_id,
246
- stage_key: next.stage_key,
247
- stage_agent_name: agentName,
248
- });
249
- // Record continuation (best-effort; no tx wrapper needed but safe either way)
250
- const h = directiveHash(delegatePrompt);
251
- const now = nowISO();
252
- if (sessionId) {
253
- // This assumes continuations table exists in vNext schema.
254
- db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)").run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
255
- }
256
- // Visible injection so user can see state (awaited)
257
- if (sessionId) {
258
- await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
259
- const continueMessage = [
260
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
261
- ``,
262
- `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
263
- ``,
264
- `When \`${agentName}\` completes, call:`,
265
- `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
266
- ``,
267
- `This advances the workflow.`,
268
- ].join("\n");
269
- await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
270
- }
271
- actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
272
- // Stop here; subagent needs to run.
273
- break;
274
- }
275
- if (next.kind === "await_stage_completion") {
276
- actions.push(`await stage completion: ${next.stage_key}`);
277
- if (sessionId) {
227
+ // NOTE: startStage owns its own tx (state-machine.ts).
228
+ withTx(db, () => {
229
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName }, emit);
230
+ });
278
231
  const context = buildContextSnapshot({
279
232
  db,
280
233
  config,
281
- run_id: next.run_id,
282
- next_action: `complete stage ${next.stage_key}`,
234
+ run_id: active.run_id,
235
+ next_action: `delegate stage ${next.stage_key}`,
236
+ });
237
+ const stageDirective = buildStageDirective({
238
+ config,
239
+ stage_key: next.stage_key,
240
+ run_id: active.run_id,
241
+ story_key: run.story_key,
242
+ story_title: story?.title ?? "(missing)",
243
+ stage_agent_name: agentName,
244
+ stage_goal: stageGoal(next.stage_key, config),
245
+ stage_constraints: stageConstraints(next.stage_key, config),
246
+ context_snapshot_md: context,
247
+ }).body;
248
+ const delegatePrompt = buildDelegationPrompt({
249
+ stageDirective,
250
+ run_id: active.run_id,
251
+ stage_key: next.stage_key,
252
+ stage_agent_name: agentName,
283
253
  });
284
- const prompt = [
285
- `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
286
- ``,
287
- `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
288
- `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
289
- ``,
290
- `Context snapshot:`,
291
- context,
292
- ].join("\n").trim();
293
- const h = directiveHash(prompt);
254
+ // Record continuation (best-effort; no tx wrapper needed but safe either way)
255
+ const h = directiveHash(delegatePrompt);
294
256
  const now = nowISO();
295
- db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)").run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
296
- await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
257
+ if (sessionId) {
258
+ // This assumes continuations table exists in vNext schema.
259
+ db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)").run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
260
+ }
261
+ // Visible injection so user can see state (awaited)
262
+ if (sessionId) {
263
+ await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
264
+ const continueMessage = [
265
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
266
+ ``,
267
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
268
+ ``,
269
+ `When \`${agentName}\` completes, call:`,
270
+ `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
271
+ ``,
272
+ `This advances the workflow.`,
273
+ ].join("\n");
274
+ await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
275
+ }
276
+ actions.push(`delegated stage ${next.stage_key} via ${agentName}`);
277
+ // Stop here; subagent needs to run.
278
+ break;
279
+ }
280
+ if (next.kind === "await_stage_completion") {
281
+ actions.push(`await stage completion: ${next.stage_key}`);
282
+ if (sessionId) {
283
+ const context = buildContextSnapshot({
284
+ db,
285
+ config,
286
+ run_id: next.run_id,
287
+ next_action: `complete stage ${next.stage_key}`,
288
+ });
289
+ const prompt = [
290
+ `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
291
+ ``,
292
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
293
+ `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
294
+ ``,
295
+ `Context snapshot:`,
296
+ context,
297
+ ].join("\n").trim();
298
+ const h = directiveHash(prompt);
299
+ const now = nowISO();
300
+ db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)").run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
301
+ await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
302
+ }
303
+ break;
297
304
  }
305
+ actions.push(`unhandled next action: ${next.kind}`);
298
306
  break;
299
307
  }
300
- actions.push(`unhandled next action: ${next.kind}`);
301
- break;
302
- }
303
- // Flush UI events (toast + prompt) AFTER state transitions
304
- if (uiEvents.length > 0) {
305
- for (const e of uiEvents) {
306
- const msg = buildUiMessage(e);
307
- if (config.ui.toasts.enabled) {
308
- await toasts.show({
309
- title: msg.title,
310
- message: msg.message,
311
- variant: msg.variant,
312
- });
313
- }
314
- if (ctx?.sessionID) {
315
- await injectChatPrompt({
316
- ctx,
317
- sessionId: ctx.sessionID,
318
- text: msg.chatText,
319
- agent: "Astro",
320
- });
308
+ // Flush UI events (toast + prompt) AFTER state transitions
309
+ if (uiEvents.length > 0) {
310
+ for (const e of uiEvents) {
311
+ const msg = buildUiMessage(e);
312
+ if (config.ui.toasts.enabled) {
313
+ await toasts.show({
314
+ title: msg.title,
315
+ message: msg.message,
316
+ variant: msg.variant,
317
+ });
318
+ }
319
+ if (ctx?.sessionID) {
320
+ await injectChatPrompt({
321
+ ctx,
322
+ sessionId: ctx.sessionID,
323
+ text: msg.chatText,
324
+ agent: "Astro",
325
+ });
326
+ }
321
327
  }
328
+ actions.push(`ui: flushed ${uiEvents.length} event(s)`);
329
+ }
330
+ // Housekeeping event
331
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)").run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
332
+ const active = getActiveRun(db);
333
+ const lines = [];
334
+ lines.push(`# astro_workflow_proceed`);
335
+ lines.push(`- mode: ${mode}`);
336
+ lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
337
+ if (active)
338
+ lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
339
+ lines.push(``, `## Actions`);
340
+ for (const a of actions)
341
+ lines.push(`- ${a}`);
342
+ if (warnings.length > 0) {
343
+ lines.push(``, `## Warnings`);
344
+ for (const w of warnings)
345
+ lines.push(`⚠️ ${w}`);
322
346
  }
323
- actions.push(`ui: flushed ${uiEvents.length} event(s)`);
347
+ return lines.join("\n").trim();
324
348
  }
325
- // Housekeeping event
326
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)").run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
327
- const active = getActiveRun(db);
328
- const lines = [];
329
- lines.push(`# astro_workflow_proceed`);
330
- lines.push(`- mode: ${mode}`);
331
- lines.push(`- steps requested: ${max_steps} (cap=${steps})`);
332
- if (active)
333
- lines.push(`- active run: \`${active.run_id}\` (stage=${active.current_stage_key ?? "?"})`);
334
- lines.push(``, `## Actions`);
335
- for (const a of actions)
336
- lines.push(`- ${a}`);
337
- if (warnings.length > 0) {
338
- lines.push(``, `## Warnings`);
339
- for (const w of warnings)
340
- lines.push(`⚠️ ${w}`);
349
+ finally {
350
+ // Always release the lock
351
+ repoLock.release();
341
352
  }
342
- return lines.join("\n").trim();
343
353
  },
344
354
  });
345
355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astrocode-workflow",
3
- "version": "0.2.1",
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();
@@ -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
+ }
@@ -22,6 +22,7 @@ import { newEventId } from "../state/ids";
22
22
  import { debug } from "../shared/log";
23
23
  import { createToastManager } from "../ui/toasts";
24
24
  import type { AgentConfig } from "@opencode-ai/sdk";
25
+ import { acquireRepoLock } from "../state/repo-lock";
25
26
 
26
27
  // Agent name mapping for case-sensitive resolution
27
28
  export const STAGE_TO_AGENT_MAP: Record<string, string> = {
@@ -184,8 +185,13 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
184
185
  max_steps: tool.schema.number().int().positive().default(config.workflow.default_max_steps),
185
186
  },
186
187
  execute: async ({ mode, max_steps }) => {
187
- const sessionId = (ctx as any).sessionID as string | undefined;
188
- 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);
189
195
 
190
196
  const actions: string[] = [];
191
197
  const warnings: string[] = [];
@@ -408,6 +414,10 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
408
414
  }
409
415
 
410
416
  return lines.join("\n").trim();
417
+ } finally {
418
+ // Always release the lock
419
+ repoLock.release();
420
+ }
411
421
  },
412
422
  });
413
423
  }