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.
Files changed (74) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/migration-v0.4-v0.5.md +19 -2
  5. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  6. package/package.json +7 -5
  7. package/src/benchmark/benchmark-runner.ts +45 -0
  8. package/src/benchmark/feedback-loop.ts +5 -0
  9. package/src/config/config.ts +38 -4
  10. package/src/config/defaults.ts +5 -0
  11. package/src/config/suggestions.ts +8 -0
  12. package/src/extension/async-notifier.ts +10 -1
  13. package/src/extension/cross-extension-rpc.ts +1 -1
  14. package/src/extension/notification-router.ts +18 -0
  15. package/src/extension/register.ts +13 -17
  16. package/src/extension/registration/subagent-tools.ts +1 -1
  17. package/src/extension/team-tool/anchor.ts +201 -0
  18. package/src/extension/team-tool/api.ts +2 -1
  19. package/src/extension/team-tool/auto-summarize.ts +154 -0
  20. package/src/extension/team-tool/run.ts +37 -2
  21. package/src/extension/team-tool.ts +44 -2
  22. package/src/hooks/registry.ts +1 -3
  23. package/src/observability/event-bus.ts +13 -4
  24. package/src/observability/event-to-metric.ts +0 -2
  25. package/src/runtime/anchor-manager.ts +473 -0
  26. package/src/runtime/async-runner.ts +8 -4
  27. package/src/runtime/auto-summarize.ts +350 -0
  28. package/src/runtime/background-runner.ts +2 -1
  29. package/src/runtime/budget-tracker.ts +354 -0
  30. package/src/runtime/chain-runner.ts +507 -0
  31. package/src/runtime/child-pi.ts +24 -6
  32. package/src/runtime/crash-recovery.ts +5 -4
  33. package/src/runtime/crew-agent-records.ts +32 -1
  34. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  35. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  36. package/src/runtime/delivery-coordinator.ts +10 -3
  37. package/src/runtime/dynamic-script-runner.ts +482 -0
  38. package/src/runtime/handoff-manager.ts +589 -0
  39. package/src/runtime/hidden-handoff.ts +424 -0
  40. package/src/runtime/live-agent-manager.ts +20 -4
  41. package/src/runtime/live-session-runtime.ts +39 -4
  42. package/src/runtime/manifest-cache.ts +2 -1
  43. package/src/runtime/model-resolver.ts +16 -4
  44. package/src/runtime/phase-tracker.ts +373 -0
  45. package/src/runtime/pipeline-runner.ts +514 -0
  46. package/src/runtime/retry-runner.ts +354 -0
  47. package/src/runtime/sandbox.ts +252 -0
  48. package/src/runtime/scheduler.ts +7 -2
  49. package/src/runtime/subagent-manager.ts +1 -1
  50. package/src/runtime/task-graph.ts +11 -1
  51. package/src/runtime/task-runner.ts +15 -1
  52. package/src/runtime/team-runner.ts +4 -3
  53. package/src/schema/team-tool-schema.ts +31 -0
  54. package/src/skills/discover-skills.ts +5 -0
  55. package/src/state/active-run-registry.ts +19 -3
  56. package/src/state/contracts.ts +9 -0
  57. package/src/state/crew-init.ts +3 -3
  58. package/src/state/decision-ledger.ts +26 -32
  59. package/src/state/event-log-rotation.ts +2 -2
  60. package/src/state/event-log.ts +17 -4
  61. package/src/state/mailbox.ts +35 -1
  62. package/src/state/run-cache.ts +18 -8
  63. package/src/tools/safe-bash-extension.ts +1 -0
  64. package/src/tools/safe-bash.ts +153 -20
  65. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  66. package/src/ui/powerbar-publisher.ts +1 -0
  67. package/src/ui/transcript-cache.ts +13 -0
  68. package/src/utils/bm25-search.ts +16 -8
  69. package/src/utils/env-filter.ts +8 -5
  70. package/src/utils/redaction.ts +169 -15
  71. package/src/utils/sse-parser.ts +10 -1
  72. package/src/worktree/cleanup.ts +6 -1
  73. package/workflows/chain.workflow.md +252 -0
  74. 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
+ }
@@ -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
- clearInterval(t as ReturnType<typeof setInterval>);
110
- clearTimeout(t as ReturnType<typeof setTimeout>);
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(() => { /* status already set to error */ });
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
- const waveTasks = ids.map((id) => taskMap.get(id)!).filter(Boolean);
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);