opencode-swarm-plugin 0.12.4 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/beads.ts CHANGED
@@ -55,6 +55,10 @@ export class BeadValidationError extends Error {
55
55
 
56
56
  /**
57
57
  * Build a bd create command from args
58
+ *
59
+ * Note: Bun's `$` template literal properly escapes arguments when passed as array.
60
+ * Each array element is treated as a separate argument, preventing shell injection.
61
+ * Example: ["bd", "create", "; rm -rf /"] becomes: bd create "; rm -rf /"
58
62
  */
59
63
  function buildCreateCommand(args: BeadCreateArgs): string[] {
60
64
  const parts = ["bd", "create", args.title];
@@ -250,25 +254,40 @@ export const beads_create_epic = tool({
250
254
 
251
255
  return JSON.stringify(result, null, 2);
252
256
  } catch (error) {
253
- // Partial failure - return what was created with rollback hint
254
- const rollbackHint = created
255
- .map((b) => `bd close ${b.id} --reason "Rollback partial epic"`)
256
- .join("\n");
257
+ // Partial failure - execute rollback automatically
258
+ const rollbackCommands: string[] = [];
259
+
260
+ for (const bead of created) {
261
+ try {
262
+ const closeCmd = [
263
+ "bd",
264
+ "close",
265
+ bead.id,
266
+ "--reason",
267
+ "Rollback partial epic",
268
+ "--json",
269
+ ];
270
+ await Bun.$`${closeCmd}`.quiet().nothrow();
271
+ rollbackCommands.push(
272
+ `bd close ${bead.id} --reason "Rollback partial epic"`,
273
+ );
274
+ } catch (rollbackError) {
275
+ // Log rollback failure but continue
276
+ console.error(`Failed to rollback bead ${bead.id}:`, rollbackError);
277
+ }
278
+ }
257
279
 
258
- const result: EpicCreateResult = {
259
- success: false,
260
- epic: created[0] || ({} as Bead),
261
- subtasks: created.slice(1),
262
- rollback_hint: rollbackHint,
263
- };
280
+ // Throw error with rollback info
281
+ const errorMsg = error instanceof Error ? error.message : String(error);
282
+ const rollbackInfo =
283
+ rollbackCommands.length > 0
284
+ ? `\n\nRolled back ${rollbackCommands.length} bead(s):\n${rollbackCommands.join("\n")}`
285
+ : "\n\nNo beads to rollback.";
264
286
 
265
- return JSON.stringify(
266
- {
267
- ...result,
268
- error: error instanceof Error ? error.message : String(error),
269
- },
270
- null,
271
- 2,
287
+ throw new BeadError(
288
+ `Epic creation failed: ${errorMsg}${rollbackInfo}`,
289
+ "beads_create_epic",
290
+ 1,
272
291
  );
273
292
  }
274
293
  },
@@ -487,10 +506,38 @@ export const beads_sync = tool({
487
506
  },
488
507
  async execute(args, ctx) {
489
508
  const autoPull = args.auto_pull ?? true;
509
+ const TIMEOUT_MS = 30000; // 30 seconds
510
+
511
+ /**
512
+ * Helper to run a command with timeout
513
+ */
514
+ const withTimeout = async <T>(
515
+ promise: Promise<T>,
516
+ timeoutMs: number,
517
+ operation: string,
518
+ ): Promise<T> => {
519
+ const timeoutPromise = new Promise<never>((_, reject) =>
520
+ setTimeout(
521
+ () =>
522
+ reject(
523
+ new BeadError(
524
+ `Operation timed out after ${timeoutMs}ms`,
525
+ operation,
526
+ ),
527
+ ),
528
+ timeoutMs,
529
+ ),
530
+ );
531
+ return Promise.race([promise, timeoutPromise]);
532
+ };
490
533
 
491
534
  // 1. Pull if requested
492
535
  if (autoPull) {
493
- const pullResult = await Bun.$`git pull --rebase`.quiet().nothrow();
536
+ const pullResult = await withTimeout(
537
+ Bun.$`git pull --rebase`.quiet().nothrow(),
538
+ TIMEOUT_MS,
539
+ "git pull --rebase",
540
+ );
494
541
  if (pullResult.exitCode !== 0) {
495
542
  throw new BeadError(
496
543
  `Failed to pull: ${pullResult.stderr.toString()}`,
@@ -501,7 +548,11 @@ export const beads_sync = tool({
501
548
  }
502
549
 
503
550
  // 2. Sync beads
504
- const syncResult = await Bun.$`bd sync`.quiet().nothrow();
551
+ const syncResult = await withTimeout(
552
+ Bun.$`bd sync`.quiet().nothrow(),
553
+ TIMEOUT_MS,
554
+ "bd sync",
555
+ );
505
556
  if (syncResult.exitCode !== 0) {
506
557
  throw new BeadError(
507
558
  `Failed to sync beads: ${syncResult.stderr.toString()}`,
@@ -511,7 +562,11 @@ export const beads_sync = tool({
511
562
  }
512
563
 
513
564
  // 3. Push
514
- const pushResult = await Bun.$`git push`.quiet().nothrow();
565
+ const pushResult = await withTimeout(
566
+ Bun.$`git push`.quiet().nothrow(),
567
+ TIMEOUT_MS,
568
+ "git push",
569
+ );
515
570
  if (pushResult.exitCode !== 0) {
516
571
  throw new BeadError(
517
572
  `Failed to push: ${pushResult.stderr.toString()}`,
package/src/learning.ts CHANGED
@@ -64,6 +64,46 @@ export const CriterionWeightSchema = z.object({
64
64
  });
65
65
  export type CriterionWeight = z.infer<typeof CriterionWeightSchema>;
66
66
 
67
+ /**
68
+ * Error types that can occur during subtask execution
69
+ */
70
+ export const ErrorTypeSchema = z.enum([
71
+ "validation",
72
+ "timeout",
73
+ "conflict",
74
+ "tool_failure",
75
+ "unknown",
76
+ ]);
77
+ export type ErrorType = z.infer<typeof ErrorTypeSchema>;
78
+
79
+ /**
80
+ * An error entry in the error accumulator
81
+ *
82
+ * Errors are accumulated during subtask execution and can be fed
83
+ * into retry prompts to help agents learn from past failures.
84
+ */
85
+ export const ErrorEntrySchema = z.object({
86
+ /** Unique ID for this error entry */
87
+ id: z.string(),
88
+ /** The bead ID this error relates to */
89
+ bead_id: z.string(),
90
+ /** Type of error encountered */
91
+ error_type: ErrorTypeSchema,
92
+ /** Human-readable error message */
93
+ message: z.string(),
94
+ /** Optional stack trace for debugging */
95
+ stack_trace: z.string().optional(),
96
+ /** Tool that failed, if applicable */
97
+ tool_name: z.string().optional(),
98
+ /** When this error occurred */
99
+ timestamp: z.string(), // ISO-8601
100
+ /** Whether this error was resolved */
101
+ resolved: z.boolean().default(false),
102
+ /** Context about what was happening when error occurred */
103
+ context: z.string().optional(),
104
+ });
105
+ export type ErrorEntry = z.infer<typeof ErrorEntrySchema>;
106
+
67
107
  /**
68
108
  * Decomposition strategies for tracking which approach was used
69
109
  */
@@ -437,6 +477,241 @@ export class InMemoryFeedbackStorage implements FeedbackStorage {
437
477
  }
438
478
  }
439
479
 
480
+ // ============================================================================
481
+ // Error Accumulator
482
+ // ============================================================================
483
+
484
+ /**
485
+ * Storage interface for error entries
486
+ *
487
+ * Similar to FeedbackStorage but for tracking errors during execution.
488
+ */
489
+ export interface ErrorStorage {
490
+ /** Store an error entry */
491
+ store(entry: ErrorEntry): Promise<void>;
492
+ /** Get all errors for a bead */
493
+ getByBead(beadId: string): Promise<ErrorEntry[]>;
494
+ /** Get unresolved errors for a bead */
495
+ getUnresolvedByBead(beadId: string): Promise<ErrorEntry[]>;
496
+ /** Mark an error as resolved */
497
+ markResolved(id: string): Promise<void>;
498
+ /** Get all errors */
499
+ getAll(): Promise<ErrorEntry[]>;
500
+ }
501
+
502
+ /**
503
+ * In-memory error storage
504
+ *
505
+ * Accumulates errors during subtask execution for feeding into retry prompts.
506
+ */
507
+ export class InMemoryErrorStorage implements ErrorStorage {
508
+ private errors: ErrorEntry[] = [];
509
+
510
+ async store(entry: ErrorEntry): Promise<void> {
511
+ this.errors.push(entry);
512
+ }
513
+
514
+ async getByBead(beadId: string): Promise<ErrorEntry[]> {
515
+ return this.errors.filter((e) => e.bead_id === beadId);
516
+ }
517
+
518
+ async getUnresolvedByBead(beadId: string): Promise<ErrorEntry[]> {
519
+ return this.errors.filter((e) => e.bead_id === beadId && !e.resolved);
520
+ }
521
+
522
+ async markResolved(id: string): Promise<void> {
523
+ const error = this.errors.find((e) => e.id === id);
524
+ if (error) {
525
+ error.resolved = true;
526
+ }
527
+ }
528
+
529
+ async getAll(): Promise<ErrorEntry[]> {
530
+ return [...this.errors];
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Error accumulator for tracking errors during subtask execution
536
+ *
537
+ * Implements patterns from "Patterns for Building AI Agents" p.40:
538
+ * - Examines and corrects errors when something goes wrong
539
+ * - Feeds error context into retry prompts
540
+ * - Tracks error patterns for learning
541
+ */
542
+ export class ErrorAccumulator {
543
+ private storage: ErrorStorage;
544
+
545
+ constructor(storage?: ErrorStorage) {
546
+ this.storage = storage ?? new InMemoryErrorStorage();
547
+ }
548
+
549
+ /**
550
+ * Record an error during subtask execution
551
+ *
552
+ * @param beadId - Bead ID where error occurred
553
+ * @param errorType - Category of error
554
+ * @param message - Human-readable error message
555
+ * @param options - Additional context (stack trace, tool name, etc.)
556
+ * @returns The created error entry
557
+ */
558
+ async recordError(
559
+ beadId: string,
560
+ errorType: ErrorType,
561
+ message: string,
562
+ options?: {
563
+ stack_trace?: string;
564
+ tool_name?: string;
565
+ context?: string;
566
+ },
567
+ ): Promise<ErrorEntry> {
568
+ const entry: ErrorEntry = {
569
+ id: `${beadId}-${errorType}-${Date.now()}`,
570
+ bead_id: beadId,
571
+ error_type: errorType,
572
+ message,
573
+ stack_trace: options?.stack_trace,
574
+ tool_name: options?.tool_name,
575
+ timestamp: new Date().toISOString(),
576
+ resolved: false,
577
+ context: options?.context,
578
+ };
579
+
580
+ const validated = ErrorEntrySchema.parse(entry);
581
+ await this.storage.store(validated);
582
+
583
+ return validated;
584
+ }
585
+
586
+ /**
587
+ * Get all errors for a bead (resolved and unresolved)
588
+ */
589
+ async getErrors(beadId: string): Promise<ErrorEntry[]> {
590
+ return this.storage.getByBead(beadId);
591
+ }
592
+
593
+ /**
594
+ * Get only unresolved errors for a bead
595
+ */
596
+ async getUnresolvedErrors(beadId: string): Promise<ErrorEntry[]> {
597
+ return this.storage.getUnresolvedByBead(beadId);
598
+ }
599
+
600
+ /**
601
+ * Mark an error as resolved
602
+ */
603
+ async resolveError(errorId: string): Promise<void> {
604
+ await this.storage.markResolved(errorId);
605
+ }
606
+
607
+ /**
608
+ * Format errors as context for retry prompts
609
+ *
610
+ * Groups errors by type and provides structured feedback
611
+ * for the agent to learn from.
612
+ *
613
+ * @param beadId - Bead to get error context for
614
+ * @param includeResolved - Include resolved errors (default: false)
615
+ * @returns Formatted error context string
616
+ */
617
+ async getErrorContext(
618
+ beadId: string,
619
+ includeResolved = false,
620
+ ): Promise<string> {
621
+ const errors = includeResolved
622
+ ? await this.getErrors(beadId)
623
+ : await this.getUnresolvedErrors(beadId);
624
+
625
+ if (errors.length === 0) {
626
+ return "";
627
+ }
628
+
629
+ // Group errors by type
630
+ const byType = errors.reduce(
631
+ (acc, err) => {
632
+ const type = err.error_type;
633
+ if (!acc[type]) {
634
+ acc[type] = [];
635
+ }
636
+ acc[type].push(err);
637
+ return acc;
638
+ },
639
+ {} as Record<ErrorType, ErrorEntry[]>,
640
+ );
641
+
642
+ // Format as structured feedback
643
+ const lines = [
644
+ "## Previous Errors",
645
+ "",
646
+ "The following errors were encountered during execution:",
647
+ "",
648
+ ];
649
+
650
+ for (const [type, typeErrors] of Object.entries(byType)) {
651
+ lines.push(
652
+ `### ${type} (${typeErrors.length} error${typeErrors.length > 1 ? "s" : ""})`,
653
+ );
654
+ lines.push("");
655
+
656
+ for (const err of typeErrors) {
657
+ lines.push(`- **${err.message}**`);
658
+ if (err.context) {
659
+ lines.push(` - Context: ${err.context}`);
660
+ }
661
+ if (err.tool_name) {
662
+ lines.push(` - Tool: ${err.tool_name}`);
663
+ }
664
+ if (err.stack_trace) {
665
+ lines.push(` - Stack: \`${err.stack_trace.slice(0, 100)}...\``);
666
+ }
667
+ lines.push(
668
+ ` - Time: ${new Date(err.timestamp).toLocaleString()}${err.resolved ? " (resolved)" : ""}`,
669
+ );
670
+ lines.push("");
671
+ }
672
+ }
673
+
674
+ lines.push(
675
+ "**Action Required**: Address these errors before proceeding. Consider:",
676
+ );
677
+ lines.push("- What caused each error?");
678
+ lines.push("- How can you prevent similar errors?");
679
+ lines.push("- Are there patterns across error types?");
680
+ lines.push("");
681
+
682
+ return lines.join("\n");
683
+ }
684
+
685
+ /**
686
+ * Get error statistics for outcome tracking
687
+ *
688
+ * @param beadId - Bead to get stats for
689
+ * @returns Error counts and patterns
690
+ */
691
+ async getErrorStats(beadId: string): Promise<{
692
+ total: number;
693
+ unresolved: number;
694
+ by_type: Record<ErrorType, number>;
695
+ }> {
696
+ const allErrors = await this.getErrors(beadId);
697
+ const unresolved = await this.getUnresolvedErrors(beadId);
698
+
699
+ const byType = allErrors.reduce(
700
+ (acc, err) => {
701
+ acc[err.error_type] = (acc[err.error_type] || 0) + 1;
702
+ return acc;
703
+ },
704
+ {} as Record<ErrorType, number>,
705
+ );
706
+
707
+ return {
708
+ total: allErrors.length,
709
+ unresolved: unresolved.length,
710
+ by_type: byType,
711
+ };
712
+ }
713
+ }
714
+
440
715
  // ============================================================================
441
716
  // Exports
442
717
  // ============================================================================
@@ -448,4 +723,6 @@ export const learningSchemas = {
448
723
  OutcomeSignalsSchema,
449
724
  ScoredOutcomeSchema,
450
725
  DecompositionStrategySchema,
726
+ ErrorTypeSchema,
727
+ ErrorEntrySchema,
451
728
  };
@@ -122,9 +122,15 @@ export function getLimitsForEndpoint(endpoint: string): EndpointLimits {
122
122
  const perHourEnv =
123
123
  process.env[`OPENCODE_RATE_LIMIT_${upperEndpoint}_PER_HOUR`];
124
124
 
125
+ // Parse and validate env vars, fall back to defaults on NaN
126
+ const parsedPerMinute = perMinuteEnv ? parseInt(perMinuteEnv, 10) : NaN;
127
+ const parsedPerHour = perHourEnv ? parseInt(perHourEnv, 10) : NaN;
128
+
125
129
  return {
126
- perMinute: perMinuteEnv ? parseInt(perMinuteEnv, 10) : defaults.perMinute,
127
- perHour: perHourEnv ? parseInt(perHourEnv, 10) : defaults.perHour,
130
+ perMinute: Number.isNaN(parsedPerMinute)
131
+ ? defaults.perMinute
132
+ : parsedPerMinute,
133
+ perHour: Number.isNaN(parsedPerHour) ? defaults.perHour : parsedPerHour,
128
134
  };
129
135
  }
130
136
 
@@ -211,10 +217,11 @@ export class RedisRateLimiter implements RateLimiter {
211
217
  const windowDuration = this.getWindowDuration(window);
212
218
  const windowStart = now - windowDuration;
213
219
 
214
- // Remove expired entries and count current ones in a pipeline
220
+ // Remove expired entries, count current ones, and fetch oldest in a single pipeline
215
221
  const pipeline = this.redis.pipeline();
216
222
  pipeline.zremrangebyscore(key, 0, windowStart);
217
223
  pipeline.zcard(key);
224
+ pipeline.zrange(key, 0, 0, "WITHSCORES"); // Fetch oldest entry atomically
218
225
 
219
226
  const results = await pipeline.exec();
220
227
  if (!results) {
@@ -225,11 +232,10 @@ export class RedisRateLimiter implements RateLimiter {
225
232
  const remaining = Math.max(0, limit - count);
226
233
  const allowed = count < limit;
227
234
 
228
- // Calculate reset time based on oldest entry in window
235
+ // Calculate reset time based on oldest entry in window (fetched atomically)
229
236
  let resetAt = now + windowDuration;
230
237
  if (!allowed) {
231
- // Get the oldest entry's timestamp to calculate precise reset
232
- const oldest = await this.redis.zrange(key, 0, 0, "WITHSCORES");
238
+ const oldest = (results[2]?.[1] as string[]) || [];
233
239
  if (oldest.length >= 2) {
234
240
  const oldestTimestamp = parseInt(oldest[1], 10);
235
241
  resetAt = oldestTimestamp + windowDuration;
@@ -42,15 +42,15 @@ export type BeadDependency = z.infer<typeof BeadDependencySchema>;
42
42
  export const BeadSchema = z.object({
43
43
  id: z
44
44
  .string()
45
- .regex(/^[a-z0-9-]+-[a-z0-9]+(\.\d+)?$/, "Invalid bead ID format"),
45
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)+(\.\d+)?$/, "Invalid bead ID format"),
46
46
  title: z.string().min(1, "Title required"),
47
47
  description: z.string().optional().default(""),
48
48
  status: BeadStatusSchema.default("open"),
49
49
  priority: z.number().int().min(0).max(3).default(2),
50
50
  issue_type: BeadTypeSchema.default("task"),
51
- created_at: z.string(), // ISO-8601
52
- updated_at: z.string().optional(),
53
- closed_at: z.string().optional(),
51
+ created_at: z.string().datetime({ offset: true }), // ISO-8601 with timezone offset
52
+ updated_at: z.string().datetime({ offset: true }).optional(),
53
+ closed_at: z.string().datetime({ offset: true }).optional(),
54
54
  parent_id: z.string().optional(),
55
55
  dependencies: z.array(BeadDependencySchema).optional().default([]),
56
56
  metadata: z.record(z.string(), z.unknown()).optional(),
@@ -53,7 +53,7 @@ export const EvaluationSchema = z.object({
53
53
  criteria: z.record(z.string(), CriterionEvaluationSchema),
54
54
  overall_feedback: z.string(),
55
55
  retry_suggestion: z.string().nullable(),
56
- timestamp: z.string().optional(), // ISO-8601
56
+ timestamp: z.string().datetime({ offset: true }).optional(), // ISO-8601 with timezone
57
57
  });
58
58
  export type Evaluation = z.infer<typeof EvaluationSchema>;
59
59
 
@@ -91,7 +91,7 @@ export const WeightedEvaluationSchema = z.object({
91
91
  criteria: z.record(z.string(), WeightedCriterionEvaluationSchema),
92
92
  overall_feedback: z.string(),
93
93
  retry_suggestion: z.string().nullable(),
94
- timestamp: z.string().optional(), // ISO-8601
94
+ timestamp: z.string().datetime({ offset: true }).optional(), // ISO-8601 with timezone
95
95
  /** Average weight across all criteria (indicates overall confidence) */
96
96
  average_weight: z.number().min(0).max(1).optional(),
97
97
  /** Raw score before weighting */
@@ -94,7 +94,7 @@ export const SwarmSpawnResultSchema = z.object({
94
94
  coordinator_name: z.string(), // Agent Mail name of coordinator
95
95
  thread_id: z.string(), // Agent Mail thread for this swarm
96
96
  agents: z.array(SpawnedAgentSchema),
97
- started_at: z.string(), // ISO-8601
97
+ started_at: z.string().datetime({ offset: true }), // ISO-8601 with timezone
98
98
  });
99
99
  export type SwarmSpawnResult = z.infer<typeof SwarmSpawnResultSchema>;
100
100
 
@@ -109,7 +109,7 @@ export const AgentProgressSchema = z.object({
109
109
  message: z.string().optional(),
110
110
  files_touched: z.array(z.string()).optional(),
111
111
  blockers: z.array(z.string()).optional(),
112
- timestamp: z.string(), // ISO-8601
112
+ timestamp: z.string().datetime({ offset: true }), // ISO-8601 with timezone
113
113
  });
114
114
  export type AgentProgress = z.infer<typeof AgentProgressSchema>;
115
115
 
@@ -124,6 +124,6 @@ export const SwarmStatusSchema = z.object({
124
124
  failed: z.number().int().min(0),
125
125
  blocked: z.number().int().min(0),
126
126
  agents: z.array(SpawnedAgentSchema),
127
- last_update: z.string(), // ISO-8601
127
+ last_update: z.string().datetime({ offset: true }), // ISO-8601 with timezone
128
128
  });
129
129
  export type SwarmStatus = z.infer<typeof SwarmStatusSchema>;
package/src/storage.ts CHANGED
@@ -73,20 +73,35 @@ async function resolveSemanticMemoryCommand(): Promise<string[]> {
73
73
  async function execSemanticMemory(
74
74
  args: string[],
75
75
  ): Promise<{ exitCode: number; stdout: Buffer; stderr: Buffer }> {
76
- const cmd = await resolveSemanticMemoryCommand();
77
- const fullCmd = [...cmd, ...args];
78
-
79
- // Use Bun.spawn for dynamic command arrays
80
- const proc = Bun.spawn(fullCmd, {
81
- stdout: "pipe",
82
- stderr: "pipe",
83
- });
76
+ try {
77
+ const cmd = await resolveSemanticMemoryCommand();
78
+ const fullCmd = [...cmd, ...args];
84
79
 
85
- const stdout = Buffer.from(await new Response(proc.stdout).arrayBuffer());
86
- const stderr = Buffer.from(await new Response(proc.stderr).arrayBuffer());
87
- const exitCode = await proc.exited;
80
+ // Use Bun.spawn for dynamic command arrays
81
+ const proc = Bun.spawn(fullCmd, {
82
+ stdout: "pipe",
83
+ stderr: "pipe",
84
+ });
88
85
 
89
- return { exitCode, stdout, stderr };
86
+ try {
87
+ const stdout = Buffer.from(await new Response(proc.stdout).arrayBuffer());
88
+ const stderr = Buffer.from(await new Response(proc.stderr).arrayBuffer());
89
+ const exitCode = await proc.exited;
90
+
91
+ return { exitCode, stdout, stderr };
92
+ } finally {
93
+ // Ensure process cleanup
94
+ proc.kill();
95
+ }
96
+ } catch (error) {
97
+ // Return structured error result on exceptions
98
+ const errorMessage = error instanceof Error ? error.message : String(error);
99
+ return {
100
+ exitCode: 1,
101
+ stdout: Buffer.from(""),
102
+ stderr: Buffer.from(`Error executing semantic-memory: ${errorMessage}`),
103
+ };
104
+ }
90
105
  }
91
106
 
92
107
  /**
@@ -646,17 +661,22 @@ export async function createStorageWithFallback(
646
661
  // ============================================================================
647
662
 
648
663
  let globalStorage: LearningStorage | null = null;
664
+ let globalStoragePromise: Promise<LearningStorage> | null = null;
649
665
 
650
666
  /**
651
667
  * Get or create the global storage instance
652
668
  *
653
669
  * Uses semantic-memory by default, with automatic fallback to in-memory.
670
+ * Prevents race conditions by storing the initialization promise.
654
671
  */
655
672
  export async function getStorage(): Promise<LearningStorage> {
656
- if (!globalStorage) {
657
- globalStorage = await createStorageWithFallback();
673
+ if (!globalStoragePromise) {
674
+ globalStoragePromise = createStorageWithFallback().then((storage) => {
675
+ globalStorage = storage;
676
+ return storage;
677
+ });
658
678
  }
659
- return globalStorage;
679
+ return globalStoragePromise;
660
680
  }
661
681
 
662
682
  /**
@@ -666,6 +686,7 @@ export async function getStorage(): Promise<LearningStorage> {
666
686
  */
667
687
  export function setStorage(storage: LearningStorage): void {
668
688
  globalStorage = storage;
689
+ globalStoragePromise = Promise.resolve(storage);
669
690
  }
670
691
 
671
692
  /**
@@ -676,4 +697,5 @@ export async function resetStorage(): Promise<void> {
676
697
  await globalStorage.close();
677
698
  globalStorage = null;
678
699
  }
700
+ globalStoragePromise = null;
679
701
  }