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.
@@ -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 md = formatRepairReport(report);
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: md, meta: { kind: "repair" } });
20
- return md + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
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 md;
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.5",
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
  },
@@ -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
- // Skip if injected within the last 5 minutes (configurable?)
39
- const cooldownMs = 5 * 60 * 1000;
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} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
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: formattedText,
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.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
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
- // Inject eligible injects after tool execution
173
- await injectEligibleInjects(sessionId);
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
  }
@@ -272,29 +272,36 @@ function startHeartbeat(opts: {
272
272
  const shouldAttempt = now - lastWriteAt >= opts.minWriteMs;
273
273
 
274
274
  if (shouldAttempt) {
275
- const existing = readLock(opts.lockPath);
276
-
277
- if (
278
- existing &&
279
- existing.lease_id === opts.leaseId &&
280
- existing.pid === (process as any).pid &&
281
- existing.instance_id === PROCESS_INSTANCE_ID
282
- ) {
283
- const updatedMs = parseISOToMs(existing.updated_at);
284
- const isFresh = updatedMs !== null && now - updatedMs < opts.minWriteMs;
285
-
286
- if (!isFresh) {
287
- writeLockAtomicish(opts.lockPath, {
288
- ...existing,
289
- updated_at: nowISO(),
290
- repo_root: opts.repoRoot,
291
- session_id: opts.sessionId ?? existing.session_id,
292
- owner: opts.owner ?? existing.owner,
293
- });
294
- lastWriteAt = now;
295
- } else {
296
- lastWriteAt = now;
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
  }
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Source of truth: SQLite file at .astro/astro.db
6
6
 
7
- export const SCHEMA_VERSION = 2;
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
- const handle = await deps.acquireRepoLock({
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
- retryMs: 30_000,
55
- staleMs: 2 * 60_000,
56
- heartbeatMs: 200,
57
- minWriteMs: 800,
58
- pollMs: 20,
59
- pollMaxMs: 250,
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);
@@ -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
+ }