pi-harness-runtime 0.10.13 → 0.10.14

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Master Planner — RFC-0017
3
+ *
4
+ * Converts a human requirement into an executable task graph.
5
+ * Uses a planning LLM to decompose the requirement into tasks with dependencies.
6
+ */
7
+ const DEFAULT_SYSTEM_PROMPT = `You are a software project planner. Given a human requirement, decompose it into a clear task list.
8
+
9
+ Rules:
10
+ 1. Each task should be atomic and independently testable
11
+ 2. Tasks must be ordered with proper dependencies
12
+ 3. Include acceptance criteria for each task
13
+ 4. Consider: analysis, implementation, testing, review phases
14
+ 5. Output ONLY valid JSON in the specified format
15
+
16
+ Output format:
17
+ {
18
+ "tasks": [
19
+ {
20
+ "id": "task-001",
21
+ "title": "Descriptive title",
22
+ "description": "What this task does",
23
+ "dependencies": [], // array of task IDs this depends on
24
+ "acceptanceCriteria": ["criterion 1", "criterion 2"]
25
+ }
26
+ ]
27
+ }`;
28
+ export class MasterPlanner {
29
+ options;
30
+ constructor(options = {}) {
31
+ this.options = options;
32
+ }
33
+ /**
34
+ * Create a plan from a requirement
35
+ */
36
+ async createPlan(requirement, jobId, rootDir) {
37
+ try {
38
+ // Build the planning prompt
39
+ const userPrompt = `Human requirement:\n${requirement}\n\nMax tasks: ${this.options.maxTasks ?? 20}`;
40
+ let taskList;
41
+ if (this.options.planningProvider) {
42
+ // Use LLM to generate task list
43
+ const response = await this.options.planningProvider.call(userPrompt, DEFAULT_SYSTEM_PROMPT);
44
+ const parsed = this.parsePlanningResponse(response);
45
+ if (!parsed) {
46
+ return { success: false, error: "Failed to parse planning response" };
47
+ }
48
+ taskList = parsed;
49
+ }
50
+ else {
51
+ // Use heuristic planner for simple requirements
52
+ taskList = this.heuristicPlan(requirement);
53
+ }
54
+ // Validate the task list
55
+ if (taskList.length === 0) {
56
+ return { success: false, error: "No tasks generated from requirement" };
57
+ }
58
+ // Validate dependencies (no cycles, all deps exist)
59
+ const taskIds = new Set(taskList.map((t) => t.id));
60
+ for (const task of taskList) {
61
+ for (const dep of task.dependencies) {
62
+ if (!taskIds.has(dep)) {
63
+ return {
64
+ success: false,
65
+ error: `Task ${task.id} has invalid dependency: ${dep}`,
66
+ };
67
+ }
68
+ }
69
+ }
70
+ // Create task graph
71
+ const graphManager = new TaskGraphManager({ jobId });
72
+ for (const task of taskList) {
73
+ graphManager.addTask(task.id, task.title, task.description, task.dependencies, task.acceptanceCriteria);
74
+ }
75
+ const graph = graphManager.getGraph();
76
+ // Save the graph
77
+ await graphManager.save(rootDir);
78
+ return { success: true, graph };
79
+ }
80
+ catch (error) {
81
+ return { success: false, error: String(error) };
82
+ }
83
+ }
84
+ /**
85
+ * Parse planning response from LLM
86
+ */
87
+ parsePlanningResponse(response) {
88
+ // Try to extract JSON from response
89
+ const jsonMatch = response.match(/```json\n([\s\S]*?)\n```/) ??
90
+ response.match(/\{[\s\S]*"tasks"[\s\S]*\}/);
91
+ if (!jsonMatch) {
92
+ console.error("Failed to extract JSON from planning response");
93
+ return null;
94
+ }
95
+ const jsonStr = jsonMatch[1] ?? jsonMatch[0];
96
+ try {
97
+ const parsed = JSON.parse(jsonStr);
98
+ return parsed.tasks ?? [];
99
+ }
100
+ catch {
101
+ console.error("Failed to parse JSON from planning response");
102
+ return null;
103
+ }
104
+ }
105
+ /**
106
+ * Heuristic planner for simple requirements
107
+ * Used when no LLM provider is available
108
+ */
109
+ heuristicPlan(requirement) {
110
+ const tasks = [];
111
+ const req = requirement.toLowerCase();
112
+ // Task 1: Analysis
113
+ tasks.push({
114
+ id: "task-001",
115
+ title: "Analyze requirements",
116
+ description: `Analyze and document the requirements: ${requirement}`,
117
+ dependencies: [],
118
+ acceptanceCriteria: [
119
+ "Requirements are clearly documented",
120
+ "All edge cases identified",
121
+ "Technical approach defined",
122
+ ],
123
+ });
124
+ // Task 2: Implementation
125
+ if (req.includes("api") ||
126
+ req.includes("endpoint") ||
127
+ req.includes("backend")) {
128
+ tasks.push({
129
+ id: "task-002",
130
+ title: "Implement API endpoints",
131
+ description: "Create API endpoints based on requirements",
132
+ dependencies: ["task-001"],
133
+ acceptanceCriteria: [
134
+ "Endpoints return correct responses",
135
+ "Error handling implemented",
136
+ "Input validation in place",
137
+ ],
138
+ });
139
+ }
140
+ if (req.includes("database") ||
141
+ req.includes("model") ||
142
+ req.includes("schema")) {
143
+ tasks.push({
144
+ id: "task-003",
145
+ title: "Implement database schema",
146
+ description: "Create database models and migrations",
147
+ dependencies: ["task-001"],
148
+ acceptanceCriteria: [
149
+ "Schema matches requirements",
150
+ "Migrations run successfully",
151
+ "Relationships defined correctly",
152
+ ],
153
+ });
154
+ }
155
+ if (req.includes("ui") ||
156
+ req.includes("frontend") ||
157
+ req.includes("page") ||
158
+ req.includes("component")) {
159
+ tasks.push({
160
+ id: "task-004",
161
+ title: "Implement UI components",
162
+ description: "Create frontend UI components",
163
+ dependencies: ["task-001"],
164
+ acceptanceCriteria: [
165
+ "Components match design",
166
+ "Responsive on all devices",
167
+ "Accessible",
168
+ ],
169
+ });
170
+ }
171
+ // Task 5: Tests
172
+ const implDeps = tasks
173
+ .filter((t) => t.id.startsWith("task-00"))
174
+ .map((t) => t.id);
175
+ tasks.push({
176
+ id: "task-010",
177
+ title: "Write unit tests",
178
+ description: "Write unit tests for all implemented code",
179
+ dependencies: implDeps.length > 0 ? implDeps : ["task-001"],
180
+ acceptanceCriteria: [
181
+ "All new code has >80% test coverage",
182
+ "All tests pass",
183
+ "Edge cases covered",
184
+ ],
185
+ });
186
+ // Task 6: Integration
187
+ tasks.push({
188
+ id: "task-011",
189
+ title: "Integration testing",
190
+ description: "Run integration tests and verify end-to-end flow",
191
+ dependencies: ["task-010"],
192
+ acceptanceCriteria: [
193
+ "Integration tests pass",
194
+ "No regression in existing functionality",
195
+ ],
196
+ });
197
+ // Task 7: Review
198
+ tasks.push({
199
+ id: "task-012",
200
+ title: "Code review",
201
+ description: "Review code for quality, security, and best practices",
202
+ dependencies: ["task-011"],
203
+ acceptanceCriteria: [
204
+ "Code follows project style guide",
205
+ "No security vulnerabilities",
206
+ "Documentation updated",
207
+ ],
208
+ });
209
+ return tasks;
210
+ }
211
+ /**
212
+ * Generate a simple task ID
213
+ */
214
+ static generateTaskId(index) {
215
+ return `task-${String(index).padStart(3, "0")}`;
216
+ }
217
+ }
218
+ /**
219
+ * Parse a requirement into a basic task list (synchronous, no LLM)
220
+ */
221
+ export function parseRequirementIntoTasks(requirement, jobId) {
222
+ const planner = new MasterPlanner();
223
+ const taskList = planner.heuristicPlan(requirement);
224
+ const graphManager = new TaskGraphManager({ jobId });
225
+ for (const task of taskList) {
226
+ graphManager.addTask(task.id, task.title, task.description, task.dependencies, task.acceptanceCriteria);
227
+ }
228
+ return graphManager;
229
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Harness Notification Events — RFC-0022
3
+ *
4
+ * Integrates NotificationCenter with the harness runtime.
5
+ * Sends mobile alerts for all runtime events.
6
+ *
7
+ * Events:
8
+ * JobStarted, TaskCompleted, TaskFailed, QuotaPaused,
9
+ * ResumeScheduled, ContextCompacted, OutputLimitContinued,
10
+ * E2EFailed, HumanReviewNeeded, ReadyForClient, JobCancelled, Error
11
+ */
12
+ import { NotificationCenter } from "../packages/notification/notification-center.js";
13
+ export class HarnessNotificationEvents {
14
+ center;
15
+ config;
16
+ machine = null;
17
+ graph = null;
18
+ constructor(config) {
19
+ this.config = config;
20
+ this.center = new NotificationCenter(config.notificationConfig);
21
+ }
22
+ /**
23
+ * Initialize the notification center
24
+ */
25
+ async initialize() {
26
+ await this.center.initialize();
27
+ }
28
+ /**
29
+ * Attach to job state machine to receive events
30
+ */
31
+ attachToMachine(machine) {
32
+ this.machine = machine;
33
+ }
34
+ /**
35
+ * Attach to task graph to get task info
36
+ */
37
+ attachToGraph(graph) {
38
+ this.graph = graph;
39
+ }
40
+ /**
41
+ * Check if notifications are configured
42
+ */
43
+ hasChannels() {
44
+ return this.center.hasChannels();
45
+ }
46
+ /**
47
+ * List configured channels
48
+ */
49
+ listChannels() {
50
+ return this.center.listChannels();
51
+ }
52
+ // --- Event Emitters ------------------------------------------------
53
+ /**
54
+ * Emit JobStarted event
55
+ */
56
+ async emitJobStarted() {
57
+ await this.emit("JobStarted");
58
+ }
59
+ /**
60
+ * Emit TaskCompleted event
61
+ */
62
+ async emitTaskCompleted(taskId, taskTitle) {
63
+ await this.emit("TaskCompleted", { taskId, taskTitle });
64
+ }
65
+ /**
66
+ * Emit TaskFailed event
67
+ */
68
+ async emitTaskFailed(taskId, taskTitle, error) {
69
+ await this.emit("TaskFailed", { taskId, taskTitle, error });
70
+ }
71
+ /**
72
+ * Emit QuotaPaused event
73
+ */
74
+ async emitQuotaPaused(resumeAt) {
75
+ await this.emit("QuotaPaused", {
76
+ error: resumeAt ? `Resumes at ${resumeAt}` : undefined,
77
+ });
78
+ }
79
+ /**
80
+ * Emit ResumeScheduled event
81
+ */
82
+ async emitResumeScheduled(resumeAt) {
83
+ await this.emit("ResumeScheduled", { error: resumeAt });
84
+ }
85
+ /**
86
+ * Emit ContextCompacted event
87
+ */
88
+ async emitContextCompacted(tokensCompacted) {
89
+ await this.emit("ContextCompacted", {
90
+ error: tokensCompacted
91
+ ? `Compacted ${tokensCompacted.toLocaleString()} tokens`
92
+ : undefined,
93
+ });
94
+ }
95
+ /**
96
+ * Emit OutputLimitContinued event
97
+ */
98
+ async emitOutputLimitContinued(attempt) {
99
+ await this.emit("OutputLimitContinued", {
100
+ error: `Attempt ${attempt}`,
101
+ });
102
+ }
103
+ /**
104
+ * Emit E2EFailed event
105
+ */
106
+ async emitE2EFailed(scenarioId) {
107
+ await this.emit("E2EFailed", {
108
+ error: scenarioId ? `Scenario: ${scenarioId}` : undefined,
109
+ });
110
+ }
111
+ /**
112
+ * Emit HumanReviewNeeded event
113
+ */
114
+ async emitHumanReviewNeeded(taskId, reason) {
115
+ await this.emit("HumanReviewNeeded", { taskId, error: reason });
116
+ }
117
+ /**
118
+ * Emit ReadyForClient event
119
+ */
120
+ async emitReadyForClient() {
121
+ await this.emit("ReadyForClient");
122
+ }
123
+ /**
124
+ * Emit JobCancelled event
125
+ */
126
+ async emitJobCancelled(reason) {
127
+ await this.emit("JobCancelled", { error: reason });
128
+ }
129
+ /**
130
+ * Emit Error event
131
+ */
132
+ async emitError(error) {
133
+ await this.emit("Error", { error });
134
+ }
135
+ // --- State Machine Event Listeners ---------------------------------
136
+ /**
137
+ * Wire up with JobStateMachine to emit events on transitions
138
+ */
139
+ wireWithStateMachine(machine) {
140
+ this.machine = machine;
141
+ // Listen to state machine events via polling or callback
142
+ // This would need the state machine to emit events
143
+ }
144
+ /**
145
+ * Check current state and emit appropriate events
146
+ */
147
+ async checkAndEmitStateChange(oldState, newState) {
148
+ // Map state machine states to notification events
149
+ const stateEventMap = {
150
+ paused_quota: "QuotaPaused",
151
+ waiting_human: "HumanReviewNeeded",
152
+ ready_for_client: "ReadyForClient",
153
+ cancelled: "JobCancelled",
154
+ planning: "JobStarted",
155
+ };
156
+ const event = stateEventMap[newState];
157
+ if (event) {
158
+ await this.emit(event);
159
+ }
160
+ }
161
+ // --- Private Methods -----------------------------------------------
162
+ async emit(event, extra) {
163
+ if (!this.center.hasChannels()) {
164
+ return; // No channels configured, skip
165
+ }
166
+ const context = {
167
+ jobId: this.config.jobId,
168
+ requirement: this.config.requirement,
169
+ ...extra,
170
+ };
171
+ try {
172
+ const results = await this.center.notify(event, context);
173
+ // Log results (but don't fail if notification fails)
174
+ for (const result of results) {
175
+ if (!result.success) {
176
+ console.warn(`[NotificationEvents] Failed to send ${event} to ${result.channel}: ${result.error}`);
177
+ }
178
+ }
179
+ }
180
+ catch (error) {
181
+ // Never crash the runtime due to notification failure
182
+ console.error(`[NotificationEvents] Notification error: ${error}`);
183
+ }
184
+ }
185
+ }
186
+ /**
187
+ * Create notification config from environment variables
188
+ */
189
+ export function createNotificationConfigFromEnv() {
190
+ const channels = [];
191
+ // Telegram
192
+ if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
193
+ channels.push({
194
+ id: "telegram",
195
+ type: "telegram",
196
+ enabled: true,
197
+ config: {
198
+ botToken: process.env.TELEGRAM_BOT_TOKEN,
199
+ chatId: process.env.TELEGRAM_CHAT_ID,
200
+ },
201
+ });
202
+ }
203
+ // Ntfy
204
+ if (process.env.NTFY_TOPIC) {
205
+ channels.push({
206
+ id: "ntfy",
207
+ type: "ntfy",
208
+ enabled: true,
209
+ config: {
210
+ server: process.env.NTFY_SERVER ?? "https://ntfy.sh",
211
+ topic: process.env.NTFY_TOPIC,
212
+ authToken: process.env.NTFY_TOKEN,
213
+ },
214
+ });
215
+ }
216
+ // Webhook
217
+ if (process.env.NOTIFICATION_WEBHOOK_URL) {
218
+ channels.push({
219
+ id: "webhook",
220
+ type: "webhook",
221
+ enabled: true,
222
+ config: {
223
+ url: process.env.NOTIFICATION_WEBHOOK_URL,
224
+ method: process.env.NOTIFICATION_WEBHOOK_METHOD ?? "POST",
225
+ authToken: process.env.NOTIFICATION_WEBHOOK_TOKEN,
226
+ },
227
+ });
228
+ }
229
+ if (channels.length === 0) {
230
+ return undefined;
231
+ }
232
+ return { channels, enabled: true };
233
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Output Token Limit Handler — RFC-0020
3
+ *
4
+ * Handles model responses that stop because the maximum output token limit was reached.
5
+ * Coordinates with AutoCompactEngine (RFC-0019) and PartialRecovery (RFC-0021).
6
+ *
7
+ * Classification Matrix:
8
+ * | Failure Type | Runtime Action |
9
+ * |--------------------|--------------------------|
10
+ * | Quota exhausted | pause until reset |
11
+ * | Context full | compact and resume |
12
+ * | Output token limit | continue same task |
13
+ * | Unknown error | retry or escalate |
14
+ */
15
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ const OUTPUT_LIMIT_PATTERNS = [
19
+ /reached the maximum output token limit/i,
20
+ /output token limit/i,
21
+ /response may be incomplete/i,
22
+ /stop_reason.*length/i,
23
+ /finish_reason.*length/i,
24
+ /max_tokens.*exceeded/i,
25
+ /completion.*truncated/i,
26
+ /model.*stopped.*token/i,
27
+ ];
28
+ export class OutputLimitHandler {
29
+ rootDir;
30
+ jobId;
31
+ taskId;
32
+ maxAttempts;
33
+ backoffMs;
34
+ requireValidation;
35
+ attempts = 0;
36
+ partials = [];
37
+ constructor(config) {
38
+ this.rootDir =
39
+ config.rootDir ??
40
+ join(homedir(), ".pi", "harness", config.jobId, "partial", config.taskId);
41
+ this.jobId = config.jobId;
42
+ this.taskId = config.taskId;
43
+ this.maxAttempts = config.maxContinueAttempts ?? 5;
44
+ this.backoffMs = config.continuationBackoffMs ?? 1000;
45
+ this.requireValidation = config.requireExpectedOutputValidation ?? true;
46
+ this.ensureDir();
47
+ }
48
+ /**
49
+ * Detect if an error or response indicates output limit was reached
50
+ */
51
+ detectOutputLimit(error, response) {
52
+ // Check error message
53
+ if (error) {
54
+ const errorStr = String(error).toLowerCase();
55
+ for (const pattern of OUTPUT_LIMIT_PATTERNS) {
56
+ if (pattern.test(errorStr)) {
57
+ return true;
58
+ }
59
+ }
60
+ }
61
+ // Check finish reason
62
+ if (response?.finishReason === "length") {
63
+ return true;
64
+ }
65
+ return false;
66
+ }
67
+ /**
68
+ * Classify the type of failure for proper handling
69
+ */
70
+ classifyFailure(error, response) {
71
+ const errorStr = String(error ?? "").toLowerCase();
72
+ // Check for quota exhaustion
73
+ if (/error.*quota/i.test(errorStr) ||
74
+ /error.*2056/i.test(errorStr) ||
75
+ /error.*insufficient_quota/i.test(errorStr)) {
76
+ return "quota_exhausted";
77
+ }
78
+ // Check for context/sequence length
79
+ if (/error.*context.*length/i.test(errorStr) ||
80
+ /error.*too many tokens/i.test(errorStr) ||
81
+ /error.*maximum context/i.test(errorStr)) {
82
+ return "context_full";
83
+ }
84
+ // Check for output token limit
85
+ if (this.detectOutputLimit(error, response) ||
86
+ response?.finishReason === "length") {
87
+ return "output_limit";
88
+ }
89
+ return "unknown";
90
+ }
91
+ /**
92
+ * Handle output limit - save partial and prepare continuation
93
+ */
94
+ async handleOutputLimit(partialContent, finishReason = "length") {
95
+ this.attempts++;
96
+ const timestamp = new Date().toISOString();
97
+ // Save partial response
98
+ const partialPath = this.savePartial(partialContent, this.attempts);
99
+ this.partials.push(partialPath);
100
+ // Create event
101
+ const event = {
102
+ timestamp,
103
+ jobId: this.jobId,
104
+ taskId: this.taskId,
105
+ reason: "output_token_limit",
106
+ partialContent,
107
+ finishReason: finishReason,
108
+ attempts: this.attempts,
109
+ };
110
+ // Save event
111
+ this.saveEvent(event);
112
+ // Wait with backoff before continuing
113
+ await this.backoff();
114
+ return event;
115
+ }
116
+ /**
117
+ * Check if we should continue (respects max attempts)
118
+ */
119
+ shouldContinue() {
120
+ return this.attempts < this.maxAttempts;
121
+ }
122
+ /**
123
+ * Get current attempt count
124
+ */
125
+ getAttempts() {
126
+ return this.attempts;
127
+ }
128
+ /**
129
+ * Get all partial responses
130
+ */
131
+ getPartials() {
132
+ return [...this.partials];
133
+ }
134
+ /**
135
+ * Merge partial responses
136
+ */
137
+ mergePartials() {
138
+ const merged = [];
139
+ for (const partialPath of this.partials) {
140
+ if (existsSync(partialPath)) {
141
+ const content = readFileSync(partialPath, "utf-8");
142
+ merged.push(content);
143
+ }
144
+ }
145
+ // v0.1: simple concatenation as markdown sections
146
+ return merged
147
+ .map((p, i) => `## Partial ${i + 1}\n\n${p}`)
148
+ .join("\n\n---\n\n");
149
+ }
150
+ /**
151
+ * Build continue message for the next turn
152
+ */
153
+ buildContinueMessage(additionalContext) {
154
+ const merged = this.mergePartials();
155
+ const lines = [
156
+ "# Continue From Partial Response",
157
+ "",
158
+ "The previous response was truncated due to output token limit.",
159
+ "",
160
+ "## Merged Partial Content",
161
+ "",
162
+ merged,
163
+ ];
164
+ if (additionalContext) {
165
+ lines.push("", "## Additional Context");
166
+ lines.push("", additionalContext);
167
+ }
168
+ lines.push("", "## Instructions");
169
+ lines.push("");
170
+ lines.push("1. Review the partial content above");
171
+ lines.push("2. Continue from where the response was truncated");
172
+ lines.push("3. Do not repeat content that already appears above");
173
+ lines.push(`4. This is attempt ${this.attempts + 1} of ${this.maxAttempts}`);
174
+ return lines.join("\n");
175
+ }
176
+ /**
177
+ * Validate expected output if configured
178
+ */
179
+ validateOutput(content, expected) {
180
+ if (!this.requireValidation) {
181
+ return true;
182
+ }
183
+ if (expected.validate) {
184
+ return expected.validate(content);
185
+ }
186
+ // Basic validation: content should be longer than partial
187
+ return content.length > this.getPartials()[0]?.length ?? 0;
188
+ }
189
+ /**
190
+ * Reset for a new task
191
+ */
192
+ reset() {
193
+ this.attempts = 0;
194
+ this.partials = [];
195
+ }
196
+ /**
197
+ * Check if escalation is needed (max attempts reached)
198
+ */
199
+ shouldEscalate() {
200
+ return this.attempts >= this.maxAttempts;
201
+ }
202
+ // --- Private Methods ------------------------------------------------
203
+ ensureDir() {
204
+ if (!existsSync(this.rootDir)) {
205
+ mkdirSync(this.rootDir, { recursive: true });
206
+ }
207
+ }
208
+ savePartial(content, attempt) {
209
+ const filename = `partial_${String(attempt).padStart(3, "0")}.md`;
210
+ const path = join(this.rootDir, filename);
211
+ writeFileSync(path, content, "utf-8");
212
+ return path;
213
+ }
214
+ saveEvent(event) {
215
+ const eventsPath = join(this.rootDir, "events.jsonl");
216
+ writeFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
217
+ // Also save recovery status
218
+ const statusPath = join(this.rootDir, "recovery_status.json");
219
+ const status = {
220
+ taskId: this.taskId,
221
+ status: this.shouldContinue() ? "continuing" : "escalated",
222
+ partials: this.partials.map((p) => p.split("/").pop()),
223
+ mergedOutput: "merged.md",
224
+ attempts: this.attempts,
225
+ lastError: event.reason,
226
+ };
227
+ writeFileSync(statusPath, JSON.stringify(status, null, 2) + "\n", "utf-8");
228
+ }
229
+ async backoff() {
230
+ const delay = this.backoffMs * 2 ** (this.attempts - 1);
231
+ await new Promise((resolve) => setTimeout(resolve, delay));
232
+ }
233
+ }