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.
@@ -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
- // Skip if injected within the last 5 minutes (configurable?)
14
- const cooldownMs = 5 * 60 * 1000;
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} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
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: formattedText,
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
- // Log diagnostic summary
106
- if (EMIT_TELEMETRY) {
160
+ catch (err) {
107
161
  // eslint-disable-next-line no-console
108
- 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}}`);
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
- // Inject eligible injects after tool execution
127
- await injectEligibleInjects(sessionId);
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
- const existing = readLock(opts.lockPath);
226
- if (existing &&
227
- existing.lease_id === opts.leaseId &&
228
- existing.pid === process.pid &&
229
- existing.instance_id === PROCESS_INSTANCE_ID) {
230
- const updatedMs = parseISOToMs(existing.updated_at);
231
- const isFresh = updatedMs !== null && now - updatedMs < opts.minWriteMs;
232
- if (!isFresh) {
233
- writeLockAtomicish(opts.lockPath, {
234
- ...existing,
235
- updated_at: nowISO(),
236
- repo_root: opts.repoRoot,
237
- session_id: opts.sessionId ?? existing.session_id,
238
- owner: opts.owner ?? existing.owner,
239
- });
240
- lastWriteAt = now;
241
- }
242
- else {
243
- lastWriteAt = now;
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;
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";
@@ -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 = 2;
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
- const handle = await deps.acquireRepoLock({
27
- lockPath: opts.lockPath,
28
- repoRoot: opts.repoRoot,
29
- sessionId: opts.sessionId,
30
- owner: opts.owner,
31
- retryMs: 30_000,
32
- staleMs: 2 * 60_000,
33
- heartbeatMs: 200,
34
- minWriteMs: 800,
35
- pollMs: 20,
36
- pollMaxMs: 250,
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 {
@@ -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,4 @@
1
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
+ export declare function createAstroLockStatusTool(opts: {
3
+ ctx: any;
4
+ }): ToolDefinition;
@@ -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
+ }