@calmo/task-runner 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.jules/nexus.md +5 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/AGENTS.md +2 -0
  4. package/CHANGELOG.md +51 -0
  5. package/README.md +34 -0
  6. package/conductor/code_styleguides/general.md +23 -0
  7. package/conductor/code_styleguides/javascript.md +51 -0
  8. package/conductor/code_styleguides/typescript.md +43 -0
  9. package/conductor/product-guidelines.md +14 -0
  10. package/conductor/product.md +16 -0
  11. package/conductor/setup_state.json +1 -0
  12. package/conductor/tech-stack.md +19 -0
  13. package/conductor/workflow.md +334 -0
  14. package/dist/EventBus.js +19 -18
  15. package/dist/EventBus.js.map +1 -1
  16. package/dist/PluginManager.d.ts +22 -0
  17. package/dist/PluginManager.js +40 -0
  18. package/dist/PluginManager.js.map +1 -0
  19. package/dist/TaskGraphValidator.d.ts +1 -1
  20. package/dist/TaskGraphValidator.js +16 -21
  21. package/dist/TaskGraphValidator.js.map +1 -1
  22. package/dist/TaskRunner.d.ts +8 -1
  23. package/dist/TaskRunner.js +37 -25
  24. package/dist/TaskRunner.js.map +1 -1
  25. package/dist/TaskStateManager.d.ts +1 -0
  26. package/dist/TaskStateManager.js +22 -6
  27. package/dist/TaskStateManager.js.map +1 -1
  28. package/dist/TaskStep.d.ts +12 -0
  29. package/dist/WorkflowExecutor.js +19 -10
  30. package/dist/WorkflowExecutor.js.map +1 -1
  31. package/dist/contracts/Plugin.d.ts +30 -0
  32. package/dist/contracts/Plugin.js +2 -0
  33. package/dist/contracts/Plugin.js.map +1 -0
  34. package/dist/strategies/DryRunExecutionStrategy.js +2 -2
  35. package/dist/strategies/DryRunExecutionStrategy.js.map +1 -1
  36. package/dist/strategies/StandardExecutionStrategy.js +43 -1
  37. package/dist/strategies/StandardExecutionStrategy.js.map +1 -1
  38. package/openspec/changes/add-middleware-support/proposal.md +19 -0
  39. package/openspec/changes/add-middleware-support/specs/task-runner/spec.md +34 -0
  40. package/openspec/changes/add-middleware-support/tasks.md +9 -0
  41. package/openspec/changes/add-resource-concurrency/tasks.md +1 -0
  42. package/openspec/changes/allow-plugin-hooks/design.md +51 -0
  43. package/openspec/changes/allow-plugin-hooks/proposal.md +12 -0
  44. package/openspec/changes/allow-plugin-hooks/specs/post-task/spec.md +21 -0
  45. package/openspec/changes/allow-plugin-hooks/specs/pre-task/spec.md +32 -0
  46. package/openspec/changes/allow-plugin-hooks/tasks.md +7 -0
  47. package/openspec/changes/archive/2026-02-15-implement-plugin-system/design.md +45 -0
  48. package/openspec/changes/archive/2026-02-15-implement-plugin-system/proposal.md +13 -0
  49. package/openspec/changes/archive/2026-02-15-implement-plugin-system/specs/plugin-context/spec.md +17 -0
  50. package/openspec/changes/archive/2026-02-15-implement-plugin-system/specs/plugin-loading/spec.md +27 -0
  51. package/openspec/changes/archive/2026-02-15-implement-plugin-system/tasks.md +7 -0
  52. package/openspec/changes/feat-completion-dependencies/proposal.md +58 -0
  53. package/openspec/changes/feat-completion-dependencies/tasks.md +46 -0
  54. package/openspec/changes/feat-task-caching/design.md +34 -0
  55. package/openspec/changes/feat-task-caching/proposal.md +18 -0
  56. package/openspec/changes/feat-task-caching/specs/task-runner/spec.md +58 -0
  57. package/openspec/changes/feat-task-caching/tasks.md +24 -0
  58. package/openspec/changes/feat-task-loop/proposal.md +22 -0
  59. package/openspec/changes/feat-task-loop/specs/task-runner/spec.md +34 -0
  60. package/openspec/changes/feat-task-loop/tasks.md +8 -0
  61. package/openspec/proposals/feat-matrix-execution/proposal.md +23 -0
  62. package/openspec/proposals/feat-matrix-execution/specs/task-runner/spec.md +47 -0
  63. package/openspec/proposals/feat-matrix-execution/tasks.md +11 -0
  64. package/openspec/proposals/feat-task-observability/proposal.md +16 -0
  65. package/openspec/proposals/feat-task-observability/specs/task-runner/spec.md +14 -0
  66. package/openspec/proposals/feat-task-observability/tasks.md +7 -0
  67. package/openspec/specs/plugin-context/spec.md +19 -0
  68. package/openspec/specs/plugin-loading/spec.md +29 -0
  69. package/package.json +1 -1
  70. package/src/EventBus.ts +11 -15
  71. package/src/PluginManager.ts +43 -0
  72. package/src/TaskGraphValidator.ts +22 -24
  73. package/src/TaskRunner.ts +45 -28
  74. package/src/TaskStateManager.ts +20 -6
  75. package/src/TaskStep.ts +14 -0
  76. package/src/WorkflowExecutor.ts +24 -11
  77. package/src/contracts/Plugin.ts +32 -0
  78. package/src/strategies/DryRunExecutionStrategy.ts +2 -2
  79. package/src/strategies/StandardExecutionStrategy.ts +48 -1
  80. /package/openspec/changes/{feat-continue-on-error → archive/2026-02-18-feat-continue-on-error}/proposal.md +0 -0
  81. /package/openspec/changes/{feat-continue-on-error → archive/2026-02-18-feat-continue-on-error}/tasks.md +0 -0
  82. /package/openspec/changes/{feat-per-task-timeout → archive/2026-02-25-feat-per-task-timeout}/proposal.md +0 -0
  83. /package/openspec/changes/{feat-per-task-timeout → archive/2026-02-25-feat-per-task-timeout}/specs/task-runner/spec.md +0 -0
  84. /package/openspec/changes/{feat-per-task-timeout → archive/2026-02-25-feat-per-task-timeout}/tasks.md +0 -0
package/src/TaskRunner.ts CHANGED
@@ -14,11 +14,10 @@ import { TaskGraphValidationError } from "./TaskGraphValidationError.js";
14
14
  import { IExecutionStrategy } from "./strategies/IExecutionStrategy.js";
15
15
  import { StandardExecutionStrategy } from "./strategies/StandardExecutionStrategy.js";
16
16
  import { RetryingExecutionStrategy } from "./strategies/RetryingExecutionStrategy.js";
17
+ import { Plugin } from "./contracts/Plugin.js";
18
+ import { PluginManager } from "./PluginManager.js";
17
19
  import { DryRunExecutionStrategy } from "./strategies/DryRunExecutionStrategy.js";
18
20
 
19
- // Re-export types for backward compatibility
20
- export { RunnerEventPayloads, RunnerEventListener, TaskRunnerExecutionConfig };
21
-
22
21
  /**
23
22
  * The main class that orchestrates the execution of a list of tasks
24
23
  * based on their dependencies, with support for parallel execution.
@@ -30,10 +29,14 @@ export class TaskRunner<TContext> {
30
29
  private executionStrategy: IExecutionStrategy<TContext> =
31
30
  new RetryingExecutionStrategy(new StandardExecutionStrategy());
32
31
 
32
+ private pluginManager: PluginManager<TContext>;
33
+
33
34
  /**
34
35
  * @param context The shared context object to be passed to each task.
35
36
  */
36
- constructor(private context: TContext) {}
37
+ constructor(private context: TContext) {
38
+ this.pluginManager = new PluginManager({ events: this.eventBus });
39
+ }
37
40
 
38
41
  /**
39
42
  * Subscribe to an event.
@@ -56,17 +59,25 @@ export class TaskRunner<TContext> {
56
59
  event: K,
57
60
  callback: RunnerEventListener<TContext, K>
58
61
  ): void {
59
- /* v8 ignore next 1 */
60
62
  this.eventBus.off(event, callback);
61
63
  }
62
64
 
65
+ /**
66
+ * Registers a plugin.
67
+ * @param plugin The plugin to register.
68
+ * @returns The TaskRunner instance for chaining.
69
+ */
70
+ public use(plugin: Plugin<TContext>): this {
71
+ this.pluginManager.use(plugin);
72
+ return this;
73
+ }
74
+
63
75
  /**
64
76
  * Sets the execution strategy to be used.
65
77
  * @param strategy The execution strategy.
66
78
  * @returns The TaskRunner instance for chaining.
67
79
  */
68
80
  public setExecutionStrategy(strategy: IExecutionStrategy<TContext>): this {
69
- /* v8 ignore next 2 */
70
81
  this.executionStrategy = strategy;
71
82
  return this;
72
83
  }
@@ -77,14 +88,16 @@ export class TaskRunner<TContext> {
77
88
  * @returns A string containing the Mermaid graph definition.
78
89
  */
79
90
  public static getMermaidGraph<T>(steps: TaskStep<T>[]): string {
80
- const graphLines = ["graph TD"];
91
+ const nodeLines: string[] = ["graph TD"];
92
+ const edgeLines = new Set<string>();
81
93
  const idMap = new Map<string, string>();
82
94
  const usedIds = new Set<string>();
83
95
  const baseIdCounters = new Map<string, number>();
84
96
 
85
97
  const getUniqueId = (name: string) => {
86
- if (idMap.has(name)) {
87
- return idMap.get(name)!;
98
+ const existingId = idMap.get(name);
99
+ if (existingId !== undefined) {
100
+ return existingId;
88
101
  }
89
102
 
90
103
  const sanitized = this.sanitizeMermaidId(name);
@@ -112,30 +125,31 @@ export class TaskRunner<TContext> {
112
125
  return uniqueId;
113
126
  };
114
127
 
115
- // Pre-calculate IDs for all steps to ensure stable generation order
116
- // We sort steps by name to ensure deterministic ID generation regardless of input order if names clash
117
- // But input order is usually significant in graph definition, so we'll stick to input order.
118
- // However, we must process all step NAMES first.
119
- for (const step of steps) {
120
- getUniqueId(step.name);
121
- }
128
+ // Process nodes and edges in a single pass over input steps
129
+ const processedNodes = new Set<string>();
130
+ for (let i = 0; i < steps.length; i++) {
131
+ const step = steps[i];
132
+ const name = step.name;
133
+ const stepId = getUniqueId(name);
122
134
 
123
- for (const step of steps) {
124
- const stepId = getUniqueId(step.name);
125
- graphLines.push(` ${stepId}[${JSON.stringify(step.name)}]`);
126
- }
135
+ const sizeBefore = processedNodes.size;
136
+ processedNodes.add(stepId);
137
+
138
+ if (processedNodes.size !== sizeBefore) {
139
+ const escapedName = name.replaceAll("\"", "&quot;");
140
+ nodeLines.push(` ${stepId}["${escapedName}"]`);
141
+ }
127
142
 
128
- for (const step of steps) {
129
143
  if (step.dependencies) {
130
- const stepId = getUniqueId(step.name);
131
- for (const dep of step.dependencies) {
132
- const depId = getUniqueId(dep);
133
- graphLines.push(` ${depId} --> ${stepId}`);
144
+ const deps = step.dependencies;
145
+ for (let j = 0; j < deps.length; j++) {
146
+ const depId = getUniqueId(deps[j]);
147
+ edgeLines.add(` ${depId} --> ${stepId}`);
134
148
  }
135
149
  }
136
150
  }
137
151
 
138
- return [...new Set(graphLines)].join("\n");
152
+ return nodeLines.concat([...edgeLines]).join("\n");
139
153
  }
140
154
 
141
155
  /**
@@ -159,6 +173,9 @@ export class TaskRunner<TContext> {
159
173
  steps: TaskStep<TContext>[],
160
174
  config?: TaskRunnerExecutionConfig
161
175
  ): Promise<Map<string, TaskResult>> {
176
+ // Initialize plugins
177
+ await this.pluginManager.initialize();
178
+
162
179
  // Validate the task graph before execution
163
180
  const taskGraph: TaskGraph = {
164
181
  tasks: steps.map((step) => ({
@@ -197,9 +214,9 @@ export class TaskRunner<TContext> {
197
214
  config.timeout,
198
215
  config.signal
199
216
  );
200
- } else {
201
- return executor.execute(steps, config?.signal);
202
217
  }
218
+
219
+ return executor.execute(steps, config?.signal);
203
220
  }
204
221
 
205
222
  /**
@@ -15,6 +15,7 @@ export class TaskStateManager<TContext> {
15
15
  private dependencyGraph = new Map<string, TaskStep<TContext>[]>();
16
16
  private dependencyCounts = new Map<string, number>();
17
17
  private readyQueue: TaskStep<TContext>[] = [];
18
+ private taskDefinitions = new Map<string, TaskStep<TContext>>();
18
19
 
19
20
  constructor(private eventBus: EventBus<TContext>) {}
20
21
 
@@ -30,8 +31,10 @@ export class TaskStateManager<TContext> {
30
31
 
31
32
  this.dependencyGraph.clear();
32
33
  this.dependencyCounts.clear();
34
+ this.taskDefinitions.clear();
33
35
 
34
36
  for (const step of steps) {
37
+ this.taskDefinitions.set(step.name, step);
35
38
  const deps = step.dependencies ?? [];
36
39
  this.dependencyCounts.set(step.name, deps.length);
37
40
 
@@ -39,10 +42,12 @@ export class TaskStateManager<TContext> {
39
42
  this.readyQueue.push(step);
40
43
  } else {
41
44
  for (const dep of deps) {
42
- if (!this.dependencyGraph.has(dep)) {
43
- this.dependencyGraph.set(dep, []);
45
+ let dependents = this.dependencyGraph.get(dep);
46
+ if (dependents === undefined) {
47
+ dependents = [];
48
+ this.dependencyGraph.set(dep, dependents);
44
49
  }
45
- this.dependencyGraph.get(dep)!.push(step);
50
+ dependents.push(step);
46
51
  }
47
52
  }
48
53
  }
@@ -54,7 +59,7 @@ export class TaskStateManager<TContext> {
54
59
  * @returns An array of tasks that are ready to run.
55
60
  */
56
61
  processDependencies(): TaskStep<TContext>[] {
57
- const toRun = [...this.readyQueue];
62
+ const toRun = this.readyQueue;
58
63
  this.readyQueue = [];
59
64
 
60
65
  // Remove them from pendingSteps as they are now handed off to the executor
@@ -87,6 +92,13 @@ export class TaskStateManager<TContext> {
87
92
 
88
93
  if (result.status === "success") {
89
94
  this.handleSuccess(step.name);
95
+ } else if (result.status === "failure") {
96
+ // If continueOnError is true, treat as success for dependents to unblock the workflow
97
+ if (this.taskDefinitions.get(step.name)?.continueOnError) {
98
+ this.handleSuccess(step.name);
99
+ } else {
100
+ this.cascadeFailure(step.name);
101
+ }
90
102
  } else {
91
103
  this.cascadeFailure(step.name);
92
104
  }
@@ -188,11 +200,13 @@ export class TaskStateManager<TContext> {
188
200
  */
189
201
  private cascadeFailure(failedStepName: string): void {
190
202
  const queue = [failedStepName];
203
+ // Optimization: Use index pointer instead of shift() to avoid O(N) array re-indexing
204
+ let head = 0;
191
205
  // Use a set to track visited nodes in this cascade pass to avoid redundant processing,
192
206
  // although checking results.has() in internalMarkSkipped also prevents it.
193
207
 
194
- while (queue.length > 0) {
195
- const currentName = queue.shift()!;
208
+ while (head < queue.length) {
209
+ const currentName = queue[head++];
196
210
  const dependents = this.dependencyGraph.get(currentName);
197
211
 
198
212
  if (!dependents) continue;
package/src/TaskStep.ts CHANGED
@@ -25,6 +25,20 @@ export interface TaskStep<TContext> {
25
25
  */
26
26
  priority?: number;
27
27
 
28
+ /**
29
+ * Optional flag to indicate that the workflow should continue even if this task fails.
30
+ * If true, dependent tasks will execute as if this task succeeded.
31
+ * The task result will still be marked as "failure".
32
+ * Default is false.
33
+ */
34
+ continueOnError?: boolean;
35
+
36
+ /**
37
+ * Optional maximum execution time in milliseconds.
38
+ * If the task runs longer than this, it will be cancelled and marked as failed.
39
+ */
40
+ timeout?: number;
41
+
28
42
  /**
29
43
  * The core logic of the task.
30
44
  * @param context The shared context object, allowing for state to be passed between tasks.
@@ -63,8 +63,22 @@ export class WorkflowExecutor<TContext> {
63
63
  }
64
64
 
65
65
  try {
66
+ // Create a signal mechanism to wait for any task completion
67
+ let signalResolver!: () => void;
68
+ let signalPromise = new Promise<void>((resolve) => {
69
+ signalResolver = resolve;
70
+ });
71
+
72
+ const signalWork = () => {
73
+ signalResolver();
74
+ // Reset the promise for the next wait
75
+ signalPromise = new Promise<void>((resolve) => {
76
+ signalResolver = resolve;
77
+ });
78
+ };
79
+
66
80
  // Initial pass
67
- this.processLoop(executingPromises, signal);
81
+ this.processLoop(executingPromises, signalWork, signal);
68
82
 
69
83
  while (
70
84
  this.stateManager.hasPendingTasks() ||
@@ -78,16 +92,13 @@ export class WorkflowExecutor<TContext> {
78
92
  break;
79
93
  } else {
80
94
  // Wait for the next task to finish
81
- await Promise.race(executingPromises);
95
+ await signalPromise;
82
96
  }
83
97
 
84
98
  if (signal?.aborted) {
85
99
  this.stateManager.cancelAllPending(
86
100
  ExecutionConstants.WORKFLOW_CANCELLED
87
101
  );
88
- } else {
89
- // After a task finishes, check for new work
90
- this.processLoop(executingPromises, signal);
91
102
  }
92
103
  }
93
104
 
@@ -109,6 +120,7 @@ export class WorkflowExecutor<TContext> {
109
120
  */
110
121
  private processLoop(
111
122
  executingPromises: Set<Promise<void>>,
123
+ signalWork: () => void,
112
124
  signal?: AbortSignal
113
125
  ): void {
114
126
  const newlyReady = this.stateManager.processDependencies();
@@ -129,12 +141,13 @@ export class WorkflowExecutor<TContext> {
129
141
 
130
142
  const step = this.readyQueue.pop()!;
131
143
 
132
- const taskPromise = this.executeTaskStep(step, signal)
133
- .finally(() => {
134
- executingPromises.delete(taskPromise);
135
- // When a task finishes, we try to run more
136
- this.processLoop(executingPromises, signal);
137
- });
144
+ const taskPromise = this.executeTaskStep(step, signal).finally(() => {
145
+ executingPromises.delete(taskPromise);
146
+ // When a task finishes, we try to run more
147
+ this.processLoop(executingPromises, signalWork, signal);
148
+ // Signal that a task has completed
149
+ signalWork();
150
+ });
138
151
 
139
152
  executingPromises.add(taskPromise);
140
153
  }
@@ -0,0 +1,32 @@
1
+ import { EventBus } from "../EventBus.js";
2
+
3
+ /**
4
+ * Context provided to a plugin during installation.
5
+ * exposes capabilities to hook into the runner lifecycle and context.
6
+ */
7
+ export interface PluginContext<TContext> {
8
+ /**
9
+ * The event bus instance to subscribe to runner events.
10
+ */
11
+ events: EventBus<TContext>;
12
+ }
13
+
14
+ /**
15
+ * Interface that all plugins must implement.
16
+ */
17
+ export interface Plugin<TContext> {
18
+ /**
19
+ * Unique name of the plugin.
20
+ */
21
+ name: string;
22
+ /**
23
+ * Semantic version of the plugin.
24
+ */
25
+ version: string;
26
+ /**
27
+ * Called when the plugin is installed into the runner.
28
+ * This is where the plugin should subscribe to events or perform setup.
29
+ * @param context The plugin context exposing runner capabilities.
30
+ */
31
+ install(context: PluginContext<TContext>): void | Promise<void>;
32
+ }
@@ -22,9 +22,9 @@ export class DryRunExecutionStrategy<
22
22
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
23
23
  _signal?: AbortSignal
24
24
  ): Promise<TaskResult> {
25
- return Promise.resolve({
25
+ return {
26
26
  status: "success",
27
27
  message: "Dry run: simulated success " + step.name,
28
- });
28
+ };
29
29
  }
30
30
  }
@@ -13,8 +13,51 @@ export class StandardExecutionStrategy<
13
13
  context: TContext,
14
14
  signal?: AbortSignal
15
15
  ): Promise<TaskResult> {
16
+ if (!step.timeout) {
17
+ try {
18
+ return await step.run(context, signal);
19
+ } catch (e) {
20
+ // Check if error is due to abort
21
+ if (
22
+ signal?.aborted &&
23
+ ((e instanceof Error && e.name === "AbortError") ||
24
+ signal.reason === e)
25
+ ) {
26
+ return {
27
+ status: "cancelled",
28
+ message: "Task cancelled during execution",
29
+ };
30
+ }
31
+ return {
32
+ status: "failure",
33
+ error: e instanceof Error ? e.message : String(e),
34
+ };
35
+ }
36
+ }
37
+
38
+ const abortController = new AbortController();
39
+ const timeoutSignal = signal
40
+ ? AbortSignal.any([signal, abortController.signal])
41
+ : abortController.signal;
42
+
43
+ let timer: NodeJS.Timeout | undefined;
44
+ let resolveTimeout!: (value: TaskResult) => void;
45
+
16
46
  try {
17
- return await step.run(context, signal);
47
+ const timeoutPromise = new Promise<TaskResult>((resolve) => {
48
+ resolveTimeout = resolve;
49
+ timer = setTimeout(() => {
50
+ abortController.abort(new Error("Timeout"));
51
+ resolve({
52
+ status: "failure",
53
+ error: `Task timed out after ${step.timeout}ms`,
54
+ });
55
+ }, step.timeout);
56
+ });
57
+
58
+ const taskPromise = step.run(context, timeoutSignal);
59
+
60
+ return await Promise.race([taskPromise, timeoutPromise]);
18
61
  } catch (e) {
19
62
  // Check if error is due to abort
20
63
  if (
@@ -30,6 +73,10 @@ export class StandardExecutionStrategy<
30
73
  status: "failure",
31
74
  error: e instanceof Error ? e.message : String(e),
32
75
  };
76
+ } finally {
77
+ clearTimeout(timer);
78
+ // Settle the timeout promise to avoid memory leaks from Promise.race
79
+ resolveTimeout({ status: "cancelled" } as TaskResult);
33
80
  }
34
81
  }
35
82
  }