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 +6 -0
- package/dist/state/repo-lock.d.ts +3 -0
- package/dist/state/repo-lock.js +29 -0
- package/dist/tools/workflow.js +178 -168
- package/package.json +1 -1
- package/src/index.ts +8 -0
- package/src/state/repo-lock.ts +26 -0
- package/src/tools/workflow.ts +12 -2
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,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
|
+
}
|
package/dist/tools/workflow.js
CHANGED
|
@@ -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
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
agentName
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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:
|
|
282
|
-
next_action: `
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
347
|
+
return lines.join("\n").trim();
|
|
324
348
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
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
|
+
}
|
package/src/tools/workflow.ts
CHANGED
|
@@ -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
|
-
|
|
188
|
-
const
|
|
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
|
}
|