astrocode-workflow 0.3.5 → 0.4.1
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/src/config/schema.d.ts +1 -0
- package/dist/src/config/schema.js +1 -0
- package/dist/src/hooks/inject-provider.js +72 -13
- package/dist/src/state/repo-lock.d.ts +33 -0
- package/dist/src/state/repo-lock.js +97 -19
- package/dist/src/state/schema.d.ts +2 -2
- package/dist/src/state/schema.js +8 -1
- package/dist/src/state/workflow-repo-lock.d.ts +7 -0
- package/dist/src/state/workflow-repo-lock.js +45 -12
- package/dist/src/tools/index.js +3 -0
- package/dist/src/tools/lock.d.ts +4 -0
- package/dist/src/tools/lock.js +78 -0
- package/dist/src/tools/repair.js +40 -6
- package/dist/src/tools/workflow.js +1 -0
- package/dist/src/workflow/repair.js +2 -2
- package/package.json +1 -2
- package/src/config/schema.ts +1 -0
- package/src/hooks/inject-provider.ts +80 -16
- package/src/state/repo-lock.ts +129 -22
- package/src/state/schema.ts +8 -1
- package/src/state/workflow-repo-lock.ts +49 -12
- package/src/tools/index.ts +3 -0
- package/src/tools/lock.ts +75 -0
- package/src/tools/repair.ts +43 -6
- package/src/tools/workflow.ts +1 -0
- package/src/workflow/repair.ts +2 -2
package/dist/src/tools/repair.js
CHANGED
|
@@ -1,25 +1,59 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
3
|
import { withTx } from "../state/db";
|
|
3
4
|
import { repairState, formatRepairReport } from "../workflow/repair";
|
|
4
5
|
import { putArtifact } from "../workflow/artifacts";
|
|
5
6
|
import { nowISO } from "../shared/time";
|
|
7
|
+
import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
|
|
6
8
|
export function createAstroRepairTool(opts) {
|
|
7
9
|
const { ctx, config, db } = opts;
|
|
8
10
|
return tool({
|
|
9
|
-
description: "Repair Astrocode invariants and recover from inconsistent DB state. Writes a repair report artifact.",
|
|
11
|
+
description: "Repair Astrocode invariants and recover from inconsistent DB state. Also checks and repairs lock files. Writes a repair report artifact.",
|
|
10
12
|
args: {
|
|
11
13
|
write_report_artifact: tool.schema.boolean().default(true),
|
|
14
|
+
repair_lock: tool.schema.boolean().default(true).describe("Attempt to remove stale/dead lock files"),
|
|
12
15
|
},
|
|
13
|
-
execute: async ({ write_report_artifact }) => {
|
|
16
|
+
execute: async ({ write_report_artifact, repair_lock }) => {
|
|
14
17
|
const repoRoot = ctx.directory;
|
|
18
|
+
const lockPath = path.join(repoRoot, ".astro", "astro.lock");
|
|
19
|
+
// First, check and repair lock if requested
|
|
20
|
+
const lockLines = [];
|
|
21
|
+
const lockStatus = getLockStatus(lockPath);
|
|
22
|
+
if (lockStatus.exists) {
|
|
23
|
+
lockLines.push("## Lock Status");
|
|
24
|
+
lockLines.push(`- Lock found: ${lockPath}`);
|
|
25
|
+
lockLines.push(`- PID: ${lockStatus.pid} (${lockStatus.pidAlive ? 'alive' : 'dead'})`);
|
|
26
|
+
lockLines.push(`- Age: ${lockStatus.ageMs ? Math.floor(lockStatus.ageMs / 1000) : '?'}s`);
|
|
27
|
+
lockLines.push(`- Status: ${lockStatus.isStale ? 'stale' : 'fresh'}`);
|
|
28
|
+
if (repair_lock) {
|
|
29
|
+
const result = tryRemoveStaleLock(lockPath);
|
|
30
|
+
if (result.removed) {
|
|
31
|
+
lockLines.push(`- **Removed**: ${result.reason}`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
lockLines.push(`- **Not removed**: ${result.reason}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
if (!lockStatus.pidAlive || lockStatus.isStale) {
|
|
39
|
+
lockLines.push(`- **Recommendation**: Run with repair_lock=true to remove this ${!lockStatus.pidAlive ? 'dead' : 'stale'} lock`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
lockLines.push("");
|
|
43
|
+
}
|
|
44
|
+
// Then repair database state
|
|
15
45
|
const report = withTx(db, () => repairState(db, config));
|
|
16
|
-
const
|
|
46
|
+
const dbMd = formatRepairReport(report);
|
|
47
|
+
// Combine lock and DB repair
|
|
48
|
+
const fullMd = lockLines.length > 0
|
|
49
|
+
? `# Astrocode Repair Report\n\n${lockLines.join("\n")}\n${dbMd.replace(/^# Astrocode repair report\n*/i, "")}`
|
|
50
|
+
: dbMd;
|
|
17
51
|
if (write_report_artifact) {
|
|
18
52
|
const rel = `.astro/repair/repair_${nowISO().replace(/[:.]/g, "-")}.md`;
|
|
19
|
-
const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content:
|
|
20
|
-
return
|
|
53
|
+
const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: fullMd, meta: { kind: "repair" } });
|
|
54
|
+
return fullMd + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
|
|
21
55
|
}
|
|
22
|
-
return
|
|
56
|
+
return fullMd;
|
|
23
57
|
},
|
|
24
58
|
});
|
|
25
59
|
}
|
|
@@ -166,6 +166,7 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
166
166
|
repoRoot,
|
|
167
167
|
sessionId,
|
|
168
168
|
owner: "astro_workflow_proceed",
|
|
169
|
+
advisory: true, // Advisory mode: warn instead of blocking on lock contention
|
|
169
170
|
fn: async () => {
|
|
170
171
|
const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
|
|
171
172
|
const actions = [];
|
|
@@ -43,10 +43,10 @@ export function repairState(db, config) {
|
|
|
43
43
|
.all(active.run_id);
|
|
44
44
|
if (stageRuns.length < pipeline.length) {
|
|
45
45
|
const existingKeys = new Set(stageRuns.map((s) => s.stage_key));
|
|
46
|
-
const insert = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, updated_at) VALUES (?, ?, ?, ?, 'pending', ?)");
|
|
46
|
+
const insert = db.prepare("INSERT INTO stage_runs (stage_run_id, run_id, stage_key, stage_index, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)");
|
|
47
47
|
pipeline.forEach((key, idx) => {
|
|
48
48
|
if (!existingKeys.has(key)) {
|
|
49
|
-
insert.run(newStageRunId(), active.run_id, key, idx, now);
|
|
49
|
+
insert.run(newStageRunId(), active.run_id, key, idx, now, now);
|
|
50
50
|
push(report, `Inserted missing stage_run ${key} for run ${active.run_id}`);
|
|
51
51
|
}
|
|
52
52
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astrocode-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@opencode-ai/plugin": "^1.1.19",
|
|
22
22
|
"@opencode-ai/sdk": "^1.1.19",
|
|
23
|
-
"astrocode-workflow": "^0.1.51",
|
|
24
23
|
"jsonc-parser": "^3.2.0",
|
|
25
24
|
"zod": "4.1.8"
|
|
26
25
|
},
|
package/src/config/schema.ts
CHANGED
|
@@ -203,6 +203,7 @@ const InjectSchema = z
|
|
|
203
203
|
scope_allowlist: z.array(z.string()).default(["repo", "global"]),
|
|
204
204
|
type_allowlist: z.array(z.string()).default(["note", "policy"]),
|
|
205
205
|
max_per_turn: z.number().int().positive().default(5),
|
|
206
|
+
auto_approve_queued_stories: z.boolean().default(false).describe("Auto-approve highest priority queued story after workflow tools"),
|
|
206
207
|
})
|
|
207
208
|
.partial()
|
|
208
209
|
.default({});
|
|
@@ -29,19 +29,30 @@ export function createInjectProvider(opts: {
|
|
|
29
29
|
const { db } = runtime;
|
|
30
30
|
|
|
31
31
|
// Cache to avoid re-injecting the same injects repeatedly
|
|
32
|
+
// Map of inject_id -> last injected timestamp
|
|
32
33
|
const injectedCache = new Map<string, number>();
|
|
33
34
|
|
|
34
35
|
function shouldSkipInject(injectId: string, nowMs: number): boolean {
|
|
35
36
|
const lastInjected = injectedCache.get(injectId);
|
|
36
37
|
if (!lastInjected) return false;
|
|
37
38
|
|
|
38
|
-
//
|
|
39
|
-
|
|
39
|
+
// REDUCED cooldown from 5 minutes to 1 minute
|
|
40
|
+
// This allows injects to appear more frequently during workflow
|
|
41
|
+
const cooldownMs = 1 * 60 * 1000;
|
|
40
42
|
return nowMs - lastInjected < cooldownMs;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
function markInjected(injectId: string, nowMs: number) {
|
|
44
46
|
injectedCache.set(injectId, nowMs);
|
|
47
|
+
|
|
48
|
+
// Clean up old entries to prevent memory leak
|
|
49
|
+
// Remove entries older than 10 minutes
|
|
50
|
+
const tenMinutesAgo = nowMs - (10 * 60 * 1000);
|
|
51
|
+
for (const [id, timestamp] of injectedCache.entries()) {
|
|
52
|
+
if (timestamp < tenMinutesAgo) {
|
|
53
|
+
injectedCache.delete(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
function getInjectionDiagnostics(nowIso: string, scopeAllowlist: string[], typeAllowlist: string[]): any {
|
|
@@ -94,7 +105,7 @@ export function createInjectProvider(opts: {
|
|
|
94
105
|
};
|
|
95
106
|
}
|
|
96
107
|
|
|
97
|
-
async function injectEligibleInjects(sessionId: string) {
|
|
108
|
+
async function injectEligibleInjects(sessionId: string, context?: string) {
|
|
98
109
|
const now = nowISO();
|
|
99
110
|
const nowMs = Date.now();
|
|
100
111
|
|
|
@@ -120,7 +131,7 @@ export function createInjectProvider(opts: {
|
|
|
120
131
|
// Log when no injects are eligible
|
|
121
132
|
if (EMIT_TELEMETRY) {
|
|
122
133
|
// eslint-disable-next-line no-console
|
|
123
|
-
console.log(`[Astrocode:inject] ${now}
|
|
134
|
+
console.log(`[Astrocode:inject] ${now} context=${context ?? 'unknown'} selected=0 injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
124
135
|
}
|
|
125
136
|
return;
|
|
126
137
|
}
|
|
@@ -135,21 +146,68 @@ export function createInjectProvider(opts: {
|
|
|
135
146
|
// Format as injection message
|
|
136
147
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
137
148
|
|
|
149
|
+
try {
|
|
150
|
+
await injectChatPrompt({
|
|
151
|
+
ctx,
|
|
152
|
+
sessionId,
|
|
153
|
+
text: formattedText,
|
|
154
|
+
agent: "Astrocode"
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
injected++;
|
|
158
|
+
markInjected(inject.inject_id, nowMs);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Log injection failures but don't crash
|
|
161
|
+
// eslint-disable-next-line no-console
|
|
162
|
+
console.error(`[Astrocode:inject] Failed to inject ${inject.inject_id}:`, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Log diagnostic summary
|
|
167
|
+
if (EMIT_TELEMETRY || injected > 0) {
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.log(`[Astrocode:inject] ${now} context=${context ?? 'unknown'} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Workflow-related tools that should trigger inject + auto-approval
|
|
174
|
+
const WORKFLOW_TOOLS = new Set([
|
|
175
|
+
'astro_workflow_proceed',
|
|
176
|
+
'astro_story_queue',
|
|
177
|
+
'astro_story_approve',
|
|
178
|
+
'astro_stage_start',
|
|
179
|
+
'astro_stage_complete',
|
|
180
|
+
'astro_stage_fail',
|
|
181
|
+
'astro_run_abort',
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
// Auto-approve queued stories if enabled
|
|
185
|
+
async function maybeAutoApprove(sessionId: string) {
|
|
186
|
+
if (!config.inject?.auto_approve_queued_stories) return;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// Get all queued stories
|
|
190
|
+
const queued = db.prepare("SELECT story_key, title FROM stories WHERE state='queued' ORDER BY priority DESC, created_at ASC").all() as Array<{ story_key: string; title: string }>;
|
|
191
|
+
|
|
192
|
+
if (queued.length === 0) return;
|
|
193
|
+
|
|
194
|
+
// Auto-approve the highest priority queued story
|
|
195
|
+
const story = queued[0];
|
|
196
|
+
db.prepare("UPDATE stories SET state='approved', updated_at=? WHERE story_key=?").run(nowISO(), story.story_key);
|
|
197
|
+
|
|
198
|
+
// eslint-disable-next-line no-console
|
|
199
|
+
console.log(`[Astrocode:inject] Auto-approved story ${story.story_key}: ${story.title}`);
|
|
200
|
+
|
|
201
|
+
// Inject a notification about the auto-approval
|
|
138
202
|
await injectChatPrompt({
|
|
139
203
|
ctx,
|
|
140
204
|
sessionId,
|
|
141
|
-
text:
|
|
205
|
+
text: `✅ Auto-approved story ${story.story_key}: ${story.title}`,
|
|
142
206
|
agent: "Astrocode"
|
|
143
207
|
});
|
|
144
|
-
|
|
145
|
-
injected++;
|
|
146
|
-
markInjected(inject.inject_id, nowMs);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Log diagnostic summary
|
|
150
|
-
if (EMIT_TELEMETRY) {
|
|
208
|
+
} catch (err) {
|
|
151
209
|
// eslint-disable-next-line no-console
|
|
152
|
-
console.
|
|
210
|
+
console.error(`[Astrocode:inject] Auto-approval failed:`, err);
|
|
153
211
|
}
|
|
154
212
|
}
|
|
155
213
|
|
|
@@ -159,18 +217,24 @@ export function createInjectProvider(opts: {
|
|
|
159
217
|
if (!config.inject?.enabled) return;
|
|
160
218
|
|
|
161
219
|
// Inject eligible injects before processing the user's message
|
|
162
|
-
await injectEligibleInjects(input.sessionID);
|
|
220
|
+
await injectEligibleInjects(input.sessionID, 'chat_message');
|
|
163
221
|
},
|
|
164
222
|
|
|
165
223
|
async onToolAfter(input: ToolExecuteAfterInput) {
|
|
166
224
|
if (!config.inject?.enabled) return;
|
|
167
225
|
|
|
226
|
+
// Only inject after workflow-related tools
|
|
227
|
+
if (!WORKFLOW_TOOLS.has(input.tool)) return;
|
|
228
|
+
|
|
168
229
|
// Extract sessionID (same pattern as continuation enforcer)
|
|
169
230
|
const sessionId = input.sessionID ?? (ctx as any).sessionID;
|
|
170
231
|
if (!sessionId) return;
|
|
171
232
|
|
|
172
|
-
//
|
|
173
|
-
await
|
|
233
|
+
// Auto-approve queued stories if enabled
|
|
234
|
+
await maybeAutoApprove(sessionId);
|
|
235
|
+
|
|
236
|
+
// Inject eligible injects after workflow tool execution
|
|
237
|
+
await injectEligibleInjects(sessionId, `tool_after:${input.tool}`);
|
|
174
238
|
},
|
|
175
239
|
};
|
|
176
240
|
}
|
package/src/state/repo-lock.ts
CHANGED
|
@@ -272,29 +272,36 @@ function startHeartbeat(opts: {
|
|
|
272
272
|
const shouldAttempt = now - lastWriteAt >= opts.minWriteMs;
|
|
273
273
|
|
|
274
274
|
if (shouldAttempt) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
275
|
+
try {
|
|
276
|
+
const existing = readLock(opts.lockPath);
|
|
277
|
+
|
|
278
|
+
if (
|
|
279
|
+
existing &&
|
|
280
|
+
existing.lease_id === opts.leaseId &&
|
|
281
|
+
existing.pid === (process as any).pid &&
|
|
282
|
+
existing.instance_id === PROCESS_INSTANCE_ID
|
|
283
|
+
) {
|
|
284
|
+
const updatedMs = parseISOToMs(existing.updated_at);
|
|
285
|
+
const isFresh = updatedMs !== null && now - updatedMs < opts.minWriteMs;
|
|
286
|
+
|
|
287
|
+
if (!isFresh) {
|
|
288
|
+
writeLockAtomicish(opts.lockPath, {
|
|
289
|
+
...existing,
|
|
290
|
+
updated_at: nowISO(),
|
|
291
|
+
repo_root: opts.repoRoot,
|
|
292
|
+
session_id: opts.sessionId ?? existing.session_id,
|
|
293
|
+
owner: opts.owner ?? existing.owner,
|
|
294
|
+
});
|
|
295
|
+
lastWriteAt = now;
|
|
296
|
+
} else {
|
|
297
|
+
lastWriteAt = now;
|
|
298
|
+
}
|
|
297
299
|
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
// Heartbeat write failed - don't propagate, just reschedule
|
|
302
|
+
// Lock will become stale if heartbeat continues failing
|
|
303
|
+
// eslint-disable-next-line no-console
|
|
304
|
+
console.warn("[Astrocode] Heartbeat write error:", err);
|
|
298
305
|
}
|
|
299
306
|
}
|
|
300
307
|
|
|
@@ -340,6 +347,18 @@ function installExitHookOnce() {
|
|
|
340
347
|
cleanup();
|
|
341
348
|
(process as any).exit(143);
|
|
342
349
|
});
|
|
350
|
+
(process as any).once("uncaughtException", (err: any) => {
|
|
351
|
+
// eslint-disable-next-line no-console
|
|
352
|
+
console.error("[Astrocode] Uncaught Exception, cleaning up locks:", err);
|
|
353
|
+
cleanup();
|
|
354
|
+
(process as any).exit(1);
|
|
355
|
+
});
|
|
356
|
+
(process as any).once("unhandledRejection", (reason: any) => {
|
|
357
|
+
// eslint-disable-next-line no-console
|
|
358
|
+
console.error("[Astrocode] Unhandled Rejection, cleaning up locks:", reason);
|
|
359
|
+
cleanup();
|
|
360
|
+
(process as any).exit(1);
|
|
361
|
+
});
|
|
343
362
|
}
|
|
344
363
|
|
|
345
364
|
/**
|
|
@@ -596,4 +615,92 @@ export async function withRepoLock<T>(opts: {
|
|
|
596
615
|
} finally {
|
|
597
616
|
handle.release();
|
|
598
617
|
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Lock diagnostics and status information.
|
|
622
|
+
*/
|
|
623
|
+
export type LockStatus = {
|
|
624
|
+
exists: boolean;
|
|
625
|
+
path: string;
|
|
626
|
+
pid?: number;
|
|
627
|
+
pidAlive?: boolean;
|
|
628
|
+
instanceId?: string;
|
|
629
|
+
sessionId?: string;
|
|
630
|
+
owner?: string;
|
|
631
|
+
leaseId?: string;
|
|
632
|
+
createdAt?: string;
|
|
633
|
+
updatedAt?: string;
|
|
634
|
+
ageMs?: number;
|
|
635
|
+
isStale?: boolean;
|
|
636
|
+
repoRoot?: string;
|
|
637
|
+
version?: number;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Get lock file status and diagnostics.
|
|
642
|
+
* Returns detailed information about the current lock state.
|
|
643
|
+
*/
|
|
644
|
+
export function getLockStatus(lockPath: string, staleMs: number = 30_000): LockStatus {
|
|
645
|
+
const existing = readLock(lockPath);
|
|
646
|
+
|
|
647
|
+
if (!existing) {
|
|
648
|
+
return {
|
|
649
|
+
exists: false,
|
|
650
|
+
path: lockPath,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const updatedMs = parseISOToMs(existing.updated_at);
|
|
655
|
+
const ageMs = updatedMs !== null ? Date.now() - updatedMs : undefined;
|
|
656
|
+
const pidAlive = isPidAlive(existing.pid);
|
|
657
|
+
const isStale = isStaleByAge(existing, staleMs);
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
exists: true,
|
|
661
|
+
path: lockPath,
|
|
662
|
+
pid: existing.pid,
|
|
663
|
+
pidAlive,
|
|
664
|
+
instanceId: existing.instance_id,
|
|
665
|
+
sessionId: existing.session_id,
|
|
666
|
+
owner: existing.owner,
|
|
667
|
+
leaseId: existing.lease_id,
|
|
668
|
+
createdAt: existing.created_at,
|
|
669
|
+
updatedAt: existing.updated_at,
|
|
670
|
+
ageMs,
|
|
671
|
+
isStale,
|
|
672
|
+
repoRoot: existing.repo_root,
|
|
673
|
+
version: existing.v,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Attempt to remove a lock file if it's safe to do so.
|
|
679
|
+
* Only removes locks with dead PIDs or stale timestamps.
|
|
680
|
+
* Returns true if lock was removed, false if lock is still held.
|
|
681
|
+
*/
|
|
682
|
+
export function tryRemoveStaleLock(lockPath: string, staleMs: number = 30_000): { removed: boolean; reason: string } {
|
|
683
|
+
const existing = readLock(lockPath);
|
|
684
|
+
|
|
685
|
+
if (!existing) {
|
|
686
|
+
return { removed: false, reason: "No lock file found" };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const pidAlive = isPidAlive(existing.pid);
|
|
690
|
+
const isStale = isStaleByAge(existing, staleMs);
|
|
691
|
+
|
|
692
|
+
if (!pidAlive) {
|
|
693
|
+
safeUnlink(lockPath);
|
|
694
|
+
fsyncDirBestEffort(path.dirname(lockPath));
|
|
695
|
+
return { removed: true, reason: `Dead PID ${existing.pid}` };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (isStale) {
|
|
699
|
+
safeUnlink(lockPath);
|
|
700
|
+
fsyncDirBestEffort(path.dirname(lockPath));
|
|
701
|
+
const ageSeconds = Math.floor((Date.now() - (parseISOToMs(existing.updated_at) ?? 0)) / 1000);
|
|
702
|
+
return { removed: true, reason: `Stale lock (${ageSeconds}s old, threshold ${staleMs / 1000}s)` };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return { removed: false, reason: `Lock is active (PID ${existing.pid} alive, age ${Math.floor((Date.now() - (parseISOToMs(existing.updated_at) ?? 0)) / 1000)}s)` };
|
|
599
706
|
}
|
package/src/state/schema.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Source of truth: SQLite file at .astro/astro.db
|
|
6
6
|
|
|
7
|
-
export const SCHEMA_VERSION =
|
|
7
|
+
export const SCHEMA_VERSION = 3; // v3: Added advisory lock support + database constraints
|
|
8
8
|
|
|
9
9
|
export const SCHEMA_SQL = `
|
|
10
10
|
PRAGMA foreign_keys = ON;
|
|
@@ -235,6 +235,13 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
|
|
|
235
235
|
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
|
|
236
236
|
CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
|
|
237
237
|
CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
|
|
238
|
+
|
|
239
|
+
-- CONSTRAINT: Only one running run at a time (partial unique index)
|
|
240
|
+
-- This provides database-level safety when using advisory locks
|
|
241
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_single_running_run ON runs(status) WHERE status = 'running';
|
|
242
|
+
|
|
243
|
+
-- CONSTRAINT: Only one run can lock a story at a time (partial unique index)
|
|
244
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_single_story_lock ON stories(in_progress) WHERE in_progress = 1;
|
|
238
245
|
CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
|
|
239
246
|
CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
|
|
240
247
|
CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
|
|
@@ -17,6 +17,12 @@ function key(lockPath: string, sessionId?: string) {
|
|
|
17
17
|
/**
|
|
18
18
|
* Acquire ONCE per workflow/session in this process.
|
|
19
19
|
* Nested calls reuse the same held lock (no reacquire, no churn).
|
|
20
|
+
*
|
|
21
|
+
* ADVISORY LOCK MODE:
|
|
22
|
+
* - Creates lock file to signal other sessions
|
|
23
|
+
* - If lock held by another session: WARN and proceed anyway
|
|
24
|
+
* - Database constraints provide actual safety (single running run)
|
|
25
|
+
* - Better UX: no blocking, just helpful warnings
|
|
20
26
|
*/
|
|
21
27
|
export async function workflowRepoLock<T>(
|
|
22
28
|
deps: { acquireRepoLock: RepoLockAcquire },
|
|
@@ -26,6 +32,7 @@ export async function workflowRepoLock<T>(
|
|
|
26
32
|
sessionId?: string;
|
|
27
33
|
owner?: string;
|
|
28
34
|
fn: () => Promise<T>;
|
|
35
|
+
advisory?: boolean; // If true, warn instead of error on contention
|
|
29
36
|
}
|
|
30
37
|
): Promise<T> {
|
|
31
38
|
const k = key(opts.lockPath, opts.sessionId);
|
|
@@ -45,19 +52,49 @@ export async function workflowRepoLock<T>(
|
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
// IMPORTANT: this is tuned for "hold for whole workflow".
|
|
48
|
-
|
|
49
|
-
lockPath: opts.lockPath,
|
|
50
|
-
repoRoot: opts.repoRoot,
|
|
51
|
-
sessionId: opts.sessionId,
|
|
52
|
-
owner: opts.owner,
|
|
55
|
+
let handle: { release: () => void } | null = null;
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
try {
|
|
58
|
+
handle = await deps.acquireRepoLock({
|
|
59
|
+
lockPath: opts.lockPath,
|
|
60
|
+
repoRoot: opts.repoRoot,
|
|
61
|
+
sessionId: opts.sessionId,
|
|
62
|
+
owner: opts.owner,
|
|
63
|
+
|
|
64
|
+
retryMs: opts.advisory ? 1000 : 30_000, // Advisory: fail fast, hard: wait longer
|
|
65
|
+
staleMs: 30_000, // Reduced from 2 minutes to 30 seconds for faster stale lock recovery
|
|
66
|
+
heartbeatMs: 200,
|
|
67
|
+
minWriteMs: 800,
|
|
68
|
+
pollMs: 20,
|
|
69
|
+
pollMaxMs: 250,
|
|
70
|
+
});
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
// Lock acquisition failed - check if advisory mode
|
|
73
|
+
if (opts.advisory) {
|
|
74
|
+
// Advisory mode: warn and proceed without lock
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.warn(`⚠️ [Astrocode] Another session may be active. Proceeding anyway (advisory lock mode).`);
|
|
77
|
+
// eslint-disable-next-line no-console
|
|
78
|
+
console.warn(` ${err.message}`);
|
|
79
|
+
|
|
80
|
+
// Proceed without lock - database constraints will ensure safety
|
|
81
|
+
try {
|
|
82
|
+
return await opts.fn();
|
|
83
|
+
} catch (dbErr: any) {
|
|
84
|
+
// Check if this is a concurrency error
|
|
85
|
+
if (dbErr.message?.includes('UNIQUE constraint') || dbErr.message?.includes('SQLITE_BUSY')) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Another session is actively working on this story. Database prevented concurrent modification. ` +
|
|
88
|
+
`Please wait for the other session to complete, or work on a different story.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
throw dbErr;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Hard lock mode: propagate error
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
61
98
|
|
|
62
99
|
const held: Held = { release: handle.release, depth: 1 };
|
|
63
100
|
HELD_BY_KEY.set(k, held);
|
package/src/tools/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createAstroRepairTool } from "./repair";
|
|
|
15
15
|
import { createAstroHealthTool } from "./health";
|
|
16
16
|
import { createAstroResetTool } from "./reset";
|
|
17
17
|
import { createAstroMetricsTool } from "./metrics";
|
|
18
|
+
import { createAstroLockStatusTool } from "./lock";
|
|
18
19
|
|
|
19
20
|
import { AgentConfig } from "@opencode-ai/sdk";
|
|
20
21
|
|
|
@@ -44,6 +45,7 @@ export function createAstroTools(opts: CreateAstroToolsOptions): Record<string,
|
|
|
44
45
|
tools.astro_health = createAstroHealthTool({ ctx, config, db });
|
|
45
46
|
tools.astro_reset = createAstroResetTool({ ctx, config, db });
|
|
46
47
|
tools.astro_metrics = createAstroMetricsTool({ ctx, config });
|
|
48
|
+
tools.astro_lock_status = createAstroLockStatusTool({ ctx });
|
|
47
49
|
|
|
48
50
|
// Recovery tool - available even in limited mode to allow DB initialization
|
|
49
51
|
tools.astro_init = createAstroInitTool({ ctx, config, runtime });
|
|
@@ -109,6 +111,7 @@ export function createAstroTools(opts: CreateAstroToolsOptions): Record<string,
|
|
|
109
111
|
["_astro_health", "astro_health"],
|
|
110
112
|
["_astro_reset", "astro_reset"],
|
|
111
113
|
["_astro_metrics", "astro_metrics"],
|
|
114
|
+
["_astro_lock_status", "astro_lock_status"],
|
|
112
115
|
];
|
|
113
116
|
|
|
114
117
|
// Only add aliases for tools that exist
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
3
|
+
import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
|
|
4
|
+
|
|
5
|
+
export function createAstroLockStatusTool(opts: { ctx: any }): ToolDefinition {
|
|
6
|
+
const { ctx } = opts;
|
|
7
|
+
|
|
8
|
+
return tool({
|
|
9
|
+
description: "Check Astrocode lock status and attempt repair. Shows diagnostics (PID, age, session) and can remove stale/dead locks.",
|
|
10
|
+
args: {
|
|
11
|
+
attempt_repair: tool.schema.boolean().default(false).describe("If true, attempt to remove stale or dead locks"),
|
|
12
|
+
},
|
|
13
|
+
execute: async ({ attempt_repair }) => {
|
|
14
|
+
const repoRoot = ctx.directory as string;
|
|
15
|
+
const lockPath = path.join(repoRoot, ".astro", "astro.lock");
|
|
16
|
+
|
|
17
|
+
const status = getLockStatus(lockPath);
|
|
18
|
+
|
|
19
|
+
if (!status.exists) {
|
|
20
|
+
return "✅ No lock file found. Repository is unlocked.";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
lines.push("# Astrocode Lock Status");
|
|
25
|
+
lines.push("");
|
|
26
|
+
lines.push("## Lock Details");
|
|
27
|
+
lines.push(`- **Path**: ${status.path}`);
|
|
28
|
+
lines.push(`- **PID**: ${status.pid} (${status.pidAlive ? '🟢 ALIVE' : '🔴 DEAD'})`);
|
|
29
|
+
lines.push(`- **Age**: ${status.ageMs ? Math.floor(status.ageMs / 1000) : '?'}s`);
|
|
30
|
+
lines.push(`- **Status**: ${status.isStale ? '⚠️ STALE' : '✅ FRESH'}`);
|
|
31
|
+
if (status.sessionId) lines.push(`- **Session**: ${status.sessionId}`);
|
|
32
|
+
if (status.owner) lines.push(`- **Owner**: ${status.owner}`);
|
|
33
|
+
if (status.instanceId) lines.push(`- **Instance**: ${status.instanceId.substring(0, 8)}...`);
|
|
34
|
+
if (status.leaseId) lines.push(`- **Lease**: ${status.leaseId.substring(0, 8)}...`);
|
|
35
|
+
if (status.createdAt) lines.push(`- **Created**: ${status.createdAt}`);
|
|
36
|
+
if (status.updatedAt) lines.push(`- **Updated**: ${status.updatedAt}`);
|
|
37
|
+
if (status.repoRoot) lines.push(`- **Repo**: ${status.repoRoot}`);
|
|
38
|
+
lines.push(`- **Version**: ${status.version ?? 'unknown'}`);
|
|
39
|
+
lines.push("");
|
|
40
|
+
|
|
41
|
+
if (attempt_repair) {
|
|
42
|
+
lines.push("## Repair Attempt");
|
|
43
|
+
const result = tryRemoveStaleLock(lockPath);
|
|
44
|
+
|
|
45
|
+
if (result.removed) {
|
|
46
|
+
lines.push(`✅ **Lock removed**: ${result.reason}`);
|
|
47
|
+
lines.push("");
|
|
48
|
+
lines.push("The repository is now unlocked and ready for use.");
|
|
49
|
+
} else {
|
|
50
|
+
lines.push(`⚠️ **Lock NOT removed**: ${result.reason}`);
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push("**Recommendations**:");
|
|
53
|
+
lines.push("- If the owning process has crashed, wait 30 seconds for automatic stale detection");
|
|
54
|
+
lines.push("- If the process is still running, wait for it to complete");
|
|
55
|
+
lines.push("- As a last resort, manually stop the process and run this tool again with attempt_repair=true");
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
lines.push("## Recommendations");
|
|
59
|
+
if (!status.pidAlive) {
|
|
60
|
+
lines.push("🔧 **Action Required**: Lock belongs to dead process. Run with `attempt_repair=true` to remove it.");
|
|
61
|
+
} else if (status.isStale) {
|
|
62
|
+
lines.push("🔧 **Action Suggested**: Lock is stale (not updated recently). Run with `attempt_repair=true` to remove it.");
|
|
63
|
+
} else {
|
|
64
|
+
lines.push("✅ Lock is active and healthy. The owning process is running normally.");
|
|
65
|
+
lines.push("");
|
|
66
|
+
lines.push("If you believe this is incorrect:");
|
|
67
|
+
lines.push("- Wait 30 seconds and check again (automatic stale detection)");
|
|
68
|
+
lines.push("- Run with `attempt_repair=true` only if you're certain the process has crashed");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|