@renseiai/agentfactory 0.8.8 → 0.8.9

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 (178) hide show
  1. package/dist/src/config/index.d.ts +1 -1
  2. package/dist/src/config/index.d.ts.map +1 -1
  3. package/dist/src/config/index.js +1 -1
  4. package/dist/src/config/repository-config.d.ts +23 -0
  5. package/dist/src/config/repository-config.d.ts.map +1 -1
  6. package/dist/src/config/repository-config.js +27 -0
  7. package/dist/src/config/repository-config.test.js +140 -1
  8. package/dist/src/governor/decision-engine.d.ts +3 -0
  9. package/dist/src/governor/decision-engine.d.ts.map +1 -1
  10. package/dist/src/governor/decision-engine.js +11 -0
  11. package/dist/src/governor/decision-engine.test.js +33 -0
  12. package/dist/src/governor/governor-types.d.ts +1 -1
  13. package/dist/src/governor/governor-types.d.ts.map +1 -1
  14. package/dist/src/governor/governor.d.ts +17 -1
  15. package/dist/src/governor/governor.d.ts.map +1 -1
  16. package/dist/src/governor/governor.js +112 -1
  17. package/dist/src/governor/governor.test.js +155 -0
  18. package/dist/src/index.d.ts +1 -0
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js +1 -0
  21. package/dist/src/orchestrator/issue-tracker-client.d.ts +4 -0
  22. package/dist/src/orchestrator/issue-tracker-client.d.ts.map +1 -1
  23. package/dist/src/orchestrator/orchestrator.d.ts.map +1 -1
  24. package/dist/src/orchestrator/orchestrator.js +24 -0
  25. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
  26. package/dist/src/orchestrator/parse-work-result.js +6 -0
  27. package/dist/src/orchestrator/parse-work-result.test.js +19 -0
  28. package/dist/src/providers/index.d.ts +64 -1
  29. package/dist/src/providers/index.d.ts.map +1 -1
  30. package/dist/src/providers/index.js +132 -1
  31. package/dist/src/providers/index.test.js +340 -2
  32. package/dist/src/routing/index.d.ts +7 -0
  33. package/dist/src/routing/index.d.ts.map +1 -0
  34. package/dist/src/routing/index.js +6 -0
  35. package/dist/src/routing/observation-recorder.d.ts +19 -0
  36. package/dist/src/routing/observation-recorder.d.ts.map +1 -0
  37. package/dist/src/routing/observation-recorder.js +73 -0
  38. package/dist/src/routing/observation-recorder.test.d.ts +2 -0
  39. package/dist/src/routing/observation-recorder.test.d.ts.map +1 -0
  40. package/dist/src/routing/observation-recorder.test.js +322 -0
  41. package/dist/src/routing/observation-store.d.ts +40 -0
  42. package/dist/src/routing/observation-store.d.ts.map +1 -0
  43. package/dist/src/routing/observation-store.js +1 -0
  44. package/dist/src/routing/observation-store.test.d.ts +2 -0
  45. package/dist/src/routing/observation-store.test.d.ts.map +1 -0
  46. package/dist/src/routing/observation-store.test.js +138 -0
  47. package/dist/src/routing/posterior-store.d.ts +12 -0
  48. package/dist/src/routing/posterior-store.d.ts.map +1 -0
  49. package/dist/src/routing/posterior-store.js +13 -0
  50. package/dist/src/routing/posterior-store.test.d.ts +2 -0
  51. package/dist/src/routing/posterior-store.test.d.ts.map +1 -0
  52. package/dist/src/routing/posterior-store.test.js +37 -0
  53. package/dist/src/routing/reward.d.ts +16 -0
  54. package/dist/src/routing/reward.d.ts.map +1 -0
  55. package/dist/src/routing/reward.js +29 -0
  56. package/dist/src/routing/reward.test.d.ts +2 -0
  57. package/dist/src/routing/reward.test.d.ts.map +1 -0
  58. package/dist/src/routing/reward.test.js +210 -0
  59. package/dist/src/routing/routing-engine.d.ts +20 -0
  60. package/dist/src/routing/routing-engine.d.ts.map +1 -0
  61. package/dist/src/routing/routing-engine.js +113 -0
  62. package/dist/src/routing/routing-engine.test.d.ts +2 -0
  63. package/dist/src/routing/routing-engine.test.d.ts.map +1 -0
  64. package/dist/src/routing/routing-engine.test.js +310 -0
  65. package/dist/src/routing/types.d.ts +157 -0
  66. package/dist/src/routing/types.d.ts.map +1 -0
  67. package/dist/src/routing/types.js +68 -0
  68. package/dist/src/routing/types.test.d.ts +2 -0
  69. package/dist/src/routing/types.test.d.ts.map +1 -0
  70. package/dist/src/routing/types.test.js +184 -0
  71. package/dist/src/templates/types.d.ts +3 -0
  72. package/dist/src/templates/types.d.ts.map +1 -1
  73. package/dist/src/templates/types.js +2 -0
  74. package/dist/src/workflow/agent-cancellation.d.ts +37 -0
  75. package/dist/src/workflow/agent-cancellation.d.ts.map +1 -0
  76. package/dist/src/workflow/agent-cancellation.js +41 -0
  77. package/dist/src/workflow/agent-cancellation.test.d.ts +2 -0
  78. package/dist/src/workflow/agent-cancellation.test.d.ts.map +1 -0
  79. package/dist/src/workflow/agent-cancellation.test.js +86 -0
  80. package/dist/src/workflow/concurrency-semaphore.d.ts +21 -0
  81. package/dist/src/workflow/concurrency-semaphore.d.ts.map +1 -0
  82. package/dist/src/workflow/concurrency-semaphore.js +46 -0
  83. package/dist/src/workflow/concurrency-semaphore.test.d.ts +2 -0
  84. package/dist/src/workflow/concurrency-semaphore.test.d.ts.map +1 -0
  85. package/dist/src/workflow/concurrency-semaphore.test.js +183 -0
  86. package/dist/src/workflow/gate-state.d.ts +115 -0
  87. package/dist/src/workflow/gate-state.d.ts.map +1 -0
  88. package/dist/src/workflow/gate-state.js +185 -0
  89. package/dist/src/workflow/gate-state.test.d.ts +2 -0
  90. package/dist/src/workflow/gate-state.test.d.ts.map +1 -0
  91. package/dist/src/workflow/gate-state.test.js +251 -0
  92. package/dist/src/workflow/gates/gate-evaluator.d.ts +119 -0
  93. package/dist/src/workflow/gates/gate-evaluator.d.ts.map +1 -0
  94. package/dist/src/workflow/gates/gate-evaluator.js +243 -0
  95. package/dist/src/workflow/gates/gate-evaluator.test.d.ts +2 -0
  96. package/dist/src/workflow/gates/gate-evaluator.test.d.ts.map +1 -0
  97. package/dist/src/workflow/gates/gate-evaluator.test.js +240 -0
  98. package/dist/src/workflow/gates/signal-gate.d.ts +114 -0
  99. package/dist/src/workflow/gates/signal-gate.d.ts.map +1 -0
  100. package/dist/src/workflow/gates/signal-gate.js +216 -0
  101. package/dist/src/workflow/gates/signal-gate.test.d.ts +2 -0
  102. package/dist/src/workflow/gates/signal-gate.test.d.ts.map +1 -0
  103. package/dist/src/workflow/gates/signal-gate.test.js +199 -0
  104. package/dist/src/workflow/gates/timeout-engine.d.ts +96 -0
  105. package/dist/src/workflow/gates/timeout-engine.d.ts.map +1 -0
  106. package/dist/src/workflow/gates/timeout-engine.js +162 -0
  107. package/dist/src/workflow/gates/timeout-engine.test.d.ts +2 -0
  108. package/dist/src/workflow/gates/timeout-engine.test.d.ts.map +1 -0
  109. package/dist/src/workflow/gates/timeout-engine.test.js +186 -0
  110. package/dist/src/workflow/gates/timer-gate.d.ts +125 -0
  111. package/dist/src/workflow/gates/timer-gate.d.ts.map +1 -0
  112. package/dist/src/workflow/gates/timer-gate.js +381 -0
  113. package/dist/src/workflow/gates/timer-gate.test.d.ts +2 -0
  114. package/dist/src/workflow/gates/timer-gate.test.d.ts.map +1 -0
  115. package/dist/src/workflow/gates/timer-gate.test.js +211 -0
  116. package/dist/src/workflow/gates/webhook-gate.d.ts +132 -0
  117. package/dist/src/workflow/gates/webhook-gate.d.ts.map +1 -0
  118. package/dist/src/workflow/gates/webhook-gate.js +216 -0
  119. package/dist/src/workflow/gates/webhook-gate.test.d.ts +2 -0
  120. package/dist/src/workflow/gates/webhook-gate.test.d.ts.map +1 -0
  121. package/dist/src/workflow/gates/webhook-gate.test.js +182 -0
  122. package/dist/src/workflow/index.d.ts +23 -2
  123. package/dist/src/workflow/index.d.ts.map +1 -1
  124. package/dist/src/workflow/index.js +15 -1
  125. package/dist/src/workflow/parallelism-executor.d.ts +25 -0
  126. package/dist/src/workflow/parallelism-executor.d.ts.map +1 -0
  127. package/dist/src/workflow/parallelism-executor.js +53 -0
  128. package/dist/src/workflow/parallelism-executor.test.d.ts +2 -0
  129. package/dist/src/workflow/parallelism-executor.test.d.ts.map +1 -0
  130. package/dist/src/workflow/parallelism-executor.test.js +191 -0
  131. package/dist/src/workflow/parallelism-types.d.ts +80 -0
  132. package/dist/src/workflow/parallelism-types.d.ts.map +1 -0
  133. package/dist/src/workflow/parallelism-types.js +8 -0
  134. package/dist/src/workflow/phase-context-injector.d.ts +29 -0
  135. package/dist/src/workflow/phase-context-injector.d.ts.map +1 -0
  136. package/dist/src/workflow/phase-context-injector.js +43 -0
  137. package/dist/src/workflow/phase-context-injector.test.d.ts +2 -0
  138. package/dist/src/workflow/phase-context-injector.test.d.ts.map +1 -0
  139. package/dist/src/workflow/phase-context-injector.test.js +123 -0
  140. package/dist/src/workflow/phase-output-collector.d.ts +39 -0
  141. package/dist/src/workflow/phase-output-collector.d.ts.map +1 -0
  142. package/dist/src/workflow/phase-output-collector.js +141 -0
  143. package/dist/src/workflow/phase-output-collector.test.d.ts +2 -0
  144. package/dist/src/workflow/phase-output-collector.test.d.ts.map +1 -0
  145. package/dist/src/workflow/phase-output-collector.test.js +179 -0
  146. package/dist/src/workflow/strategies/fan-in-strategy.d.ts +21 -0
  147. package/dist/src/workflow/strategies/fan-in-strategy.d.ts.map +1 -0
  148. package/dist/src/workflow/strategies/fan-in-strategy.js +92 -0
  149. package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts +2 -0
  150. package/dist/src/workflow/strategies/fan-in-strategy.test.d.ts.map +1 -0
  151. package/dist/src/workflow/strategies/fan-in-strategy.test.js +182 -0
  152. package/dist/src/workflow/strategies/fan-out-strategy.d.ts +16 -0
  153. package/dist/src/workflow/strategies/fan-out-strategy.d.ts.map +1 -0
  154. package/dist/src/workflow/strategies/fan-out-strategy.js +47 -0
  155. package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts +2 -0
  156. package/dist/src/workflow/strategies/fan-out-strategy.test.d.ts.map +1 -0
  157. package/dist/src/workflow/strategies/fan-out-strategy.test.js +97 -0
  158. package/dist/src/workflow/strategies/index.d.ts +4 -0
  159. package/dist/src/workflow/strategies/index.d.ts.map +1 -0
  160. package/dist/src/workflow/strategies/index.js +3 -0
  161. package/dist/src/workflow/strategies/race-strategy.d.ts +19 -0
  162. package/dist/src/workflow/strategies/race-strategy.d.ts.map +1 -0
  163. package/dist/src/workflow/strategies/race-strategy.js +92 -0
  164. package/dist/src/workflow/strategies/race-strategy.test.d.ts +2 -0
  165. package/dist/src/workflow/strategies/race-strategy.test.d.ts.map +1 -0
  166. package/dist/src/workflow/strategies/race-strategy.test.js +318 -0
  167. package/dist/src/workflow/transition-engine.d.ts.map +1 -1
  168. package/dist/src/workflow/transition-engine.js +12 -0
  169. package/dist/src/workflow/transition-engine.test.js +92 -0
  170. package/dist/src/workflow/workflow-registry.d.ts +5 -1
  171. package/dist/src/workflow/workflow-registry.d.ts.map +1 -1
  172. package/dist/src/workflow/workflow-registry.js +8 -0
  173. package/dist/src/workflow/workflow-registry.test.js +54 -0
  174. package/dist/src/workflow/workflow-types.d.ts +151 -6
  175. package/dist/src/workflow/workflow-types.d.ts.map +1 -1
  176. package/dist/src/workflow/workflow-types.js +71 -1
  177. package/dist/src/workflow/workflow-types.test.js +293 -2
  178. package/package.json +2 -2
@@ -0,0 +1,318 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RaceStrategy } from './race-strategy.js';
3
+ import { InMemoryAgentCancellation } from '../agent-cancellation.js';
4
+ /** Helper to create a task */
5
+ function makeTask(id, issueId) {
6
+ return { id, issueId: issueId ?? id, phaseName: 'test-phase' };
7
+ }
8
+ /** Helper to create a successful result */
9
+ function makeResult(task, outputs, durationMs) {
10
+ return {
11
+ id: task.id,
12
+ issueId: task.issueId,
13
+ success: true,
14
+ outputs,
15
+ durationMs,
16
+ };
17
+ }
18
+ /** Helper to create a failed result */
19
+ function makeFailedResult(task) {
20
+ return {
21
+ id: task.id,
22
+ issueId: task.issueId,
23
+ success: false,
24
+ };
25
+ }
26
+ describe('RaceStrategy', () => {
27
+ it('sets strategy to "race"', async () => {
28
+ const strategy = new RaceStrategy();
29
+ const result = await strategy.execute([], {
30
+ dispatch: async () => ({ id: '', issueId: '', success: true }),
31
+ });
32
+ expect(result.strategy).toBe('race');
33
+ });
34
+ describe('empty task list', () => {
35
+ it('returns empty result', async () => {
36
+ const strategy = new RaceStrategy();
37
+ const result = await strategy.execute([], {
38
+ dispatch: async () => ({ id: '', issueId: '', success: true }),
39
+ });
40
+ expect(result).toEqual({
41
+ strategy: 'race',
42
+ completed: [],
43
+ cancelled: [],
44
+ failed: [],
45
+ outputs: {},
46
+ });
47
+ });
48
+ });
49
+ describe('single task', () => {
50
+ it('returns that task result with no cancellation', async () => {
51
+ const cancellation = new InMemoryAgentCancellation();
52
+ const strategy = new RaceStrategy(cancellation);
53
+ const task = makeTask('t1', 'SUP-100');
54
+ const dispatch = async (t) => makeResult(t, { code: 'done' }, 100);
55
+ const result = await strategy.execute([task], { dispatch });
56
+ expect(result.completed).toHaveLength(1);
57
+ expect(result.completed[0].id).toBe('t1');
58
+ expect(result.completed[0].success).toBe(true);
59
+ expect(result.cancelled).toEqual([]);
60
+ expect(result.failed).toEqual([]);
61
+ expect(result.outputs).toEqual({ 'SUP-100': { code: 'done' } });
62
+ });
63
+ });
64
+ describe('race with 3 agents, agent 2 wins first', () => {
65
+ it('collects winner output and cancels others', async () => {
66
+ const cancellation = new InMemoryAgentCancellation();
67
+ const strategy = new RaceStrategy(cancellation);
68
+ const t1 = makeTask('t1', 'SUP-101');
69
+ const t2 = makeTask('t2', 'SUP-102');
70
+ const t3 = makeTask('t3', 'SUP-103');
71
+ const dispatch = async (task) => {
72
+ // Agent 2 resolves fastest
73
+ if (task.id === 't2') {
74
+ await new Promise((r) => setTimeout(r, 10));
75
+ return makeResult(task, { winner: true }, 10);
76
+ }
77
+ // Agent 1 and 3 take longer
78
+ await new Promise((r) => setTimeout(r, 50));
79
+ return makeResult(task, { winner: false }, 50);
80
+ };
81
+ const result = await strategy.execute([t1, t2, t3], { dispatch });
82
+ expect(result.strategy).toBe('race');
83
+ // All tasks should eventually complete (they all resolve successfully)
84
+ expect(result.completed.length).toBeGreaterThanOrEqual(1);
85
+ // Winner's outputs should be collected
86
+ expect(result.outputs['SUP-102']).toEqual({ winner: true });
87
+ // t1 and t3 should be cancelled
88
+ expect(result.cancelled).toContain('t1');
89
+ expect(result.cancelled).toContain('t3');
90
+ expect(result.cancelled).not.toContain('t2');
91
+ });
92
+ });
93
+ describe('all agents fail', () => {
94
+ it('returns aggregated errors and no cancellation', async () => {
95
+ const cancellation = new InMemoryAgentCancellation();
96
+ const strategy = new RaceStrategy(cancellation);
97
+ const t1 = makeTask('t1', 'SUP-201');
98
+ const t2 = makeTask('t2', 'SUP-202');
99
+ const t3 = makeTask('t3', 'SUP-203');
100
+ const dispatch = async (task) => {
101
+ throw new Error(`Agent ${task.id} failed`);
102
+ };
103
+ const result = await strategy.execute([t1, t2, t3], { dispatch });
104
+ expect(result.completed).toEqual([]);
105
+ expect(result.cancelled).toEqual([]);
106
+ expect(result.failed).toHaveLength(3);
107
+ const failedIds = result.failed.map((f) => f.id);
108
+ expect(failedIds).toContain('t1');
109
+ expect(failedIds).toContain('t2');
110
+ expect(failedIds).toContain('t3');
111
+ for (const f of result.failed) {
112
+ expect(f.error).toMatch(/Agent .+ failed/);
113
+ }
114
+ expect(result.outputs).toEqual({});
115
+ });
116
+ });
117
+ describe('cancellation tracking', () => {
118
+ it('cancelled IDs are reported in result', async () => {
119
+ const cancellation = new InMemoryAgentCancellation();
120
+ const strategy = new RaceStrategy(cancellation);
121
+ const t1 = makeTask('t1');
122
+ const t2 = makeTask('t2');
123
+ const t3 = makeTask('t3');
124
+ const dispatch = async (task) => {
125
+ if (task.id === 't1') {
126
+ // t1 wins immediately
127
+ return makeResult(task, { fast: true }, 1);
128
+ }
129
+ await new Promise((r) => setTimeout(r, 30));
130
+ return makeResult(task, {}, 30);
131
+ };
132
+ const result = await strategy.execute([t1, t2, t3], { dispatch });
133
+ // t2 and t3 should be in cancelled
134
+ expect(result.cancelled).toContain('t2');
135
+ expect(result.cancelled).toContain('t3');
136
+ expect(result.cancelled).toHaveLength(2);
137
+ // Cancellation state matches
138
+ expect(cancellation.isCancelled('t2')).toBe(true);
139
+ expect(cancellation.isCancelled('t3')).toBe(true);
140
+ expect(cancellation.isCancelled('t1')).toBe(false);
141
+ });
142
+ });
143
+ describe('winner outputs collected, non-winner outputs excluded', () => {
144
+ it('only includes the winner outputs in result.outputs', async () => {
145
+ const strategy = new RaceStrategy();
146
+ const t1 = makeTask('t1', 'SUP-301');
147
+ const t2 = makeTask('t2', 'SUP-302');
148
+ const dispatch = async (task) => {
149
+ if (task.id === 't1') {
150
+ // t1 wins
151
+ await new Promise((r) => setTimeout(r, 5));
152
+ return makeResult(task, { result: 'alpha' }, 5);
153
+ }
154
+ // t2 is slower and also succeeds
155
+ await new Promise((r) => setTimeout(r, 40));
156
+ return makeResult(task, { result: 'beta' }, 40);
157
+ };
158
+ const result = await strategy.execute([t1, t2], { dispatch });
159
+ // Winner's outputs should be in result.outputs
160
+ expect(result.outputs['SUP-301']).toEqual({ result: 'alpha' });
161
+ // Non-winner that completed after cancellation: since it still succeeds,
162
+ // its outputs appear only if success is true (per implementation)
163
+ // The key point: the winner's outputs are definitely there
164
+ expect(result.outputs).toHaveProperty('SUP-301');
165
+ });
166
+ });
167
+ describe('agent resolves after winner still appears in completed', () => {
168
+ it('late-finishing agents appear in completed list', async () => {
169
+ const strategy = new RaceStrategy();
170
+ const t1 = makeTask('t1', 'SUP-401');
171
+ const t2 = makeTask('t2', 'SUP-402');
172
+ const dispatch = async (task) => {
173
+ if (task.id === 't1') {
174
+ // t1 wins fast
175
+ return makeResult(task, { first: true }, 1);
176
+ }
177
+ // t2 completes later but still successfully
178
+ await new Promise((r) => setTimeout(r, 30));
179
+ return makeResult(task, { second: true }, 30);
180
+ };
181
+ const result = await strategy.execute([t1, t2], { dispatch });
182
+ // Both should appear in completed since both resolved successfully
183
+ const completedIds = result.completed.map((c) => c.id);
184
+ expect(completedIds).toContain('t1');
185
+ expect(completedIds).toContain('t2');
186
+ // t2 should still be marked as cancelled
187
+ expect(result.cancelled).toContain('t2');
188
+ });
189
+ });
190
+ describe('mixed success and failure', () => {
191
+ it('handles a mix of failures and successes correctly', async () => {
192
+ const strategy = new RaceStrategy();
193
+ const t1 = makeTask('t1', 'SUP-501');
194
+ const t2 = makeTask('t2', 'SUP-502');
195
+ const t3 = makeTask('t3', 'SUP-503');
196
+ const dispatch = async (task) => {
197
+ if (task.id === 't1') {
198
+ // t1 fails fast
199
+ throw new Error('Agent t1 crashed');
200
+ }
201
+ if (task.id === 't2') {
202
+ // t2 succeeds and becomes winner
203
+ await new Promise((r) => setTimeout(r, 15));
204
+ return makeResult(task, { answer: 42 }, 15);
205
+ }
206
+ // t3 succeeds later
207
+ await new Promise((r) => setTimeout(r, 40));
208
+ return makeResult(task, { answer: 99 }, 40);
209
+ };
210
+ const result = await strategy.execute([t1, t2, t3], { dispatch });
211
+ // t1 should be in failed
212
+ expect(result.failed).toHaveLength(1);
213
+ expect(result.failed[0].id).toBe('t1');
214
+ expect(result.failed[0].error).toBe('Agent t1 crashed');
215
+ // t2 should be the winner
216
+ expect(result.outputs['SUP-502']).toEqual({ answer: 42 });
217
+ // t3 should be cancelled
218
+ expect(result.cancelled).toContain('t3');
219
+ });
220
+ });
221
+ describe('non-Error rejection', () => {
222
+ it('handles non-Error rejection values', async () => {
223
+ const strategy = new RaceStrategy();
224
+ const t1 = makeTask('t1', 'SUP-601');
225
+ const dispatch = async (_task) => {
226
+ throw 'string error';
227
+ };
228
+ const result = await strategy.execute([t1], { dispatch });
229
+ expect(result.failed).toHaveLength(1);
230
+ expect(result.failed[0].error).toBe('string error');
231
+ });
232
+ });
233
+ describe('cancellation timeout', () => {
234
+ it('resolves after timeout if agent does not acknowledge cancellation', async () => {
235
+ const cancellation = new InMemoryAgentCancellation();
236
+ // Use a very short timeout (100ms) for test speed
237
+ const strategy = new RaceStrategy(cancellation, 100);
238
+ const t1 = makeTask('t1', 'SUP-701');
239
+ const t2 = makeTask('t2', 'SUP-702');
240
+ const dispatch = async (task) => {
241
+ if (task.id === 't1') {
242
+ // t1 wins immediately
243
+ return makeResult(task, { fast: true }, 1);
244
+ }
245
+ // t2 never completes — simulates an agent that never checks isCancelled()
246
+ return new Promise(() => {
247
+ // intentionally never resolves
248
+ });
249
+ };
250
+ const startTime = Date.now();
251
+ const result = await strategy.execute([t1, t2], { dispatch });
252
+ const elapsed = Date.now() - startTime;
253
+ // Should resolve within a reasonable time (timeout + buffer)
254
+ expect(elapsed).toBeLessThan(1000);
255
+ // Winner should still be collected
256
+ expect(result.completed).toHaveLength(1);
257
+ expect(result.completed[0].id).toBe('t1');
258
+ expect(result.outputs['SUP-701']).toEqual({ fast: true });
259
+ // t2 should be marked as cancelled
260
+ expect(result.cancelled).toContain('t2');
261
+ });
262
+ it('respects configurable timeout value', async () => {
263
+ const cancellation = new InMemoryAgentCancellation();
264
+ // Use a 200ms timeout
265
+ const strategy = new RaceStrategy(cancellation, 200);
266
+ const t1 = makeTask('t1', 'SUP-801');
267
+ const t2 = makeTask('t2', 'SUP-802');
268
+ const dispatch = async (task) => {
269
+ if (task.id === 't1') {
270
+ return makeResult(task, { winner: true }, 1);
271
+ }
272
+ // t2 hangs forever
273
+ return new Promise(() => { });
274
+ };
275
+ const startTime = Date.now();
276
+ await strategy.execute([t1, t2], { dispatch });
277
+ const elapsed = Date.now() - startTime;
278
+ // Should resolve after ~200ms, not immediately and not at 30s default
279
+ expect(elapsed).toBeGreaterThanOrEqual(150); // allow for timer imprecision
280
+ expect(elapsed).toBeLessThan(1000);
281
+ });
282
+ it('force-resolves with hanging agents after timeout expiry', async () => {
283
+ const cancellation = new InMemoryAgentCancellation();
284
+ const strategy = new RaceStrategy(cancellation, 100);
285
+ const t1 = makeTask('t1', 'SUP-901');
286
+ const t2 = makeTask('t2', 'SUP-902');
287
+ const t3 = makeTask('t3', 'SUP-903');
288
+ const dispatch = async (task) => {
289
+ if (task.id === 't1') {
290
+ // t1 wins fast
291
+ await new Promise((r) => setTimeout(r, 5));
292
+ return makeResult(task, { answer: 'first' }, 5);
293
+ }
294
+ // t2 and t3 hang forever — simulate agents that never check isCancelled()
295
+ return new Promise(() => { });
296
+ };
297
+ const result = await strategy.execute([t1, t2, t3], { dispatch });
298
+ // The strategy should have resolved despite hanging agents
299
+ expect(result.strategy).toBe('race');
300
+ expect(result.completed).toHaveLength(1);
301
+ expect(result.completed[0].id).toBe('t1');
302
+ expect(result.outputs['SUP-901']).toEqual({ answer: 'first' });
303
+ // Both hanging agents should be in the cancelled list
304
+ expect(result.cancelled).toContain('t2');
305
+ expect(result.cancelled).toContain('t3');
306
+ // They should NOT appear in failed since they didn't error
307
+ expect(result.failed).toHaveLength(0);
308
+ });
309
+ it('uses default 30s timeout when none is specified', () => {
310
+ // Verify the constructor default — we test this indirectly by confirming
311
+ // the strategy can be constructed without a timeout parameter
312
+ const strategy = new RaceStrategy();
313
+ // If this compiles and runs, the default is applied internally.
314
+ // We can't directly access the private field, but we verify it doesn't throw.
315
+ expect(strategy).toBeDefined();
316
+ });
317
+ });
318
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"transition-engine.d.ts","sourceRoot":"","sources":["../../../src/workflow/transition-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAElF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAO9D;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,aAAa,CAAA;IACpB,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,qDAAqD;IACrD,aAAa,EAAE,OAAO,CAAA;IACtB,0FAA0F;IAC1F,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACrC;AAMD,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,MAAM,EAAE,cAAc,CAAA;IACtB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAA;CACf;AAmCD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,iBAAiB,GAAG,gBAAgB,CAwE5E"}
1
+ {"version":3,"file":"transition-engine.d.ts","sourceRoot":"","sources":["../../../src/workflow/transition-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAA;AAElF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAO9D;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,aAAa,CAAA;IACpB,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,qDAAqD;IACrD,aAAa,EAAE,OAAO,CAAA;IACtB,0FAA0F;IAC1F,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACrC;AAMD,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,MAAM,EAAE,cAAc,CAAA;IACtB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAA;CACf;AAmCD;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,iBAAiB,GAAG,gBAAgB,CAqF5E"}
@@ -105,6 +105,18 @@ export function evaluateTransitions(ctx) {
105
105
  reason: `Phase '${match.to}' does not map to a known GovernorAction`,
106
106
  };
107
107
  }
108
+ // Check if the target phase belongs to a parallelism group.
109
+ // Only parent issues trigger parallel dispatch — sub-issues are dispatched
110
+ // individually by the ParallelismExecutor within the Governor.
111
+ if (isParentIssue) {
112
+ const group = registry.getParallelismGroup(match.to);
113
+ if (group) {
114
+ return {
115
+ action: 'trigger-parallel-group',
116
+ reason: `Issue ${issue.identifier} in '${issue.status}' → parallel group '${group.name}' (strategy: ${group.strategy})`,
117
+ };
118
+ }
119
+ }
108
120
  const parentSuffix = isParentIssue ? ' (parent — uses coordination template)' : '';
109
121
  return {
110
122
  action,
@@ -319,6 +319,98 @@ describe('evaluateTransitions', () => {
319
319
  expect(result.reason).not.toContain('coordination template');
320
320
  });
321
321
  });
322
+ // --- Parallelism group detection ---
323
+ describe('parallelism group detection', () => {
324
+ it('parent issue with phase in parallelism group returns trigger-parallel-group', () => {
325
+ const workflow = makeWorkflow({
326
+ parallelism: [
327
+ {
328
+ name: 'dev-parallel',
329
+ phases: ['development'],
330
+ strategy: 'fan-out',
331
+ },
332
+ ],
333
+ });
334
+ const ctx = makeContext({
335
+ issue: makeIssue({ status: 'Backlog' }),
336
+ registry: registryWith(workflow),
337
+ isParentIssue: true,
338
+ });
339
+ const result = evaluateTransitions(ctx);
340
+ expect(result.action).toBe('trigger-parallel-group');
341
+ expect(result.reason).toContain('parallel group');
342
+ expect(result.reason).toContain('dev-parallel');
343
+ expect(result.reason).toContain('fan-out');
344
+ });
345
+ it('non-parent issue with phase in parallelism group returns normal action', () => {
346
+ const workflow = makeWorkflow({
347
+ parallelism: [
348
+ {
349
+ name: 'dev-parallel',
350
+ phases: ['development'],
351
+ strategy: 'fan-out',
352
+ },
353
+ ],
354
+ });
355
+ const ctx = makeContext({
356
+ issue: makeIssue({ status: 'Backlog' }),
357
+ registry: registryWith(workflow),
358
+ isParentIssue: false,
359
+ });
360
+ const result = evaluateTransitions(ctx);
361
+ expect(result.action).toBe('trigger-development');
362
+ expect(result.reason).not.toContain('parallel group');
363
+ });
364
+ it('issue with no parallelism groups returns normal action', () => {
365
+ const ctx = makeContext({
366
+ issue: makeIssue({ status: 'Backlog' }),
367
+ isParentIssue: true,
368
+ });
369
+ const result = evaluateTransitions(ctx);
370
+ expect(result.action).toBe('trigger-development');
371
+ expect(result.reason).not.toContain('parallel group');
372
+ });
373
+ it('parent issue with phase NOT in any parallelism group returns normal action', () => {
374
+ const workflow = makeWorkflow({
375
+ parallelism: [
376
+ {
377
+ name: 'qa-parallel',
378
+ phases: ['qa'],
379
+ strategy: 'fan-in',
380
+ },
381
+ ],
382
+ });
383
+ const ctx = makeContext({
384
+ issue: makeIssue({ status: 'Backlog' }),
385
+ registry: registryWith(workflow),
386
+ isParentIssue: true,
387
+ });
388
+ const result = evaluateTransitions(ctx);
389
+ // 'development' is not in the parallelism group, so normal action
390
+ expect(result.action).toBe('trigger-development');
391
+ expect(result.reason).toContain('coordination template');
392
+ });
393
+ it('escalation strategy overrides parallelism group detection', () => {
394
+ const workflow = makeWorkflow({
395
+ parallelism: [
396
+ {
397
+ name: 'dev-parallel',
398
+ phases: ['development'],
399
+ strategy: 'fan-out',
400
+ },
401
+ ],
402
+ });
403
+ const ctx = makeContext({
404
+ issue: makeIssue({ status: 'Backlog' }),
405
+ registry: registryWith(workflow),
406
+ isParentIssue: true,
407
+ workflowStrategy: 'escalate-human',
408
+ });
409
+ const result = evaluateTransitions(ctx);
410
+ // Escalation takes priority over parallelism
411
+ expect(result.action).toBe('escalate-human');
412
+ });
413
+ });
322
414
  // --- Phase-to-action mapping ---
323
415
  describe('phase-to-action mapping', () => {
324
416
  it('returns none for unknown phase name', () => {
@@ -9,7 +9,7 @@
9
9
  * 2. Project-level override (e.g., .agentfactory/workflow.yaml)
10
10
  * 3. Inline config override (programmatic)
11
11
  */
12
- import type { WorkflowDefinition, EscalationConfig } from './workflow-types.js';
12
+ import type { WorkflowDefinition, EscalationConfig, ParallelismGroupDefinition } from './workflow-types.js';
13
13
  /**
14
14
  * Interface for an external workflow store (e.g., Redis-backed).
15
15
  * WorkflowRegistry can load definitions from this store as an additional layer.
@@ -86,6 +86,10 @@ export declare class WorkflowRegistry {
86
86
  * Falls back to 'normal' if no match or no escalation config.
87
87
  */
88
88
  getEscalationStrategy(cycleCount: number): string;
89
+ /**
90
+ * Get the parallelism group that contains the given phase, if any.
91
+ */
92
+ getParallelismGroup(phaseName: string): ParallelismGroupDefinition | undefined;
89
93
  /**
90
94
  * Get circuit breaker limits from the workflow definition.
91
95
  */
@@ -1 +1 @@
1
- {"version":3,"file":"workflow-registry.d.ts","sourceRoot":"","sources":["../../../src/workflow/workflow-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAO/E;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAChF,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,sBAAsB;IACrC,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,kBAAkB,CAAA;IAC7B,oEAAoE;IACpE,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,wFAAwF;IACxF,KAAK,CAAC,EAAE,mBAAmB,CAAA;IAC3B,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAcD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,SAAS,CAAC,CAAwC;;IAI1D;;;OAGG;IACH,MAAM,CAAC,MAAM,CAAC,MAAM,GAAE,sBAA2B,GAAG,gBAAgB;IAMpE;;OAEG;WACU,WAAW,CAAC,MAAM,GAAE,sBAA2B,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAMxF;;;;OAIG;IACH,UAAU,CAAC,MAAM,GAAE,sBAA2B,GAAG,IAAI;IAsBrD;;;;;;;OAOG;IACG,eAAe,CAAC,MAAM,GAAE,sBAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAqCzE;;;OAGG;IACH,WAAW,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI;IAK/C;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,GAAG,IAAI;IAIhE;;OAEG;IACH,WAAW,IAAI,kBAAkB,GAAG,IAAI;IAIxC;;OAEG;IACH,aAAa,IAAI,gBAAgB,GAAG,IAAI;IAIxC;;;;;OAKG;IACH,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAcjD;;OAEG;IACH,uBAAuB,IAAI;QAAE,mBAAmB,EAAE,MAAM,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE;CAOxF"}
1
+ {"version":3,"file":"workflow-registry.d.ts","sourceRoot":"","sources":["../../../src/workflow/workflow-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAA;AAO3G;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAChF,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAA;CACvC;AAED,MAAM,WAAW,sBAAsB;IACrC,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,kBAAkB,CAAA;IAC7B,oEAAoE;IACpE,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,wFAAwF;IACxF,KAAK,CAAC,EAAE,mBAAmB,CAAA;IAC3B,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAcD,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAkC;IAClD,OAAO,CAAC,SAAS,CAAC,CAAwC;;IAI1D;;;OAGG;IACH,MAAM,CAAC,MAAM,CAAC,MAAM,GAAE,sBAA2B,GAAG,gBAAgB;IAMpE;;OAEG;WACU,WAAW,CAAC,MAAM,GAAE,sBAA2B,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAMxF;;;;OAIG;IACH,UAAU,CAAC,MAAM,GAAE,sBAA2B,GAAG,IAAI;IAsBrD;;;;;;;OAOG;IACG,eAAe,CAAC,MAAM,GAAE,sBAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAqCzE;;;OAGG;IACH,WAAW,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI;IAK/C;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,GAAG,IAAI;IAIhE;;OAEG;IACH,WAAW,IAAI,kBAAkB,GAAG,IAAI;IAIxC;;OAEG;IACH,aAAa,IAAI,gBAAgB,GAAG,IAAI;IAIxC;;;;;OAKG;IACH,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAcjD;;OAEG;IACH,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,0BAA0B,GAAG,SAAS;IAK9E;;OAEG;IACH,uBAAuB,IAAI;QAAE,mBAAmB,EAAE,MAAM,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE;CAOxF"}
@@ -148,6 +148,14 @@ export class WorkflowRegistry {
148
148
  const match = sorted.find(rung => cycleCount >= rung.cycle);
149
149
  return match?.strategy ?? 'normal';
150
150
  }
151
+ /**
152
+ * Get the parallelism group that contains the given phase, if any.
153
+ */
154
+ getParallelismGroup(phaseName) {
155
+ if (!this.workflow?.parallelism)
156
+ return undefined;
157
+ return this.workflow.parallelism.find(g => g.phases.includes(phaseName));
158
+ }
151
159
  /**
152
160
  * Get circuit breaker limits from the workflow definition.
153
161
  */
@@ -185,6 +185,60 @@ describe('WorkflowRegistry', () => {
185
185
  expect(limits.maxSessionsPerPhase).toBe(3);
186
186
  });
187
187
  });
188
+ describe('getParallelismGroup()', () => {
189
+ it('returns the group containing the given phase', () => {
190
+ const custom = makeWorkflow({
191
+ parallelism: [
192
+ {
193
+ name: 'dev-parallel',
194
+ phases: ['development'],
195
+ strategy: 'fan-out',
196
+ },
197
+ {
198
+ name: 'qa-parallel',
199
+ phases: ['qa'],
200
+ strategy: 'fan-in',
201
+ },
202
+ ],
203
+ });
204
+ const registry = WorkflowRegistry.create({ workflow: custom });
205
+ const group = registry.getParallelismGroup('development');
206
+ expect(group).toBeDefined();
207
+ expect(group.name).toBe('dev-parallel');
208
+ expect(group.strategy).toBe('fan-out');
209
+ });
210
+ it('returns undefined for phase not in any group', () => {
211
+ const custom = makeWorkflow({
212
+ parallelism: [
213
+ {
214
+ name: 'dev-parallel',
215
+ phases: ['development'],
216
+ strategy: 'fan-out',
217
+ },
218
+ ],
219
+ });
220
+ const registry = WorkflowRegistry.create({ workflow: custom });
221
+ expect(registry.getParallelismGroup('qa')).toBeUndefined();
222
+ });
223
+ it('returns undefined when no parallelism defined', () => {
224
+ const custom = makeWorkflow(); // No parallelism
225
+ const registry = WorkflowRegistry.create({ workflow: custom });
226
+ expect(registry.getParallelismGroup('development')).toBeUndefined();
227
+ });
228
+ it('returns undefined for a completely unknown phase name', () => {
229
+ const custom = makeWorkflow({
230
+ parallelism: [
231
+ {
232
+ name: 'dev-parallel',
233
+ phases: ['development'],
234
+ strategy: 'fan-out',
235
+ },
236
+ ],
237
+ });
238
+ const registry = WorkflowRegistry.create({ workflow: custom });
239
+ expect(registry.getParallelismGroup('nonexistent-phase')).toBeUndefined();
240
+ });
241
+ });
188
242
  describe('getEscalation()', () => {
189
243
  it('returns escalation config from workflow', () => {
190
244
  const registry = WorkflowRegistry.create();