astrocode-workflow 0.2.0 → 0.3.0

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.
@@ -4,7 +4,7 @@ export declare function openSqlite(dbPath: string, opts?: {
4
4
  busyTimeoutMs?: number;
5
5
  }): SqliteDb;
6
6
  export declare function configurePragmas(db: SqliteDb, pragmas: Record<string, any>): void;
7
- /** BEGIN IMMEDIATE transaction helper. */
7
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
8
8
  export declare function withTx<T>(db: SqliteDb, fn: () => T, opts?: {
9
9
  require?: boolean;
10
10
  }): T;
package/dist/state/db.js CHANGED
@@ -46,7 +46,30 @@ export function configurePragmas(db, pragmas) {
46
46
  if (pragmas.temp_store)
47
47
  db.pragma(`temp_store = ${pragmas.temp_store}`);
48
48
  }
49
- /** BEGIN IMMEDIATE transaction helper. */
49
+ /**
50
+ * Re-entrant transaction helper.
51
+ *
52
+ * SQLite rejects BEGIN inside BEGIN. We use:
53
+ * - depth=0: BEGIN IMMEDIATE ... COMMIT/ROLLBACK
54
+ * - depth>0: SAVEPOINT sp_n ... RELEASE / ROLLBACK TO + RELEASE
55
+ *
56
+ * This allows callers to safely nest withTx across layers (tools -> workflow -> state machine)
57
+ * without "cannot start a transaction within a transaction".
58
+ */
59
+ const TX_DEPTH = new WeakMap();
60
+ function getDepth(db) {
61
+ return TX_DEPTH.get(db) ?? 0;
62
+ }
63
+ function setDepth(db, depth) {
64
+ if (depth <= 0)
65
+ TX_DEPTH.delete(db);
66
+ else
67
+ TX_DEPTH.set(db, depth);
68
+ }
69
+ function savepointName(depth) {
70
+ return `sp_${depth}`;
71
+ }
72
+ /** BEGIN IMMEDIATE transaction helper (re-entrant). */
50
73
  export function withTx(db, fn, opts) {
51
74
  const adapter = createDatabaseAdapter();
52
75
  const available = adapter.isAvailable();
@@ -55,21 +78,56 @@ export function withTx(db, fn, opts) {
55
78
  throw new Error("Database adapter unavailable; transaction required.");
56
79
  return fn();
57
80
  }
58
- db.exec("BEGIN IMMEDIATE");
81
+ const depth = getDepth(db);
82
+ if (depth === 0) {
83
+ db.exec("BEGIN IMMEDIATE");
84
+ setDepth(db, 1);
85
+ try {
86
+ const out = fn();
87
+ db.exec("COMMIT");
88
+ return out;
89
+ }
90
+ catch (e) {
91
+ try {
92
+ db.exec("ROLLBACK");
93
+ }
94
+ catch {
95
+ // ignore
96
+ }
97
+ throw e;
98
+ }
99
+ finally {
100
+ setDepth(db, 0);
101
+ }
102
+ }
103
+ // Nested: use SAVEPOINT
104
+ const nextDepth = depth + 1;
105
+ const sp = savepointName(nextDepth);
106
+ db.exec(`SAVEPOINT ${sp}`);
107
+ setDepth(db, nextDepth);
59
108
  try {
60
109
  const out = fn();
61
- db.exec("COMMIT");
110
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
62
111
  return out;
63
112
  }
64
113
  catch (e) {
65
114
  try {
66
- db.exec("ROLLBACK");
115
+ db.exec(`ROLLBACK TO SAVEPOINT ${sp}`);
116
+ }
117
+ catch {
118
+ // ignore
119
+ }
120
+ try {
121
+ db.exec(`RELEASE SAVEPOINT ${sp}`);
67
122
  }
68
123
  catch {
69
124
  // ignore
70
125
  }
71
126
  throw e;
72
127
  }
128
+ finally {
129
+ setDepth(db, depth);
130
+ }
73
131
  }
74
132
  export function getSchemaVersion(db) {
75
133
  try {
@@ -2,9 +2,9 @@ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
2
2
  import type { AstrocodeConfig } from "../config/schema";
3
3
  import type { SqliteDb } from "../state/db";
4
4
  import type { StageKey } from "../state/types";
5
+ import type { AgentConfig } from "@opencode-ai/sdk";
5
6
  export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
6
7
  export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
7
- import { AgentConfig } from "@opencode-ai/sdk";
8
8
  export declare function createAstroWorkflowProceedTool(opts: {
9
9
  ctx: any;
10
10
  config: AstrocodeConfig;
@@ -1,5 +1,5 @@
1
+ // src/tools/workflow.ts
1
2
  import { tool } from "@opencode-ai/plugin/tool";
2
- import { withTx } from "../state/db";
3
3
  import { buildContextSnapshot } from "../workflow/context";
4
4
  import { decideNextAction, createRunForStory, startStage, completeRun, getActiveRun, EVENT_TYPES } from "../workflow/state-machine";
5
5
  import { buildStageDirective, directiveHash } from "../workflow/directives";
@@ -31,24 +31,20 @@ export function resolveAgentName(stageKey, config, agents, warnings) {
31
31
  // Validate that the agent actually exists in the registry
32
32
  if (agents && !agents[candidate]) {
33
33
  const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
34
- if (warnings) {
34
+ if (warnings)
35
35
  warnings.push(warning);
36
- }
37
- else {
36
+ else
38
37
  console.warn(`[Astrocode] ${warning}`);
39
- }
40
38
  candidate = "General";
41
39
  }
42
40
  // Final guard: ensure General exists, fallback to built-in "general" if not
43
41
  if (agents && !agents[candidate]) {
44
42
  const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
45
- if (warnings) {
43
+ if (warnings)
46
44
  warnings.push(finalWarning);
47
- }
48
- else {
45
+ else
49
46
  console.warn(`[Astrocode] ${finalWarning}`);
50
- }
51
- return "general"; // built-in, guaranteed by OpenCode
47
+ return "general";
52
48
  }
53
49
  return candidate;
54
50
  }
@@ -84,9 +80,6 @@ function stageConstraints(stage, cfg) {
84
80
  }
85
81
  return common;
86
82
  }
87
- function agentNameForStage(stage, cfg) {
88
- return cfg.agents.stage_agent_names[stage];
89
- }
90
83
  function buildDelegationPrompt(opts) {
91
84
  const { stageDirective, run_id, stage_key, stage_agent_name } = opts;
92
85
  const stageUpper = stage_key.toUpperCase();
@@ -108,7 +101,6 @@ function buildDelegationPrompt(opts) {
108
101
  ``,
109
102
  `Important: do NOT do any stage work yourself in orchestrator mode.`,
110
103
  ].join("\n").trim();
111
- // Debug log the delegation prompt to troubleshoot agent output issues
112
104
  debug(`Delegating stage ${stage_key} to agent ${stage_agent_name}`, { prompt_length: prompt.length });
113
105
  return prompt;
114
106
  }
@@ -134,59 +126,54 @@ export function createAstroWorkflowProceedTool(opts) {
134
126
  break;
135
127
  }
136
128
  if (next.kind === "start_run") {
137
- const { run_id } = withTx(db, () => createRunForStory(db, config, next.story_key));
129
+ // NOTE: createRunForStory owns its own tx (state-machine.ts).
130
+ const { run_id } = createRunForStory(db, config, next.story_key);
138
131
  actions.push(`started run ${run_id} for story ${next.story_key}`);
139
132
  if (config.ui.toasts.enabled && config.ui.toasts.show_run_started) {
140
133
  await toasts.show({ title: "Astrocode", message: `Run started (${run_id})`, variant: "success" });
141
134
  }
135
+ if (sessionId) {
136
+ await injectChatPrompt({
137
+ ctx,
138
+ sessionId,
139
+ agent: "Astro",
140
+ text: [
141
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_STARTED]`,
142
+ ``,
143
+ `Run started: \`${run_id}\``,
144
+ `Story: \`${next.story_key}\``,
145
+ ``,
146
+ `Next: call **astro_workflow_proceed** again to delegate the first stage.`,
147
+ ].join("\n"),
148
+ });
149
+ actions.push(`injected run started message for ${run_id}`);
150
+ }
142
151
  if (mode === "step")
143
152
  break;
144
153
  continue;
145
154
  }
146
155
  if (next.kind === "complete_run") {
147
- withTx(db, () => completeRun(db, next.run_id));
156
+ // NOTE: completeRun owns its own tx (state-machine.ts).
157
+ completeRun(db, next.run_id);
148
158
  actions.push(`completed run ${next.run_id}`);
149
159
  if (config.ui.toasts.enabled && config.ui.toasts.show_run_completed) {
150
160
  await toasts.show({ title: "Astrocode", message: `Run completed (${next.run_id})`, variant: "success" });
151
161
  }
152
- // Inject continuation directive for workflow resumption
162
+ // explicit injection on completeRun (requested)
153
163
  if (sessionId) {
154
- const continueDirective = [
155
- `[SYSTEM DIRECTIVE: ASTROCODE — CONTINUE]`,
156
- ``,
157
- `Run ${next.run_id} completed successfully.`,
158
- ``,
159
- `The Clara Forms implementation run has finished all stages. The spec has been analyzed and decomposed into prioritized implementation stories.`,
160
- ``,
161
- `Next actions: Review the generated stories and approve the next one to continue development.`,
162
- ].join("\n");
163
- await injectChatPrompt({
164
- ctx,
165
- sessionId,
166
- text: continueDirective,
167
- agent: "Astro"
168
- });
169
- actions.push(`injected continuation directive for completed run ${next.run_id}`);
170
- }
171
- // Check for next approved story to start
172
- const nextStory = db.prepare("SELECT story_key, title FROM stories WHERE state = 'approved' ORDER BY priority DESC, created_at ASC LIMIT 1").get();
173
- if (nextStory && sessionId) {
174
- const nextDirective = [
175
- `[SYSTEM DIRECTIVE: ASTROCODE — START_NEXT_STORY]`,
176
- ``,
177
- `The previous run completed successfully. Start the next approved story.`,
178
- ``,
179
- `Next Story: ${nextStory.story_key} — ${nextStory.title}`,
180
- ``,
181
- `Action: Call astro_story_approve with story_key="${nextStory.story_key}" to start it, or select a different story.`,
182
- ].join("\n");
183
164
  await injectChatPrompt({
184
165
  ctx,
185
166
  sessionId,
186
- text: nextDirective,
187
- agent: "Astro"
167
+ agent: "Astro",
168
+ text: [
169
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_COMPLETED]`,
170
+ ``,
171
+ `Run \`${next.run_id}\` completed.`,
172
+ ``,
173
+ `Next: call **astro_workflow_proceed** (mode=step) to start the next approved story (if any).`,
174
+ ].join("\n"),
188
175
  });
189
- actions.push(`injected directive to start next story ${nextStory.story_key}`);
176
+ actions.push(`injected run completed message for ${next.run_id}`);
190
177
  }
191
178
  if (mode === "step")
192
179
  break;
@@ -198,53 +185,55 @@ export function createAstroWorkflowProceedTool(opts) {
198
185
  throw new Error("Invariant: delegate_stage but no active run.");
199
186
  const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
200
187
  const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
201
- // Mark stage started + set subagent_type to the stage agent.
202
188
  let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
203
- // Validate agent availability with fallback chain
204
- const systemConfig = config;
205
- // Check both the system config agent map (if present) OR the local agents map passed to the tool
206
189
  const agentExists = (name) => {
207
- // Check local agents map first (populated from src/index.ts)
208
- if (agents && agents[name]) {
190
+ if (agents && agents[name])
209
191
  return true;
210
- }
211
- // Check system config agent map
212
- if (systemConfig.agent && systemConfig.agent[name]) {
213
- return true;
214
- }
215
- // For known stage agents, assume they exist (they are system-provided subagents)
216
- const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close"];
217
- if (knownStageAgents.includes(name)) {
192
+ const knownStageAgents = ["Frame", "Plan", "Spec", "Implement", "Review", "Verify", "Close", "General", "Astro", "general"];
193
+ if (knownStageAgents.includes(name))
218
194
  return true;
219
- }
220
195
  return false;
221
196
  };
222
197
  if (!agentExists(agentName)) {
223
198
  const originalAgent = agentName;
224
- console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
225
- // First fallback: orchestrator
199
+ console.warn(`[Astrocode] Agent ${agentName} not found. Falling back to orchestrator.`);
226
200
  agentName = config.agents?.orchestrator_name || "Astro";
227
201
  if (!agentExists(agentName)) {
228
202
  console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
229
- // Final fallback: General (guaranteed to exist)
230
203
  agentName = "General";
231
204
  if (!agentExists(agentName)) {
232
205
  throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
233
206
  }
234
207
  }
235
208
  }
236
- withTx(db, () => {
237
- startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
238
- // Log delegation observability
239
- if (config.debug?.telemetry?.enabled) {
240
- // eslint-disable-next-line no-console
241
- console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
242
- }
243
- });
209
+ // NOTE: startStage owns its own tx (state-machine.ts).
210
+ startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
211
+ actions.push(`stage started: ${next.stage_key}`);
244
212
  if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
245
213
  await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
246
214
  }
247
- const context = buildContextSnapshot({ db, config, run_id: active.run_id, next_action: `delegate stage ${next.stage_key}` });
215
+ // explicit injection on startStage (requested)
216
+ if (sessionId) {
217
+ await injectChatPrompt({
218
+ ctx,
219
+ sessionId,
220
+ agent: "Astro",
221
+ text: [
222
+ `[SYSTEM DIRECTIVE: ASTROCODE — STAGE_STARTED]`,
223
+ ``,
224
+ `Run: \`${active.run_id}\``,
225
+ `Stage: \`${next.stage_key}\``,
226
+ `Delegated to: \`${agentName}\``,
227
+ ].join("\n"),
228
+ });
229
+ actions.push(`injected stage started message for ${next.stage_key}`);
230
+ }
231
+ const context = buildContextSnapshot({
232
+ db,
233
+ config,
234
+ run_id: active.run_id,
235
+ next_action: `delegate stage ${next.stage_key}`,
236
+ });
248
237
  const stageDirective = buildStageDirective({
249
238
  config,
250
239
  stage_key: next.stage_key,
@@ -262,25 +251,29 @@ export function createAstroWorkflowProceedTool(opts) {
262
251
  stage_key: next.stage_key,
263
252
  stage_agent_name: agentName,
264
253
  });
265
- // Record in continuations as a stage directive (dedupe by hash)
266
- const h = directiveHash(delegatePrompt);
267
- const now = nowISO();
268
- if (sessionId) {
269
- db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)").run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
254
+ // Best-effort: continuations table may not exist on older DBs.
255
+ try {
256
+ const h = directiveHash(delegatePrompt);
257
+ const now = nowISO();
258
+ if (sessionId) {
259
+ db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'stage', ?, ?)").run(sessionId, active.run_id, h, `delegate ${next.stage_key}`, now);
260
+ }
261
+ }
262
+ catch (e) {
263
+ warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
270
264
  }
271
265
  // Visible injection so user can see state
272
266
  if (sessionId) {
273
267
  await injectChatPrompt({ ctx, sessionId, text: delegatePrompt, agent: "Astro" });
274
- // Inject continuation guidance
275
268
  const continueMessage = [
276
269
  `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_COMPLETION]`,
277
270
  ``,
278
- `Stage ${next.stage_key} delegated to ${agentName}.`,
271
+ `Stage \`${next.stage_key}\` delegated to \`${agentName}\`.`,
279
272
  ``,
280
- `When ${agentName} completes, call:`,
273
+ `When \`${agentName}\` completes, call:`,
281
274
  `astro_stage_complete(run_id="${active.run_id}", stage_key="${next.stage_key}", output_text="[paste subagent output here]")`,
282
275
  ``,
283
- `This advances the workflow.`,
276
+ `Then run **astro_workflow_proceed** again.`,
284
277
  ].join("\n");
285
278
  await injectChatPrompt({ ctx, sessionId, text: continueMessage, agent: "Astro" });
286
279
  }
@@ -290,35 +283,62 @@ export function createAstroWorkflowProceedTool(opts) {
290
283
  }
291
284
  if (next.kind === "await_stage_completion") {
292
285
  actions.push(`await stage completion: ${next.stage_key}`);
293
- // Optionally nudge with a short directive
294
286
  if (sessionId) {
295
- const context = buildContextSnapshot({ db, config, run_id: next.run_id, next_action: `complete stage ${next.stage_key}` });
287
+ const context = buildContextSnapshot({
288
+ db,
289
+ config,
290
+ run_id: next.run_id,
291
+ next_action: `complete stage ${next.stage_key}`,
292
+ });
296
293
  const prompt = [
297
294
  `[SYSTEM DIRECTIVE: ASTROCODE — AWAIT_STAGE_OUTPUT]`,
298
295
  ``,
299
- `Run ${next.run_id} is waiting for stage ${next.stage_key} output.`,
296
+ `Run \`${next.run_id}\` is waiting for stage \`${next.stage_key}\` output.`,
300
297
  `If you have the subagent output, call astro_stage_complete with output_text=the FULL output.`,
301
298
  ``,
302
299
  `Context snapshot:`,
303
300
  context,
304
301
  ].join("\n").trim();
305
- const h = directiveHash(prompt);
306
- const now = nowISO();
307
- db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)").run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
302
+ try {
303
+ const h = directiveHash(prompt);
304
+ const now = nowISO();
305
+ db.prepare("INSERT INTO continuations (session_id, run_id, directive_hash, kind, reason, created_at) VALUES (?, ?, ?, 'continue', ?, ?)").run(sessionId, next.run_id, h, `await ${next.stage_key}`, now);
306
+ }
307
+ catch (e) {
308
+ warnings.push(`continuations insert failed (non-fatal): ${String(e)}`);
309
+ }
308
310
  await injectChatPrompt({ ctx, sessionId, text: prompt, agent: "Astro" });
309
311
  }
310
312
  break;
311
313
  }
312
314
  if (next.kind === "failed") {
313
315
  actions.push(`failed: ${next.stage_key} — ${next.error_text}`);
316
+ if (sessionId) {
317
+ await injectChatPrompt({
318
+ ctx,
319
+ sessionId,
320
+ agent: "Astro",
321
+ text: [
322
+ `[SYSTEM DIRECTIVE: ASTROCODE — RUN_FAILED]`,
323
+ ``,
324
+ `Run \`${next.run_id}\` failed at stage \`${next.stage_key}\`.`,
325
+ `Error: ${next.error_text}`,
326
+ ].join("\n"),
327
+ });
328
+ actions.push(`injected run failed message for ${next.run_id}`);
329
+ }
314
330
  break;
315
331
  }
316
- // safety
317
332
  actions.push(`unhandled next action: ${next.kind}`);
318
333
  break;
319
334
  }
320
- // Housekeeping event
321
- db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)").run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
335
+ // Housekeeping event (best-effort)
336
+ try {
337
+ db.prepare("INSERT INTO events (event_id, run_id, stage_key, type, body_json, created_at) VALUES (?, NULL, NULL, ?, ?, ?)").run(newEventId(), EVENT_TYPES.WORKFLOW_PROCEED, JSON.stringify({ started_at: startedAt, mode, max_steps: steps, actions }), nowISO());
338
+ }
339
+ catch (e) {
340
+ warnings.push(`workflow.proceed event insert failed (non-fatal): ${String(e)}`);
341
+ }
322
342
  const active = getActiveRun(db);
323
343
  const lines = [];
324
344
  lines.push(`# astro_workflow_proceed`);
@@ -1,20 +1,6 @@
1
- type QueueItem = {
1
+ export declare function injectChatPrompt(opts: {
2
2
  ctx: any;
3
- sessionId: string;
3
+ sessionId?: string;
4
4
  text: string;
5
5
  agent?: string;
6
- };
7
- /**
8
- * Enqueue an injection and ensure the worker is running.
9
- * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
10
- */
11
- export declare function enqueueChatPrompt(opts: QueueItem): void;
12
- /**
13
- * Wait until all queued injections have been processed (sent or exhausted retries).
14
- */
15
- export declare function flushChatPrompts(): Promise<void>;
16
- /**
17
- * Deterministic helper: enqueue + flush (recommended for stage boundaries).
18
- */
19
- export declare function injectChatPrompt(opts: QueueItem): Promise<void>;
20
- export {};
6
+ }): Promise<void>;
package/dist/ui/inject.js CHANGED
@@ -1,118 +1,88 @@
1
1
  // src/ui/inject.ts
2
- //
3
- // Deterministic chat injection:
4
- // - Always enqueue
5
- // - Process sequentially (per-process single worker)
6
- // - Retries with backoff
7
- // - flush() lets callers wait until injections are actually sent
8
- //
9
- // IMPORTANT: Callers who need reliability must `await injectChatPrompt(...)`
10
- // or `await flushChatPrompts()` after enqueueing.
2
+ let isInjecting = false;
11
3
  const injectionQueue = [];
12
- let workerRunning = false;
13
- // Used to let callers await "queue drained"
14
- let drainWaiters = [];
15
4
  function sleep(ms) {
16
- return new Promise((resolve) => setTimeout(resolve, ms));
5
+ return new Promise((r) => setTimeout(r, ms));
17
6
  }
18
- function resolveDrainWaitersIfIdle() {
19
- if (workerRunning)
20
- return;
21
- if (injectionQueue.length !== 0)
22
- return;
23
- const waiters = drainWaiters;
24
- drainWaiters = [];
25
- for (const w of waiters)
26
- w();
27
- }
28
- function getPromptApi(ctx) {
29
- const fn = ctx?.client?.session?.prompt;
30
- return typeof fn === "function" ? fn.bind(ctx.client.session) : null;
7
+ function resolveSessionId(ctx, sessionId) {
8
+ if (sessionId)
9
+ return sessionId;
10
+ const direct = ctx?.sessionID ?? ctx?.sessionId ?? ctx?.session?.id;
11
+ if (typeof direct === "string" && direct.length > 0)
12
+ return direct;
13
+ return null;
31
14
  }
32
- async function sendWithRetries(opts) {
33
- const { ctx, sessionId, text } = opts;
34
- const agent = opts.agent ?? "Astro";
35
- const prefixedText = `[${agent}]\n\n${text}`;
36
- if (!sessionId) {
37
- console.warn("[Astrocode] Injection skipped: missing sessionId");
38
- return;
39
- }
40
- const prompt = getPromptApi(ctx);
41
- if (!prompt) {
42
- console.warn("[Astrocode] Injection skipped: ctx.client.session.prompt unavailable");
43
- return;
44
- }
45
- const maxAttempts = 3;
46
- let attempt = 0;
47
- while (attempt < maxAttempts) {
48
- attempt += 1;
49
- try {
50
- await prompt({
51
- path: { id: sessionId },
52
- body: {
53
- parts: [{ type: "text", text: prefixedText }],
54
- agent,
55
- },
56
- });
57
- return;
58
- }
59
- catch (err) {
60
- const msg = err instanceof Error ? err.message : String(err);
61
- const isLast = attempt >= maxAttempts;
62
- if (isLast) {
63
- console.warn(`[Astrocode] Injection failed (final): ${msg}`);
64
- return;
65
- }
66
- // Exponential backoff + jitter
67
- const base = 150 * Math.pow(2, attempt - 1); // 150, 300, 600
68
- const jitter = Math.floor(Math.random() * 120);
69
- await sleep(base + jitter);
70
- }
15
+ async function tryInjectOnce(opts) {
16
+ const { ctx, sessionId, text, agent } = opts;
17
+ // Prefer explicit chat prompt API
18
+ const promptApi = ctx?.client?.session?.prompt;
19
+ if (!promptApi) {
20
+ throw new Error("API not available (ctx.client.session.prompt)");
71
21
  }
22
+ const prefixedText = `[${agent}]\n\n${text}`;
23
+ // Some hosts reject unknown fields; keep body minimal and stable.
24
+ await promptApi({
25
+ path: { id: sessionId },
26
+ body: {
27
+ parts: [{ type: "text", text: prefixedText }],
28
+ },
29
+ });
72
30
  }
73
- async function runWorkerLoop() {
74
- if (workerRunning)
31
+ async function processQueue() {
32
+ if (isInjecting)
33
+ return;
34
+ if (injectionQueue.length === 0)
75
35
  return;
76
- workerRunning = true;
36
+ isInjecting = true;
77
37
  try {
78
- // Drain sequentially to preserve ordering
79
38
  while (injectionQueue.length > 0) {
80
39
  const item = injectionQueue.shift();
81
40
  if (!item)
82
41
  continue;
83
- await sendWithRetries(item);
42
+ const { ctx, text, agent = "Astro" } = item;
43
+ const sessionId = resolveSessionId(ctx, item.sessionId);
44
+ if (!sessionId) {
45
+ // Drop on floor: we cannot recover without a session id.
46
+ // Keep draining the queue so we don't stall.
47
+ // eslint-disable-next-line no-console
48
+ console.warn("[Astrocode] Injection skipped: no sessionId");
49
+ continue;
50
+ }
51
+ const maxAttempts = item.retry?.maxAttempts ?? 4;
52
+ const baseDelayMs = item.retry?.baseDelayMs ?? 250;
53
+ let lastErr = null;
54
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
55
+ try {
56
+ await tryInjectOnce({ ctx, sessionId, text, agent });
57
+ lastErr = null;
58
+ break;
59
+ }
60
+ catch (e) {
61
+ lastErr = e;
62
+ const delay = baseDelayMs * Math.pow(2, attempt - 1); // 250, 500, 1000, 2000
63
+ // eslint-disable-next-line no-console
64
+ console.warn(`[Astrocode] Injection attempt ${attempt}/${maxAttempts} failed: ${String(e)}; retrying in ${delay}ms`);
65
+ await sleep(delay);
66
+ }
67
+ }
68
+ if (lastErr) {
69
+ // eslint-disable-next-line no-console
70
+ console.warn(`[Astrocode] Injection failed permanently after ${maxAttempts} attempts: ${String(lastErr)}`);
71
+ }
84
72
  }
85
73
  }
86
74
  finally {
87
- workerRunning = false;
88
- resolveDrainWaitersIfIdle();
75
+ isInjecting = false;
89
76
  }
90
77
  }
91
- /**
92
- * Enqueue an injection and ensure the worker is running.
93
- * Does NOT wait for delivery — use `flushChatPrompts()` to wait.
94
- */
95
- export function enqueueChatPrompt(opts) {
96
- injectionQueue.push(opts);
97
- // Kick worker
98
- void runWorkerLoop();
99
- }
100
- /**
101
- * Wait until all queued injections have been processed (sent or exhausted retries).
102
- */
103
- export function flushChatPrompts() {
104
- if (!workerRunning && injectionQueue.length === 0)
105
- return Promise.resolve();
106
- return new Promise((resolve) => {
107
- drainWaiters.push(resolve);
108
- // Ensure worker is running (in case someone enqueued without kick)
109
- void runWorkerLoop();
110
- });
111
- }
112
- /**
113
- * Deterministic helper: enqueue + flush (recommended for stage boundaries).
114
- */
115
78
  export async function injectChatPrompt(opts) {
116
- enqueueChatPrompt(opts);
117
- await flushChatPrompts();
79
+ injectionQueue.push({
80
+ ctx: opts.ctx,
81
+ sessionId: opts.sessionId,
82
+ text: opts.text,
83
+ agent: opts.agent ?? "Astro",
84
+ retry: { maxAttempts: 4, baseDelayMs: 250 },
85
+ });
86
+ // Fire-and-forget; queue drain is serialized by isInjecting.
87
+ void processQueue();
118
88
  }