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
|
@@ -183,6 +183,7 @@ export declare const AstrocodeConfigSchema: z.ZodDefault<z.ZodObject<{
|
|
|
183
183
|
scope_allowlist: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
184
184
|
type_allowlist: z.ZodOptional<z.ZodDefault<z.ZodArray<z.ZodString>>>;
|
|
185
185
|
max_per_turn: z.ZodOptional<z.ZodDefault<z.ZodNumber>>;
|
|
186
|
+
auto_approve_queued_stories: z.ZodOptional<z.ZodDefault<z.ZodBoolean>>;
|
|
186
187
|
}, z.core.$strip>>>;
|
|
187
188
|
debug: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
188
189
|
telemetry: z.ZodOptional<z.ZodDefault<z.ZodObject<{
|
|
@@ -169,6 +169,7 @@ const InjectSchema = z
|
|
|
169
169
|
scope_allowlist: z.array(z.string()).default(["repo", "global"]),
|
|
170
170
|
type_allowlist: z.array(z.string()).default(["note", "policy"]),
|
|
171
171
|
max_per_turn: z.number().int().positive().default(5),
|
|
172
|
+
auto_approve_queued_stories: z.boolean().default(false).describe("Auto-approve highest priority queued story after workflow tools"),
|
|
172
173
|
})
|
|
173
174
|
.partial()
|
|
174
175
|
.default({});
|
|
@@ -5,17 +5,27 @@ export function createInjectProvider(opts) {
|
|
|
5
5
|
const { ctx, config, runtime } = opts;
|
|
6
6
|
const { db } = runtime;
|
|
7
7
|
// Cache to avoid re-injecting the same injects repeatedly
|
|
8
|
+
// Map of inject_id -> last injected timestamp
|
|
8
9
|
const injectedCache = new Map();
|
|
9
10
|
function shouldSkipInject(injectId, nowMs) {
|
|
10
11
|
const lastInjected = injectedCache.get(injectId);
|
|
11
12
|
if (!lastInjected)
|
|
12
13
|
return false;
|
|
13
|
-
//
|
|
14
|
-
|
|
14
|
+
// REDUCED cooldown from 5 minutes to 1 minute
|
|
15
|
+
// This allows injects to appear more frequently during workflow
|
|
16
|
+
const cooldownMs = 1 * 60 * 1000;
|
|
15
17
|
return nowMs - lastInjected < cooldownMs;
|
|
16
18
|
}
|
|
17
19
|
function markInjected(injectId, nowMs) {
|
|
18
20
|
injectedCache.set(injectId, nowMs);
|
|
21
|
+
// Clean up old entries to prevent memory leak
|
|
22
|
+
// Remove entries older than 10 minutes
|
|
23
|
+
const tenMinutesAgo = nowMs - (10 * 60 * 1000);
|
|
24
|
+
for (const [id, timestamp] of injectedCache.entries()) {
|
|
25
|
+
if (timestamp < tenMinutesAgo) {
|
|
26
|
+
injectedCache.delete(id);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
19
29
|
}
|
|
20
30
|
function getInjectionDiagnostics(nowIso, scopeAllowlist, typeAllowlist) {
|
|
21
31
|
// Get ALL injects to analyze filtering
|
|
@@ -60,7 +70,7 @@ export function createInjectProvider(opts) {
|
|
|
60
70
|
eligible_ids: eligibleIds,
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
|
-
async function injectEligibleInjects(sessionId) {
|
|
73
|
+
async function injectEligibleInjects(sessionId, context) {
|
|
64
74
|
const now = nowISO();
|
|
65
75
|
const nowMs = Date.now();
|
|
66
76
|
// Get allowlists from config or defaults
|
|
@@ -81,7 +91,7 @@ export function createInjectProvider(opts) {
|
|
|
81
91
|
// Log when no injects are eligible
|
|
82
92
|
if (EMIT_TELEMETRY) {
|
|
83
93
|
// eslint-disable-next-line no-console
|
|
84
|
-
console.log(`[Astrocode:inject] ${now}
|
|
94
|
+
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}`);
|
|
85
95
|
}
|
|
86
96
|
return;
|
|
87
97
|
}
|
|
@@ -93,19 +103,63 @@ export function createInjectProvider(opts) {
|
|
|
93
103
|
}
|
|
94
104
|
// Format as injection message
|
|
95
105
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
106
|
+
try {
|
|
107
|
+
await injectChatPrompt({
|
|
108
|
+
ctx,
|
|
109
|
+
sessionId,
|
|
110
|
+
text: formattedText,
|
|
111
|
+
agent: "Astrocode"
|
|
112
|
+
});
|
|
113
|
+
injected++;
|
|
114
|
+
markInjected(inject.inject_id, nowMs);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
// Log injection failures but don't crash
|
|
118
|
+
// eslint-disable-next-line no-console
|
|
119
|
+
console.error(`[Astrocode:inject] Failed to inject ${inject.inject_id}:`, err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Log diagnostic summary
|
|
123
|
+
if (EMIT_TELEMETRY || injected > 0) {
|
|
124
|
+
// eslint-disable-next-line no-console
|
|
125
|
+
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}}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Workflow-related tools that should trigger inject + auto-approval
|
|
129
|
+
const WORKFLOW_TOOLS = new Set([
|
|
130
|
+
'astro_workflow_proceed',
|
|
131
|
+
'astro_story_queue',
|
|
132
|
+
'astro_story_approve',
|
|
133
|
+
'astro_stage_start',
|
|
134
|
+
'astro_stage_complete',
|
|
135
|
+
'astro_stage_fail',
|
|
136
|
+
'astro_run_abort',
|
|
137
|
+
]);
|
|
138
|
+
// Auto-approve queued stories if enabled
|
|
139
|
+
async function maybeAutoApprove(sessionId) {
|
|
140
|
+
if (!config.inject?.auto_approve_queued_stories)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
// Get all queued stories
|
|
144
|
+
const queued = db.prepare("SELECT story_key, title FROM stories WHERE state='queued' ORDER BY priority DESC, created_at ASC").all();
|
|
145
|
+
if (queued.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
// Auto-approve the highest priority queued story
|
|
148
|
+
const story = queued[0];
|
|
149
|
+
db.prepare("UPDATE stories SET state='approved', updated_at=? WHERE story_key=?").run(nowISO(), story.story_key);
|
|
150
|
+
// eslint-disable-next-line no-console
|
|
151
|
+
console.log(`[Astrocode:inject] Auto-approved story ${story.story_key}: ${story.title}`);
|
|
152
|
+
// Inject a notification about the auto-approval
|
|
96
153
|
await injectChatPrompt({
|
|
97
154
|
ctx,
|
|
98
155
|
sessionId,
|
|
99
|
-
text:
|
|
156
|
+
text: `✅ Auto-approved story ${story.story_key}: ${story.title}`,
|
|
100
157
|
agent: "Astrocode"
|
|
101
158
|
});
|
|
102
|
-
injected++;
|
|
103
|
-
markInjected(inject.inject_id, nowMs);
|
|
104
159
|
}
|
|
105
|
-
|
|
106
|
-
if (EMIT_TELEMETRY) {
|
|
160
|
+
catch (err) {
|
|
107
161
|
// eslint-disable-next-line no-console
|
|
108
|
-
console.
|
|
162
|
+
console.error(`[Astrocode:inject] Auto-approval failed:`, err);
|
|
109
163
|
}
|
|
110
164
|
}
|
|
111
165
|
// Public hook handlers
|
|
@@ -114,17 +168,22 @@ export function createInjectProvider(opts) {
|
|
|
114
168
|
if (!config.inject?.enabled)
|
|
115
169
|
return;
|
|
116
170
|
// Inject eligible injects before processing the user's message
|
|
117
|
-
await injectEligibleInjects(input.sessionID);
|
|
171
|
+
await injectEligibleInjects(input.sessionID, 'chat_message');
|
|
118
172
|
},
|
|
119
173
|
async onToolAfter(input) {
|
|
120
174
|
if (!config.inject?.enabled)
|
|
121
175
|
return;
|
|
176
|
+
// Only inject after workflow-related tools
|
|
177
|
+
if (!WORKFLOW_TOOLS.has(input.tool))
|
|
178
|
+
return;
|
|
122
179
|
// Extract sessionID (same pattern as continuation enforcer)
|
|
123
180
|
const sessionId = input.sessionID ?? ctx.sessionID;
|
|
124
181
|
if (!sessionId)
|
|
125
182
|
return;
|
|
126
|
-
//
|
|
127
|
-
await
|
|
183
|
+
// Auto-approve queued stories if enabled
|
|
184
|
+
await maybeAutoApprove(sessionId);
|
|
185
|
+
// Inject eligible injects after workflow tool execution
|
|
186
|
+
await injectEligibleInjects(sessionId, `tool_after:${input.tool}`);
|
|
128
187
|
},
|
|
129
188
|
};
|
|
130
189
|
}
|
|
@@ -32,3 +32,36 @@ export declare function withRepoLock<T>(opts: {
|
|
|
32
32
|
owner?: string;
|
|
33
33
|
fn: () => Promise<T>;
|
|
34
34
|
}): Promise<T>;
|
|
35
|
+
/**
|
|
36
|
+
* Lock diagnostics and status information.
|
|
37
|
+
*/
|
|
38
|
+
export type LockStatus = {
|
|
39
|
+
exists: boolean;
|
|
40
|
+
path: string;
|
|
41
|
+
pid?: number;
|
|
42
|
+
pidAlive?: boolean;
|
|
43
|
+
instanceId?: string;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
owner?: string;
|
|
46
|
+
leaseId?: string;
|
|
47
|
+
createdAt?: string;
|
|
48
|
+
updatedAt?: string;
|
|
49
|
+
ageMs?: number;
|
|
50
|
+
isStale?: boolean;
|
|
51
|
+
repoRoot?: string;
|
|
52
|
+
version?: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Get lock file status and diagnostics.
|
|
56
|
+
* Returns detailed information about the current lock state.
|
|
57
|
+
*/
|
|
58
|
+
export declare function getLockStatus(lockPath: string, staleMs?: number): LockStatus;
|
|
59
|
+
/**
|
|
60
|
+
* Attempt to remove a lock file if it's safe to do so.
|
|
61
|
+
* Only removes locks with dead PIDs or stale timestamps.
|
|
62
|
+
* Returns true if lock was removed, false if lock is still held.
|
|
63
|
+
*/
|
|
64
|
+
export declare function tryRemoveStaleLock(lockPath: string, staleMs?: number): {
|
|
65
|
+
removed: boolean;
|
|
66
|
+
reason: string;
|
|
67
|
+
};
|
|
@@ -222,27 +222,35 @@ function startHeartbeat(opts) {
|
|
|
222
222
|
const now = Date.now();
|
|
223
223
|
const shouldAttempt = now - lastWriteAt >= opts.minWriteMs;
|
|
224
224
|
if (shouldAttempt) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
existing
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
225
|
+
try {
|
|
226
|
+
const existing = readLock(opts.lockPath);
|
|
227
|
+
if (existing &&
|
|
228
|
+
existing.lease_id === opts.leaseId &&
|
|
229
|
+
existing.pid === process.pid &&
|
|
230
|
+
existing.instance_id === PROCESS_INSTANCE_ID) {
|
|
231
|
+
const updatedMs = parseISOToMs(existing.updated_at);
|
|
232
|
+
const isFresh = updatedMs !== null && now - updatedMs < opts.minWriteMs;
|
|
233
|
+
if (!isFresh) {
|
|
234
|
+
writeLockAtomicish(opts.lockPath, {
|
|
235
|
+
...existing,
|
|
236
|
+
updated_at: nowISO(),
|
|
237
|
+
repo_root: opts.repoRoot,
|
|
238
|
+
session_id: opts.sessionId ?? existing.session_id,
|
|
239
|
+
owner: opts.owner ?? existing.owner,
|
|
240
|
+
});
|
|
241
|
+
lastWriteAt = now;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
lastWriteAt = now;
|
|
245
|
+
}
|
|
244
246
|
}
|
|
245
247
|
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// Heartbeat write failed - don't propagate, just reschedule
|
|
250
|
+
// Lock will become stale if heartbeat continues failing
|
|
251
|
+
// eslint-disable-next-line no-console
|
|
252
|
+
console.warn("[Astrocode] Heartbeat write error:", err);
|
|
253
|
+
}
|
|
246
254
|
}
|
|
247
255
|
timer = setTimeout(tick, opts.heartbeatMs);
|
|
248
256
|
timer.unref?.();
|
|
@@ -284,6 +292,18 @@ function installExitHookOnce() {
|
|
|
284
292
|
cleanup();
|
|
285
293
|
process.exit(143);
|
|
286
294
|
});
|
|
295
|
+
process.once("uncaughtException", (err) => {
|
|
296
|
+
// eslint-disable-next-line no-console
|
|
297
|
+
console.error("[Astrocode] Uncaught Exception, cleaning up locks:", err);
|
|
298
|
+
cleanup();
|
|
299
|
+
process.exit(1);
|
|
300
|
+
});
|
|
301
|
+
process.once("unhandledRejection", (reason) => {
|
|
302
|
+
// eslint-disable-next-line no-console
|
|
303
|
+
console.error("[Astrocode] Unhandled Rejection, cleaning up locks:", reason);
|
|
304
|
+
cleanup();
|
|
305
|
+
process.exit(1);
|
|
306
|
+
});
|
|
287
307
|
}
|
|
288
308
|
/**
|
|
289
309
|
* Acquire a repo-scoped lock with:
|
|
@@ -500,3 +520,61 @@ export async function withRepoLock(opts) {
|
|
|
500
520
|
handle.release();
|
|
501
521
|
}
|
|
502
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Get lock file status and diagnostics.
|
|
525
|
+
* Returns detailed information about the current lock state.
|
|
526
|
+
*/
|
|
527
|
+
export function getLockStatus(lockPath, staleMs = 30_000) {
|
|
528
|
+
const existing = readLock(lockPath);
|
|
529
|
+
if (!existing) {
|
|
530
|
+
return {
|
|
531
|
+
exists: false,
|
|
532
|
+
path: lockPath,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const updatedMs = parseISOToMs(existing.updated_at);
|
|
536
|
+
const ageMs = updatedMs !== null ? Date.now() - updatedMs : undefined;
|
|
537
|
+
const pidAlive = isPidAlive(existing.pid);
|
|
538
|
+
const isStale = isStaleByAge(existing, staleMs);
|
|
539
|
+
return {
|
|
540
|
+
exists: true,
|
|
541
|
+
path: lockPath,
|
|
542
|
+
pid: existing.pid,
|
|
543
|
+
pidAlive,
|
|
544
|
+
instanceId: existing.instance_id,
|
|
545
|
+
sessionId: existing.session_id,
|
|
546
|
+
owner: existing.owner,
|
|
547
|
+
leaseId: existing.lease_id,
|
|
548
|
+
createdAt: existing.created_at,
|
|
549
|
+
updatedAt: existing.updated_at,
|
|
550
|
+
ageMs,
|
|
551
|
+
isStale,
|
|
552
|
+
repoRoot: existing.repo_root,
|
|
553
|
+
version: existing.v,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Attempt to remove a lock file if it's safe to do so.
|
|
558
|
+
* Only removes locks with dead PIDs or stale timestamps.
|
|
559
|
+
* Returns true if lock was removed, false if lock is still held.
|
|
560
|
+
*/
|
|
561
|
+
export function tryRemoveStaleLock(lockPath, staleMs = 30_000) {
|
|
562
|
+
const existing = readLock(lockPath);
|
|
563
|
+
if (!existing) {
|
|
564
|
+
return { removed: false, reason: "No lock file found" };
|
|
565
|
+
}
|
|
566
|
+
const pidAlive = isPidAlive(existing.pid);
|
|
567
|
+
const isStale = isStaleByAge(existing, staleMs);
|
|
568
|
+
if (!pidAlive) {
|
|
569
|
+
safeUnlink(lockPath);
|
|
570
|
+
fsyncDirBestEffort(path.dirname(lockPath));
|
|
571
|
+
return { removed: true, reason: `Dead PID ${existing.pid}` };
|
|
572
|
+
}
|
|
573
|
+
if (isStale) {
|
|
574
|
+
safeUnlink(lockPath);
|
|
575
|
+
fsyncDirBestEffort(path.dirname(lockPath));
|
|
576
|
+
const ageSeconds = Math.floor((Date.now() - (parseISOToMs(existing.updated_at) ?? 0)) / 1000);
|
|
577
|
+
return { removed: true, reason: `Stale lock (${ageSeconds}s old, threshold ${staleMs / 1000}s)` };
|
|
578
|
+
}
|
|
579
|
+
return { removed: false, reason: `Lock is active (PID ${existing.pid} alive, age ${Math.floor((Date.now() - (parseISOToMs(existing.updated_at) ?? 0)) / 1000)}s)` };
|
|
580
|
+
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const SCHEMA_VERSION =
|
|
2
|
-
export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";
|
|
1
|
+
export declare const SCHEMA_VERSION = 3;
|
|
2
|
+
export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\n\n-- CONSTRAINT: Only one running run at a time (partial unique index)\n-- This provides database-level safety when using advisory locks\nCREATE UNIQUE INDEX IF NOT EXISTS idx_single_running_run ON runs(status) WHERE status = 'running';\n\n-- CONSTRAINT: Only one run can lock a story at a time (partial unique index)\nCREATE UNIQUE INDEX IF NOT EXISTS idx_single_story_lock ON stories(in_progress) WHERE in_progress = 1;\nCREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";
|
package/dist/src/state/schema.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// vNext adds continuation/snapshot/session tables and stronger indexes.
|
|
4
4
|
//
|
|
5
5
|
// Source of truth: SQLite file at .astro/astro.db
|
|
6
|
-
export const SCHEMA_VERSION =
|
|
6
|
+
export const SCHEMA_VERSION = 3; // v3: Added advisory lock support + database constraints
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
PRAGMA foreign_keys = ON;
|
|
9
9
|
|
|
@@ -233,6 +233,13 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
|
|
|
233
233
|
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
|
|
234
234
|
CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
|
|
235
235
|
CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
|
|
236
|
+
|
|
237
|
+
-- CONSTRAINT: Only one running run at a time (partial unique index)
|
|
238
|
+
-- This provides database-level safety when using advisory locks
|
|
239
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_single_running_run ON runs(status) WHERE status = 'running';
|
|
240
|
+
|
|
241
|
+
-- CONSTRAINT: Only one run can lock a story at a time (partial unique index)
|
|
242
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_single_story_lock ON stories(in_progress) WHERE in_progress = 1;
|
|
236
243
|
CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
|
|
237
244
|
CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
|
|
238
245
|
CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
|
|
@@ -3,6 +3,12 @@ type RepoLockAcquire = typeof acquireRepoLock;
|
|
|
3
3
|
/**
|
|
4
4
|
* Acquire ONCE per workflow/session in this process.
|
|
5
5
|
* Nested calls reuse the same held lock (no reacquire, no churn).
|
|
6
|
+
*
|
|
7
|
+
* ADVISORY LOCK MODE:
|
|
8
|
+
* - Creates lock file to signal other sessions
|
|
9
|
+
* - If lock held by another session: WARN and proceed anyway
|
|
10
|
+
* - Database constraints provide actual safety (single running run)
|
|
11
|
+
* - Better UX: no blocking, just helpful warnings
|
|
6
12
|
*/
|
|
7
13
|
export declare function workflowRepoLock<T>(deps: {
|
|
8
14
|
acquireRepoLock: RepoLockAcquire;
|
|
@@ -12,5 +18,6 @@ export declare function workflowRepoLock<T>(deps: {
|
|
|
12
18
|
sessionId?: string;
|
|
13
19
|
owner?: string;
|
|
14
20
|
fn: () => Promise<T>;
|
|
21
|
+
advisory?: boolean;
|
|
15
22
|
}): Promise<T>;
|
|
16
23
|
export {};
|
|
@@ -5,6 +5,12 @@ function key(lockPath, sessionId) {
|
|
|
5
5
|
/**
|
|
6
6
|
* Acquire ONCE per workflow/session in this process.
|
|
7
7
|
* Nested calls reuse the same held lock (no reacquire, no churn).
|
|
8
|
+
*
|
|
9
|
+
* ADVISORY LOCK MODE:
|
|
10
|
+
* - Creates lock file to signal other sessions
|
|
11
|
+
* - If lock held by another session: WARN and proceed anyway
|
|
12
|
+
* - Database constraints provide actual safety (single running run)
|
|
13
|
+
* - Better UX: no blocking, just helpful warnings
|
|
8
14
|
*/
|
|
9
15
|
export async function workflowRepoLock(deps, opts) {
|
|
10
16
|
const k = key(opts.lockPath, opts.sessionId);
|
|
@@ -23,18 +29,45 @@ export async function workflowRepoLock(deps, opts) {
|
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
// IMPORTANT: this is tuned for "hold for whole workflow".
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
let handle = null;
|
|
33
|
+
try {
|
|
34
|
+
handle = await deps.acquireRepoLock({
|
|
35
|
+
lockPath: opts.lockPath,
|
|
36
|
+
repoRoot: opts.repoRoot,
|
|
37
|
+
sessionId: opts.sessionId,
|
|
38
|
+
owner: opts.owner,
|
|
39
|
+
retryMs: opts.advisory ? 1000 : 30_000, // Advisory: fail fast, hard: wait longer
|
|
40
|
+
staleMs: 30_000, // Reduced from 2 minutes to 30 seconds for faster stale lock recovery
|
|
41
|
+
heartbeatMs: 200,
|
|
42
|
+
minWriteMs: 800,
|
|
43
|
+
pollMs: 20,
|
|
44
|
+
pollMaxMs: 250,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// Lock acquisition failed - check if advisory mode
|
|
49
|
+
if (opts.advisory) {
|
|
50
|
+
// Advisory mode: warn and proceed without lock
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.warn(`⚠️ [Astrocode] Another session may be active. Proceeding anyway (advisory lock mode).`);
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.warn(` ${err.message}`);
|
|
55
|
+
// Proceed without lock - database constraints will ensure safety
|
|
56
|
+
try {
|
|
57
|
+
return await opts.fn();
|
|
58
|
+
}
|
|
59
|
+
catch (dbErr) {
|
|
60
|
+
// Check if this is a concurrency error
|
|
61
|
+
if (dbErr.message?.includes('UNIQUE constraint') || dbErr.message?.includes('SQLITE_BUSY')) {
|
|
62
|
+
throw new Error(`Another session is actively working on this story. Database prevented concurrent modification. ` +
|
|
63
|
+
`Please wait for the other session to complete, or work on a different story.`);
|
|
64
|
+
}
|
|
65
|
+
throw dbErr;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Hard lock mode: propagate error
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
38
71
|
const held = { release: handle.release, depth: 1 };
|
|
39
72
|
HELD_BY_KEY.set(k, held);
|
|
40
73
|
try {
|
package/dist/src/tools/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createAstroRepairTool } from "./repair";
|
|
|
11
11
|
import { createAstroHealthTool } from "./health";
|
|
12
12
|
import { createAstroResetTool } from "./reset";
|
|
13
13
|
import { createAstroMetricsTool } from "./metrics";
|
|
14
|
+
import { createAstroLockStatusTool } from "./lock";
|
|
14
15
|
export function createAstroTools(opts) {
|
|
15
16
|
const { ctx, config, agents, runtime } = opts;
|
|
16
17
|
const { db } = runtime;
|
|
@@ -22,6 +23,7 @@ export function createAstroTools(opts) {
|
|
|
22
23
|
tools.astro_health = createAstroHealthTool({ ctx, config, db });
|
|
23
24
|
tools.astro_reset = createAstroResetTool({ ctx, config, db });
|
|
24
25
|
tools.astro_metrics = createAstroMetricsTool({ ctx, config });
|
|
26
|
+
tools.astro_lock_status = createAstroLockStatusTool({ ctx });
|
|
25
27
|
// Recovery tool - available even in limited mode to allow DB initialization
|
|
26
28
|
tools.astro_init = createAstroInitTool({ ctx, config, runtime });
|
|
27
29
|
// Database-dependent tools
|
|
@@ -83,6 +85,7 @@ export function createAstroTools(opts) {
|
|
|
83
85
|
["_astro_health", "astro_health"],
|
|
84
86
|
["_astro_reset", "astro_reset"],
|
|
85
87
|
["_astro_metrics", "astro_metrics"],
|
|
88
|
+
["_astro_lock_status", "astro_lock_status"],
|
|
86
89
|
];
|
|
87
90
|
// Only add aliases for tools that exist
|
|
88
91
|
for (const [alias, target] of aliases) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
3
|
+
import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
|
|
4
|
+
export function createAstroLockStatusTool(opts) {
|
|
5
|
+
const { ctx } = opts;
|
|
6
|
+
return tool({
|
|
7
|
+
description: "Check Astrocode lock status and attempt repair. Shows diagnostics (PID, age, session) and can remove stale/dead locks.",
|
|
8
|
+
args: {
|
|
9
|
+
attempt_repair: tool.schema.boolean().default(false).describe("If true, attempt to remove stale or dead locks"),
|
|
10
|
+
},
|
|
11
|
+
execute: async ({ attempt_repair }) => {
|
|
12
|
+
const repoRoot = ctx.directory;
|
|
13
|
+
const lockPath = path.join(repoRoot, ".astro", "astro.lock");
|
|
14
|
+
const status = getLockStatus(lockPath);
|
|
15
|
+
if (!status.exists) {
|
|
16
|
+
return "✅ No lock file found. Repository is unlocked.";
|
|
17
|
+
}
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push("# Astrocode Lock Status");
|
|
20
|
+
lines.push("");
|
|
21
|
+
lines.push("## Lock Details");
|
|
22
|
+
lines.push(`- **Path**: ${status.path}`);
|
|
23
|
+
lines.push(`- **PID**: ${status.pid} (${status.pidAlive ? '🟢 ALIVE' : '🔴 DEAD'})`);
|
|
24
|
+
lines.push(`- **Age**: ${status.ageMs ? Math.floor(status.ageMs / 1000) : '?'}s`);
|
|
25
|
+
lines.push(`- **Status**: ${status.isStale ? '⚠️ STALE' : '✅ FRESH'}`);
|
|
26
|
+
if (status.sessionId)
|
|
27
|
+
lines.push(`- **Session**: ${status.sessionId}`);
|
|
28
|
+
if (status.owner)
|
|
29
|
+
lines.push(`- **Owner**: ${status.owner}`);
|
|
30
|
+
if (status.instanceId)
|
|
31
|
+
lines.push(`- **Instance**: ${status.instanceId.substring(0, 8)}...`);
|
|
32
|
+
if (status.leaseId)
|
|
33
|
+
lines.push(`- **Lease**: ${status.leaseId.substring(0, 8)}...`);
|
|
34
|
+
if (status.createdAt)
|
|
35
|
+
lines.push(`- **Created**: ${status.createdAt}`);
|
|
36
|
+
if (status.updatedAt)
|
|
37
|
+
lines.push(`- **Updated**: ${status.updatedAt}`);
|
|
38
|
+
if (status.repoRoot)
|
|
39
|
+
lines.push(`- **Repo**: ${status.repoRoot}`);
|
|
40
|
+
lines.push(`- **Version**: ${status.version ?? 'unknown'}`);
|
|
41
|
+
lines.push("");
|
|
42
|
+
if (attempt_repair) {
|
|
43
|
+
lines.push("## Repair Attempt");
|
|
44
|
+
const result = tryRemoveStaleLock(lockPath);
|
|
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
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
lines.push(`⚠️ **Lock NOT removed**: ${result.reason}`);
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push("**Recommendations**:");
|
|
54
|
+
lines.push("- If the owning process has crashed, wait 30 seconds for automatic stale detection");
|
|
55
|
+
lines.push("- If the process is still running, wait for it to complete");
|
|
56
|
+
lines.push("- As a last resort, manually stop the process and run this tool again with attempt_repair=true");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
lines.push("## Recommendations");
|
|
61
|
+
if (!status.pidAlive) {
|
|
62
|
+
lines.push("🔧 **Action Required**: Lock belongs to dead process. Run with `attempt_repair=true` to remove it.");
|
|
63
|
+
}
|
|
64
|
+
else if (status.isStale) {
|
|
65
|
+
lines.push("🔧 **Action Suggested**: Lock is stale (not updated recently). Run with `attempt_repair=true` to remove it.");
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
lines.push("✅ Lock is active and healthy. The owning process is running normally.");
|
|
69
|
+
lines.push("");
|
|
70
|
+
lines.push("If you believe this is incorrect:");
|
|
71
|
+
lines.push("- Wait 30 seconds and check again (automatic stale detection)");
|
|
72
|
+
lines.push("- Run with `attempt_repair=true` only if you're certain the process has crashed");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|