pi-crew 0.5.5 → 0.5.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/CHANGELOG.md +153 -0
- package/README.md +17 -1
- package/docs/architecture.md +2 -0
- package/docs/migration-v0.4-v0.5.md +19 -2
- package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
- package/package.json +7 -5
- package/src/benchmark/benchmark-runner.ts +45 -0
- package/src/benchmark/feedback-loop.ts +5 -0
- package/src/config/config.ts +38 -4
- package/src/config/defaults.ts +5 -0
- package/src/config/suggestions.ts +8 -0
- package/src/extension/async-notifier.ts +10 -1
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/extension/notification-router.ts +18 -0
- package/src/extension/register.ts +13 -17
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/team-tool/anchor.ts +201 -0
- package/src/extension/team-tool/api.ts +2 -1
- package/src/extension/team-tool/auto-summarize.ts +154 -0
- package/src/extension/team-tool/run.ts +37 -2
- package/src/extension/team-tool.ts +44 -2
- package/src/hooks/registry.ts +1 -3
- package/src/observability/event-bus.ts +13 -4
- package/src/observability/event-to-metric.ts +0 -2
- package/src/runtime/anchor-manager.ts +473 -0
- package/src/runtime/async-runner.ts +8 -4
- package/src/runtime/auto-summarize.ts +350 -0
- package/src/runtime/background-runner.ts +2 -1
- package/src/runtime/budget-tracker.ts +354 -0
- package/src/runtime/chain-runner.ts +507 -0
- package/src/runtime/child-pi.ts +24 -6
- package/src/runtime/crash-recovery.ts +5 -4
- package/src/runtime/crew-agent-records.ts +32 -1
- package/src/runtime/custom-tools/irc-tool.ts +13 -0
- package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
- package/src/runtime/delivery-coordinator.ts +10 -3
- package/src/runtime/dynamic-script-runner.ts +482 -0
- package/src/runtime/handoff-manager.ts +589 -0
- package/src/runtime/hidden-handoff.ts +424 -0
- package/src/runtime/live-agent-manager.ts +20 -4
- package/src/runtime/live-session-runtime.ts +39 -4
- package/src/runtime/manifest-cache.ts +2 -1
- package/src/runtime/model-resolver.ts +16 -4
- package/src/runtime/phase-tracker.ts +373 -0
- package/src/runtime/pipeline-runner.ts +514 -0
- package/src/runtime/retry-runner.ts +354 -0
- package/src/runtime/sandbox.ts +252 -0
- package/src/runtime/scheduler.ts +7 -2
- package/src/runtime/subagent-manager.ts +1 -1
- package/src/runtime/task-graph.ts +11 -1
- package/src/runtime/task-runner.ts +15 -1
- package/src/runtime/team-runner.ts +4 -3
- package/src/schema/team-tool-schema.ts +31 -0
- package/src/skills/discover-skills.ts +5 -0
- package/src/state/active-run-registry.ts +19 -3
- package/src/state/contracts.ts +9 -0
- package/src/state/crew-init.ts +3 -3
- package/src/state/decision-ledger.ts +26 -32
- package/src/state/event-log-rotation.ts +2 -2
- package/src/state/event-log.ts +17 -4
- package/src/state/mailbox.ts +35 -1
- package/src/state/run-cache.ts +18 -8
- package/src/tools/safe-bash-extension.ts +1 -0
- package/src/tools/safe-bash.ts +153 -20
- package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
- package/src/ui/powerbar-publisher.ts +1 -0
- package/src/ui/transcript-cache.ts +13 -0
- package/src/utils/bm25-search.ts +16 -8
- package/src/utils/env-filter.ts +8 -5
- package/src/utils/redaction.ts +169 -15
- package/src/utils/sse-parser.ts +10 -1
- package/src/worktree/cleanup.ts +6 -1
- package/workflows/chain.workflow.md +252 -0
- package/workflows/pipeline.workflow.md +27 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetryRunner - Execute tasks with retry support and summary accumulation.
|
|
3
|
+
*
|
|
4
|
+
* Based on pi-boomerang's --rethrow pattern:
|
|
5
|
+
* - Retries failed tasks up to maxAttempts
|
|
6
|
+
* - Generates handoffs between attempts
|
|
7
|
+
* - Accumulates context from previous attempts
|
|
8
|
+
* - Supports exponential backoff
|
|
9
|
+
* - Limits handoff accumulation to prevent memory leaks
|
|
10
|
+
*
|
|
11
|
+
* @see docs/pi-boomerang-integration-plan.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { HandoffSummary, HandoffManager, TaskPacket, TaskResult } from "./handoff-manager.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Retry configuration.
|
|
18
|
+
*/
|
|
19
|
+
export interface RetryConfig {
|
|
20
|
+
/** Maximum number of retry attempts */
|
|
21
|
+
maxAttempts: number;
|
|
22
|
+
/** Generate summary between attempts for context accumulation */
|
|
23
|
+
summaryBetweenAttempts?: boolean;
|
|
24
|
+
/** Stop retrying if task succeeds */
|
|
25
|
+
stopOnSuccess?: boolean;
|
|
26
|
+
/** Base backoff delay in milliseconds (multiplied by attempt number) */
|
|
27
|
+
backoffMs?: number;
|
|
28
|
+
/** Backoff multiplier (default: 1) */
|
|
29
|
+
backoffMultiplier?: number;
|
|
30
|
+
/** Maximum backoff delay cap in milliseconds */
|
|
31
|
+
maxBackoffMs?: number;
|
|
32
|
+
/** Custom retry condition (return true to retry) */
|
|
33
|
+
retryCondition?: (result: TaskResult, attempt: number) => boolean;
|
|
34
|
+
/** Maximum handoffs to retain (default: 100) - prevents memory leaks */
|
|
35
|
+
maxHandoffs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of a single attempt.
|
|
40
|
+
*/
|
|
41
|
+
export interface AttemptResult {
|
|
42
|
+
attempt: number;
|
|
43
|
+
result: TaskResult;
|
|
44
|
+
summary?: HandoffSummary;
|
|
45
|
+
duration: number;
|
|
46
|
+
error?: string;
|
|
47
|
+
timestamp: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Final retry result.
|
|
52
|
+
*/
|
|
53
|
+
export interface RetryResult {
|
|
54
|
+
success: boolean;
|
|
55
|
+
attempts: AttemptResult[];
|
|
56
|
+
finalResult?: TaskResult;
|
|
57
|
+
totalHandoffs: HandoffSummary[];
|
|
58
|
+
totalDuration: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Task runner interface (minimal for retry functionality).
|
|
63
|
+
*/
|
|
64
|
+
export interface TaskRunnerLike {
|
|
65
|
+
runTask(packet: TaskPacket): Promise<TaskResult>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* RetryRunner handles task execution with automatic retry and summary accumulation.
|
|
70
|
+
*/
|
|
71
|
+
export class RetryRunner {
|
|
72
|
+
private _disposed = false;
|
|
73
|
+
private _handoffs: HandoffSummary[] = [];
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
private taskRunner: TaskRunnerLike,
|
|
77
|
+
private handoffManager: HandoffManager,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if this runner has been disposed.
|
|
82
|
+
*/
|
|
83
|
+
get isDisposed(): boolean {
|
|
84
|
+
return this._disposed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dispose of resources held by this runner.
|
|
89
|
+
* Clears any accumulated state and prevents further operations.
|
|
90
|
+
*/
|
|
91
|
+
dispose(): void {
|
|
92
|
+
this._disposed = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Clear accumulated handoffs to free memory.
|
|
97
|
+
* Useful when you want to reset state without disposing.
|
|
98
|
+
*/
|
|
99
|
+
clearHandoffs(): void {
|
|
100
|
+
this._handoffs = [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get the effective max handoffs limit from config or default.
|
|
105
|
+
*/
|
|
106
|
+
private getMaxHandoffs(config: RetryConfig): number {
|
|
107
|
+
return config.maxHandoffs ?? 100;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Trim handoffs array to max size, keeping most recent.
|
|
112
|
+
*/
|
|
113
|
+
private trimHandoffs(handoffs: HandoffSummary[], maxSize: number): HandoffSummary[] {
|
|
114
|
+
if (handoffs.length <= maxSize) {
|
|
115
|
+
return handoffs;
|
|
116
|
+
}
|
|
117
|
+
// Keep the most recent handoffs (last maxSize items)
|
|
118
|
+
return handoffs.slice(-maxSize);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Execute task with retry support.
|
|
123
|
+
* Summaries accumulate between attempts for better context.
|
|
124
|
+
*
|
|
125
|
+
* @param packet - Task packet to execute
|
|
126
|
+
* @param config - Retry configuration (stopOnSuccess defaults to true)
|
|
127
|
+
* @returns Final retry result with all attempts
|
|
128
|
+
*/
|
|
129
|
+
async runWithRetry(
|
|
130
|
+
packet: TaskPacket,
|
|
131
|
+
config: RetryConfig
|
|
132
|
+
): Promise<RetryResult> {
|
|
133
|
+
if (this._disposed) {
|
|
134
|
+
throw new Error("RetryRunner has been disposed");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const attempts: AttemptResult[] = [];
|
|
138
|
+
const handoffs: HandoffSummary[] = [];
|
|
139
|
+
const startTime = Date.now();
|
|
140
|
+
const maxHandoffs = this.getMaxHandoffs(config);
|
|
141
|
+
|
|
142
|
+
// Clear previous handoffs at start of each retry run
|
|
143
|
+
this._handoffs = [];
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
|
|
147
|
+
if (this._disposed) {
|
|
148
|
+
throw new Error("RetryRunner was disposed during retry");
|
|
149
|
+
}
|
|
150
|
+
const attemptStart = Date.now();
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// Inject accumulated handoffs into context
|
|
154
|
+
const enrichedPacket = this.enrichPacketWithHandoffs(
|
|
155
|
+
packet,
|
|
156
|
+
handoffs,
|
|
157
|
+
attempt
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Execute task
|
|
161
|
+
const result = await this.taskRunner.runTask(enrichedPacket);
|
|
162
|
+
|
|
163
|
+
const attemptResult: AttemptResult = {
|
|
164
|
+
attempt,
|
|
165
|
+
result,
|
|
166
|
+
duration: Date.now() - attemptStart,
|
|
167
|
+
timestamp: Date.now(),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Generate summary between attempts
|
|
171
|
+
if (config.summaryBetweenAttempts !== false) {
|
|
172
|
+
const summary = await this.handoffManager.generateSummary(
|
|
173
|
+
packet,
|
|
174
|
+
result
|
|
175
|
+
);
|
|
176
|
+
attemptResult.summary = summary;
|
|
177
|
+
handoffs.push(summary);
|
|
178
|
+
|
|
179
|
+
// Trim handoffs to prevent memory leak
|
|
180
|
+
if (handoffs.length > maxHandoffs) {
|
|
181
|
+
handoffs.splice(0, handoffs.length - maxHandoffs);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
attempts.push(attemptResult);
|
|
186
|
+
|
|
187
|
+
// Stop on success if configured (default to true when undefined)
|
|
188
|
+
if ((config.stopOnSuccess ?? true) && result.outcome === "success") {
|
|
189
|
+
return {
|
|
190
|
+
success: true,
|
|
191
|
+
attempts,
|
|
192
|
+
finalResult: result,
|
|
193
|
+
totalHandoffs: handoffs,
|
|
194
|
+
totalDuration: Date.now() - startTime,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check custom retry condition
|
|
199
|
+
if (config.retryCondition && !config.retryCondition(result, attempt)) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Check default retry condition (retry on failure)
|
|
204
|
+
if (result.outcome !== "success" && attempt < config.maxAttempts) {
|
|
205
|
+
// Apply backoff before retry
|
|
206
|
+
const backoffDelay = this.calculateBackoff(
|
|
207
|
+
config.backoffMs ?? 1000,
|
|
208
|
+
attempt,
|
|
209
|
+
config.backoffMultiplier ?? 1,
|
|
210
|
+
config.maxBackoffMs
|
|
211
|
+
);
|
|
212
|
+
if (backoffDelay > 0) {
|
|
213
|
+
await this.sleep(backoffDelay);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
} catch (error) {
|
|
218
|
+
attempts.push({
|
|
219
|
+
attempt,
|
|
220
|
+
result: {
|
|
221
|
+
outcome: "failure",
|
|
222
|
+
error: error instanceof Error ? error.message : String(error),
|
|
223
|
+
},
|
|
224
|
+
duration: Date.now() - attemptStart,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
error: error instanceof Error ? error.message : String(error),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Apply backoff before retry on error
|
|
230
|
+
if (config.backoffMs && attempt < config.maxAttempts) {
|
|
231
|
+
const backoffDelay = this.calculateBackoff(
|
|
232
|
+
config.backoffMs,
|
|
233
|
+
attempt,
|
|
234
|
+
config.backoffMultiplier ?? 1,
|
|
235
|
+
config.maxBackoffMs
|
|
236
|
+
);
|
|
237
|
+
if (backoffDelay > 0) {
|
|
238
|
+
await this.sleep(backoffDelay);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const finalAttempt = attempts[attempts.length - 1];
|
|
245
|
+
return {
|
|
246
|
+
success: finalAttempt?.result.outcome === "success",
|
|
247
|
+
attempts,
|
|
248
|
+
finalResult: finalAttempt?.result,
|
|
249
|
+
totalHandoffs: this.trimHandoffs(handoffs, maxHandoffs),
|
|
250
|
+
totalDuration: Date.now() - startTime,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Enrich packet with accumulated handoffs from previous attempts.
|
|
256
|
+
*/
|
|
257
|
+
private enrichPacketWithHandoffs(
|
|
258
|
+
packet: TaskPacket,
|
|
259
|
+
handoffs: HandoffSummary[],
|
|
260
|
+
attempt: number
|
|
261
|
+
): TaskPacket {
|
|
262
|
+
if (handoffs.length === 0) {
|
|
263
|
+
return packet;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Build accumulated context from previous attempts
|
|
267
|
+
const accumulatedContext = handoffs.map((h, index) =>
|
|
268
|
+
`## Attempt ${index + 1}: ${h.task}\n` +
|
|
269
|
+
`Outcome: ${h.outcome}\n` +
|
|
270
|
+
`Files: created=${h.filesCreated.join(", ")}, modified=${h.filesModified.join(", ")}\n` +
|
|
271
|
+
`Decisions: ${h.decisions.map(d => d.rationale).join("; ")}\n` +
|
|
272
|
+
`Blockers: ${h.blockers.join(", ")}\n` +
|
|
273
|
+
`Next Steps: ${h.nextSteps.join(", ")}\n`
|
|
274
|
+
).join("\n---\n");
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
...packet,
|
|
278
|
+
context: {
|
|
279
|
+
...packet.context,
|
|
280
|
+
__boomerangAttempts: attempt,
|
|
281
|
+
__boomerangHandoffs: handoffs,
|
|
282
|
+
__boomerangContext: `Previous attempts summary:\n${accumulatedContext}`,
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Calculate backoff delay with optional capping.
|
|
289
|
+
*/
|
|
290
|
+
private calculateBackoff(
|
|
291
|
+
baseMs: number,
|
|
292
|
+
attempt: number,
|
|
293
|
+
multiplier: number,
|
|
294
|
+
maxBackoffMs?: number
|
|
295
|
+
): number {
|
|
296
|
+
let delay = baseMs * Math.pow(multiplier, attempt - 1);
|
|
297
|
+
if (maxBackoffMs !== undefined) {
|
|
298
|
+
delay = Math.min(delay, maxBackoffMs);
|
|
299
|
+
}
|
|
300
|
+
return delay;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Sleep helper.
|
|
305
|
+
*/
|
|
306
|
+
private sleep(ms: number): Promise<void> {
|
|
307
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Create a RetryRunner with default dependencies.
|
|
313
|
+
*/
|
|
314
|
+
export function createRetryRunner(
|
|
315
|
+
taskRunner: TaskRunnerLike,
|
|
316
|
+
handoffManager: HandoffManager
|
|
317
|
+
): RetryRunner {
|
|
318
|
+
return new RetryRunner(taskRunner, handoffManager);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Default retry config for common scenarios.
|
|
323
|
+
*/
|
|
324
|
+
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
325
|
+
maxAttempts: 3,
|
|
326
|
+
summaryBetweenAttempts: true,
|
|
327
|
+
stopOnSuccess: true,
|
|
328
|
+
backoffMs: 1000,
|
|
329
|
+
backoffMultiplier: 2,
|
|
330
|
+
maxBackoffMs: 30000,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Retry config for transient failures (quick retries).
|
|
335
|
+
*/
|
|
336
|
+
export const TRANSIENT_FAILURE_RETRY_CONFIG: RetryConfig = {
|
|
337
|
+
maxAttempts: 2,
|
|
338
|
+
summaryBetweenAttempts: false,
|
|
339
|
+
stopOnSuccess: true,
|
|
340
|
+
backoffMs: 500,
|
|
341
|
+
backoffMultiplier: 1,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Retry config for persistent failures (exponential backoff).
|
|
346
|
+
*/
|
|
347
|
+
export const PERSISTENT_FAILURE_RETRY_CONFIG: RetryConfig = {
|
|
348
|
+
maxAttempts: 5,
|
|
349
|
+
summaryBetweenAttempts: true,
|
|
350
|
+
stopOnSuccess: true,
|
|
351
|
+
backoffMs: 2000,
|
|
352
|
+
backoffMultiplier: 2,
|
|
353
|
+
maxBackoffMs: 60000,
|
|
354
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import * as vm from "node:vm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Forbidden patterns for sandbox security (C4).
|
|
5
|
+
* These are checked during script compilation/validation.
|
|
6
|
+
*/
|
|
7
|
+
const FORBIDDEN_PATTERNS = [
|
|
8
|
+
// ESM patterns
|
|
9
|
+
/import\s*\(/, // Dynamic import()
|
|
10
|
+
/import\s+.*from\s+/, // Static import
|
|
11
|
+
/export\s+(default\s+)?/, // Export statements
|
|
12
|
+
/import\.meta/, // import.meta
|
|
13
|
+
// Module patterns
|
|
14
|
+
/require\s*\(/, // CommonJS require
|
|
15
|
+
/module\./, // module.exports, module.id, etc.
|
|
16
|
+
/__dirname/, // __dirname reference
|
|
17
|
+
/__filename/, // __filename reference
|
|
18
|
+
/\bdefine\s*\(/, // AMD define
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whitelist of allowed identifiers for strict mode.
|
|
23
|
+
* Only these identifiers can be used in sandboxed code.
|
|
24
|
+
*/
|
|
25
|
+
const ALLOWED_IDENTIFIERS = new Set([
|
|
26
|
+
// Built-in constructors
|
|
27
|
+
"Array", "Boolean", "Date", "Error", "Function", "JSON", "Map", "Number", "Object", "Promise", "RegExp", "Set", "String", "Symbol",
|
|
28
|
+
// Static methods
|
|
29
|
+
"ArrayBuffer", "Uint8Array", "parseInt", "parseFloat", "isNaN", "isFinite",
|
|
30
|
+
// URI encoding
|
|
31
|
+
"encodeURI", "decodeURI", "encodeURIComponent", "decodeURIComponent",
|
|
32
|
+
// Math (read-only)
|
|
33
|
+
"Math",
|
|
34
|
+
// Console (safe methods only)
|
|
35
|
+
"console",
|
|
36
|
+
// Process (limited)
|
|
37
|
+
"process",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
Object.freeze(FORBIDDEN_PATTERNS);
|
|
41
|
+
|
|
42
|
+
export interface SandboxOptions {
|
|
43
|
+
timeout?: number;
|
|
44
|
+
globals?: Record<string, unknown>;
|
|
45
|
+
onLog?: (message: string) => void;
|
|
46
|
+
onError?: (message: string) => void;
|
|
47
|
+
onWarn?: (message: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* WorkflowSandbox provides a safe execution context for dynamic JavaScript
|
|
52
|
+
* in pi-crew workflows. It creates a VM context with restricted globals
|
|
53
|
+
* and provides safe console and process objects.
|
|
54
|
+
*/
|
|
55
|
+
export class WorkflowSandbox {
|
|
56
|
+
private context: vm.Context;
|
|
57
|
+
private timeout: number;
|
|
58
|
+
|
|
59
|
+
constructor(options: SandboxOptions = {}) {
|
|
60
|
+
this.timeout = options.timeout ?? 30000;
|
|
61
|
+
this.context = this.createSafeContext(options.globals ?? {}, options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private createSafeContext(globals: Record<string, unknown>, options: SandboxOptions): vm.Context {
|
|
65
|
+
// C4: Frozen process object - limited access to process internals
|
|
66
|
+
const frozenProcess = {
|
|
67
|
+
cwd: () => process.cwd(),
|
|
68
|
+
platform: process.platform,
|
|
69
|
+
arch: process.arch,
|
|
70
|
+
version: process.version,
|
|
71
|
+
env: { ...process.env }, // Copy, not reference
|
|
72
|
+
// Explicitly excluded: exit, kill, hrtime, memoryUsage, cpuUsage, binding, dlopen, _tickCallback
|
|
73
|
+
};
|
|
74
|
+
Object.freeze(frozenProcess);
|
|
75
|
+
|
|
76
|
+
// Safe console implementation
|
|
77
|
+
const safeConsole = {
|
|
78
|
+
log: (...args: unknown[]) => (options.onLog ?? console.log)(args.map(formatArg).join(" ")),
|
|
79
|
+
error: (...args: unknown[]) => (options.onError ?? console.error)(args.map(formatArg).join(" ")),
|
|
80
|
+
warn: (...args: unknown[]) => (options.onWarn ?? console.warn)(args.map(formatArg).join(" ")),
|
|
81
|
+
info: (...args: unknown[]) => (options.onLog ?? console.log)(args.map(formatArg).join(" ")),
|
|
82
|
+
debug: (...args: unknown[]) => (options.onLog ?? console.log)(args.map(formatArg).join(" ")),
|
|
83
|
+
table: (data: unknown) => (options.onLog ?? console.log)(JSON.stringify(data, null, 2)),
|
|
84
|
+
dir: (data: unknown) => (options.onLog ?? console.log)(JSON.stringify(data, null, 2)),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// C4: Ensure globals don't include process, global, or globalThis references
|
|
88
|
+
const safeGlobals: Record<string, unknown> = {};
|
|
89
|
+
for (const [key, value] of Object.entries(globals)) {
|
|
90
|
+
// Filter out dangerous global references
|
|
91
|
+
if (key === "process" || key === "global" || key === "globalThis" || key === "GLOBAL") {
|
|
92
|
+
continue; // Skip - these are handled by frozenProcess or intentionally omitted
|
|
93
|
+
}
|
|
94
|
+
safeGlobals[key] = value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Context isolation - explicitly list allowed globals
|
|
98
|
+
const contextGlobals: Record<string, unknown> = {
|
|
99
|
+
...safeGlobals,
|
|
100
|
+
process: frozenProcess,
|
|
101
|
+
console: safeConsole,
|
|
102
|
+
// Safe Math (static methods only)
|
|
103
|
+
Math: Math,
|
|
104
|
+
// Safe JSON
|
|
105
|
+
JSON: JSON,
|
|
106
|
+
// Safe Number
|
|
107
|
+
Number: Number,
|
|
108
|
+
// Safe String
|
|
109
|
+
String: String,
|
|
110
|
+
// Safe Boolean
|
|
111
|
+
Boolean: Boolean,
|
|
112
|
+
// Safe Array
|
|
113
|
+
Array: Array,
|
|
114
|
+
// Safe Object
|
|
115
|
+
Object: Object,
|
|
116
|
+
// Safe RegExp
|
|
117
|
+
RegExp: RegExp,
|
|
118
|
+
// Safe Error
|
|
119
|
+
Error: Error,
|
|
120
|
+
// Safe Map
|
|
121
|
+
Map: Map,
|
|
122
|
+
// Safe Set
|
|
123
|
+
Set: Set,
|
|
124
|
+
// Safe Promise
|
|
125
|
+
Promise: Promise,
|
|
126
|
+
// Safe Symbol
|
|
127
|
+
Symbol: Symbol,
|
|
128
|
+
// Safe parseInt/parseFloat
|
|
129
|
+
parseInt: parseInt,
|
|
130
|
+
parseFloat: parseFloat,
|
|
131
|
+
isNaN: isNaN,
|
|
132
|
+
isFinite: isFinite,
|
|
133
|
+
// Safe encodeURI/decodeURI
|
|
134
|
+
encodeURI: encodeURI,
|
|
135
|
+
decodeURI: decodeURI,
|
|
136
|
+
encodeURIComponent: encodeURIComponent,
|
|
137
|
+
decodeURIComponent: decodeURIComponent,
|
|
138
|
+
// Safe typed arrays (read-only buffer views)
|
|
139
|
+
ArrayBuffer: ArrayBuffer,
|
|
140
|
+
Uint8Array: Uint8Array,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return vm.createContext(contextGlobals);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* C4: Validate code before execution - check for forbidden patterns and
|
|
148
|
+
* ensure compilation is safe.
|
|
149
|
+
*/
|
|
150
|
+
private validateScript(code: string): void {
|
|
151
|
+
// Check for ESM/module patterns
|
|
152
|
+
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
153
|
+
if (pattern.test(code)) {
|
|
154
|
+
throw new Error(`Forbidden pattern detected: ${pattern.source}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check for import.meta specifically (C4)
|
|
159
|
+
if (/import\.meta/.test(code)) {
|
|
160
|
+
throw new Error("import.meta is not allowed in sandboxed code");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Verify compilation succeeds (C4)
|
|
164
|
+
const wrappedCode = `(function(){ ${code} })()`;
|
|
165
|
+
new vm.Script(wrappedCode, {
|
|
166
|
+
filename: "sandbox-validate.js",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Execute JavaScript code in the sandboxed context.
|
|
172
|
+
* @param code - The JavaScript code to execute
|
|
173
|
+
* @param timeout - Optional timeout override in milliseconds
|
|
174
|
+
* @returns The result of the script execution
|
|
175
|
+
* @throws Error if code contains forbidden patterns or fails compilation
|
|
176
|
+
*/
|
|
177
|
+
execute(code: string, timeout?: number): unknown {
|
|
178
|
+
// C4: Validate script before execution
|
|
179
|
+
this.validateScript(code);
|
|
180
|
+
|
|
181
|
+
const effectiveTimeout = timeout ?? this.timeout;
|
|
182
|
+
// Wrap code in an IIFE to allow return statements
|
|
183
|
+
const wrappedCode = `(function(){ ${code} })()`;
|
|
184
|
+
const script = new vm.Script(wrappedCode, {
|
|
185
|
+
filename: "workflow.js",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return script.runInContext(this.context, {
|
|
189
|
+
timeout: effectiveTimeout,
|
|
190
|
+
displayErrors: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Execute an async function in the sandboxed context.
|
|
196
|
+
* @param fn - Async function to execute
|
|
197
|
+
* @param timeout - Optional timeout override in milliseconds
|
|
198
|
+
* @returns Promise resolving to the function result
|
|
199
|
+
*/
|
|
200
|
+
async executeAsync<T>(fn: () => Promise<T>, timeout?: number): Promise<T> {
|
|
201
|
+
const effectiveTimeout = timeout ?? this.timeout;
|
|
202
|
+
const script = new vm.Script(`(${fn.toString()})()`, {
|
|
203
|
+
filename: "workflow.js",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = script.runInContext(this.context, {
|
|
207
|
+
timeout: effectiveTimeout,
|
|
208
|
+
displayErrors: true,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return result as Promise<T>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Create a new sandbox with additional globals merged in.
|
|
216
|
+
*/
|
|
217
|
+
extend(additionalGlobals: Record<string, unknown>): WorkflowSandbox {
|
|
218
|
+
const newSandbox = new WorkflowSandbox({
|
|
219
|
+
timeout: this.timeout,
|
|
220
|
+
globals: { ...additionalGlobals },
|
|
221
|
+
});
|
|
222
|
+
return newSandbox;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get the VM context for advanced use cases.
|
|
227
|
+
*/
|
|
228
|
+
getContext(): vm.Context {
|
|
229
|
+
return this.context;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function formatArg(arg: unknown): string {
|
|
234
|
+
if (typeof arg === "string") return arg;
|
|
235
|
+
if (arg === null) return "null";
|
|
236
|
+
if (arg === undefined) return "undefined";
|
|
237
|
+
if (typeof arg === "object") {
|
|
238
|
+
try {
|
|
239
|
+
return JSON.stringify(arg);
|
|
240
|
+
} catch {
|
|
241
|
+
return String(arg);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return String(arg);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Create a pre-configured sandbox for workflow execution.
|
|
249
|
+
*/
|
|
250
|
+
export function createWorkflowSandbox(options?: SandboxOptions): WorkflowSandbox {
|
|
251
|
+
return new WorkflowSandbox(options);
|
|
252
|
+
}
|
package/src/runtime/scheduler.ts
CHANGED
|
@@ -106,8 +106,13 @@ export class CrewScheduler {
|
|
|
106
106
|
private disarm(id: string): void {
|
|
107
107
|
const t = this.timers.get(id);
|
|
108
108
|
if (t) {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// Branch on timer type to use correct clear function
|
|
110
|
+
const job = this.jobs.get(id);
|
|
111
|
+
if (job?.scheduleType === "once") {
|
|
112
|
+
clearTimeout(t as ReturnType<typeof setTimeout>);
|
|
113
|
+
} else {
|
|
114
|
+
clearInterval(t as ReturnType<typeof setInterval>);
|
|
115
|
+
}
|
|
111
116
|
this.timers.delete(id);
|
|
112
117
|
}
|
|
113
118
|
}
|
|
@@ -220,7 +220,7 @@ export class SubagentManager {
|
|
|
220
220
|
const record = this.records.get(id);
|
|
221
221
|
if (!record) return undefined;
|
|
222
222
|
if (record.status !== "running" && record.status !== "queued") return record;
|
|
223
|
-
if (record.promise) await record.promise.catch(() => {
|
|
223
|
+
if (record.promise) await record.promise.catch((error) => { logInternalError("subagent-manager.waitForRecord", error, `id=${id}`); });
|
|
224
224
|
else await new Promise((resolve) => setTimeout(resolve, 100));
|
|
225
225
|
}
|
|
226
226
|
}
|
|
@@ -34,12 +34,21 @@ export interface ExecutionPlan {
|
|
|
34
34
|
* - Each subsequent wave contains tasks whose dependencies are all in earlier waves.
|
|
35
35
|
* - If all tasks have empty `dependsOn`, they all go into wave 0 (backward compatible).
|
|
36
36
|
* - If a cycle is detected, `hasCycle` is true and `cycleNodes` lists the involved IDs.
|
|
37
|
+
*
|
|
38
|
+
* @throws Error if a task depends on itself (self-dependency).
|
|
37
39
|
*/
|
|
38
40
|
export function buildExecutionPlan(tasks: TaskNode[]): ExecutionPlan {
|
|
39
41
|
if (tasks.length === 0) {
|
|
40
42
|
return { waves: [], hasCycle: false };
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// HIGH-9: Detect self-dependency
|
|
46
|
+
for (const task of tasks) {
|
|
47
|
+
if (task.dependsOn.includes(task.id)) {
|
|
48
|
+
throw new Error(`Task "${task.id}" has self-dependency (depends on itself)`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
43
52
|
const idSet = new Set<string>(tasks.map((t) => t.id));
|
|
44
53
|
const adjacency = new Map<string, Set<string>>(); // id -> ids that depend on it
|
|
45
54
|
const inDegree = new Map<string, number>();
|
|
@@ -108,7 +117,8 @@ export function buildExecutionPlan(tasks: TaskNode[]): ExecutionPlan {
|
|
|
108
117
|
*/
|
|
109
118
|
function buildWave(tasks: TaskNode[], ids: string[], index: number): ExecutionWave {
|
|
110
119
|
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
|
111
|
-
|
|
120
|
+
// MEDIUM-12: Filter out undefined values instead of using non-null assertion
|
|
121
|
+
const waveTasks = ids.map((id) => taskMap.get(id)).filter(Boolean) as TaskNode[];
|
|
112
122
|
|
|
113
123
|
let label: string | undefined;
|
|
114
124
|
if (waveTasks.length > 0 && waveTasks.every((t) => t.phase !== undefined)) {
|
|
@@ -205,6 +205,20 @@ export async function runTeamTask(
|
|
|
205
205
|
input.taskRuntimeOverride ??
|
|
206
206
|
input.runtimeKind ??
|
|
207
207
|
(input.executeWorkers ? "child-process" : "scaffold");
|
|
208
|
+
// FIX: Check signal before persisting state — if cancelled, skip the write.
|
|
209
|
+
if (input.signal?.aborted) {
|
|
210
|
+
const cancelReason = cancellationReasonFromSignal(input.signal);
|
|
211
|
+
const cancelledTask: TeamTaskState = {
|
|
212
|
+
...task,
|
|
213
|
+
status: "cancelled",
|
|
214
|
+
error: `${cancelReason.code}: ${cancelReason.message}`,
|
|
215
|
+
finishedAt: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
manifest: input.manifest,
|
|
219
|
+
tasks: updateTask(tasks, cancelledTask),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
208
222
|
tasks = persistSingleTaskUpdate(manifest, tasks, task);
|
|
209
223
|
if (runtimeKind === "child-process")
|
|
210
224
|
({ task, tasks } = checkpointTask(
|
|
@@ -458,7 +472,7 @@ export async function runTeamTask(
|
|
|
458
472
|
taskId: task.id,
|
|
459
473
|
message: `Worker lifecycle: ${event.type}${event.error ? ` error=${event.error}` : ""}${event.exitCode != null ? ` exit=${event.exitCode}` : ""}`,
|
|
460
474
|
data: { ...event },
|
|
461
|
-
});
|
|
475
|
+
}).catch((error) => logInternalError("task-runner.lifecycle-event", error, `taskId=${task.id}, type=${event.type}`));
|
|
462
476
|
},
|
|
463
477
|
onStdoutLine: (line) => {
|
|
464
478
|
appendCrewAgentOutput(manifest, task.id, line);
|