bosun 0.41.0 → 0.41.2
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/.env.example +8 -0
- package/README.md +20 -0
- package/agent/agent-event-bus.mjs +248 -6
- package/agent/agent-pool.mjs +125 -28
- package/agent/agent-work-analyzer.mjs +8 -16
- package/agent/retry-queue.mjs +164 -0
- package/bosun.config.example.json +25 -0
- package/bosun.schema.json +825 -183
- package/cli.mjs +59 -5
- package/config/config.mjs +130 -3
- package/infra/monitor.mjs +693 -67
- package/infra/runtime-accumulator.mjs +376 -84
- package/infra/session-tracker.mjs +82 -25
- package/lib/codebase-audit.mjs +133 -18
- package/package.json +23 -4
- package/server/setup-web-server.mjs +25 -0
- package/server/ui-server.mjs +248 -29
- package/setup.mjs +27 -24
- package/shell/codex-shell.mjs +34 -3
- package/shell/copilot-shell.mjs +50 -8
- package/task/msg-hub.mjs +193 -0
- package/task/pipeline.mjs +544 -0
- package/task/task-cli.mjs +38 -2
- package/task/task-executor-pipeline.mjs +143 -0
- package/task/task-executor.mjs +36 -27
- package/telegram/get-telegram-chat-id.mjs +57 -47
- package/ui/components/workspace-switcher.js +7 -7
- package/ui/demo-defaults.js +15694 -10573
- package/ui/modules/settings-schema.js +2 -0
- package/ui/modules/state.js +54 -57
- package/ui/modules/voice-client-sdk.js +375 -36
- package/ui/modules/voice-client.js +140 -31
- package/ui/setup.html +68 -2
- package/ui/styles/components.css +57 -0
- package/ui/styles.css +201 -1
- package/ui/tabs/dashboard.js +74 -0
- package/ui/tabs/logs.js +10 -0
- package/ui/tabs/settings.js +178 -99
- package/ui/tabs/tasks.js +31 -1
- package/ui/tabs/telemetry.js +34 -0
- package/ui/tabs/workflow-canvas-utils.mjs +8 -1
- package/ui/tabs/workflows.js +532 -275
- package/voice/voice-agents-sdk.mjs +1 -1
- package/voice/voice-relay.mjs +6 -6
- package/workflow/declarative-workflows.mjs +145 -0
- package/workflow/msg-hub.mjs +237 -0
- package/workflow/pipeline-workflows.mjs +287 -0
- package/workflow/pipeline.mjs +828 -315
- package/workflow/workflow-cli.mjs +128 -0
- package/workflow/workflow-engine.mjs +329 -17
- package/workflow/workflow-nodes/custom-loader.mjs +250 -0
- package/workflow/workflow-nodes.mjs +1955 -223
- package/workflow/workflow-templates.mjs +26 -8
- package/workflow-templates/agents.mjs +0 -1
- package/workflow-templates/bosun-native.mjs +212 -2
- package/workflow-templates/continuation-loop.mjs +339 -0
- package/workflow-templates/github.mjs +516 -40
- package/workflow-templates/planning.mjs +446 -17
- package/workflow-templates/reliability.mjs +65 -12
- package/workflow-templates/task-batch.mjs +24 -8
- package/workflow-templates/task-lifecycle.mjs +83 -6
- package/workspace/context-cache.mjs +66 -18
- package/workspace/workspace-manager.mjs +2 -1
- package/workflow-templates/issue-continuation.mjs +0 -243
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module task/pipeline
|
|
3
|
+
* @description Declarative multi-agent pipeline primitives with fresh-context stage isolation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
function normalizeStage(stage, index) {
|
|
9
|
+
if (typeof stage === "function") {
|
|
10
|
+
return {
|
|
11
|
+
id: `stage-${index + 1}`,
|
|
12
|
+
name: stage.name || `stage-${index + 1}`,
|
|
13
|
+
run: stage,
|
|
14
|
+
meta: {},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (stage && typeof stage === "object") {
|
|
19
|
+
const runner =
|
|
20
|
+
typeof stage.run === "function"
|
|
21
|
+
? stage.run.bind(stage)
|
|
22
|
+
: typeof stage.execute === "function"
|
|
23
|
+
? stage.execute.bind(stage)
|
|
24
|
+
: null;
|
|
25
|
+
if (!runner) {
|
|
26
|
+
throw new TypeError(`Pipeline stage ${index + 1} is missing run/execute()`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
id: String(stage.id || stage.name || `stage-${index + 1}`),
|
|
30
|
+
name: String(stage.name || stage.id || `stage-${index + 1}`),
|
|
31
|
+
run: runner,
|
|
32
|
+
meta: { ...stage },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new TypeError(`Unsupported pipeline stage at index ${index}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeStages(stages) {
|
|
40
|
+
if (!Array.isArray(stages) || stages.length === 0) {
|
|
41
|
+
throw new TypeError("Pipeline requires at least one stage");
|
|
42
|
+
}
|
|
43
|
+
return stages.map((stage, index) => normalizeStage(stage, index));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeError(error) {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return {
|
|
49
|
+
message: error.message,
|
|
50
|
+
name: error.name,
|
|
51
|
+
stack: error.stack || "",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
message: String(error || "Unknown pipeline error"),
|
|
56
|
+
name: "Error",
|
|
57
|
+
stack: "",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function coerceText(value) {
|
|
62
|
+
if (value == null) return "";
|
|
63
|
+
if (typeof value === "string") return value;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.stringify(value);
|
|
66
|
+
} catch {
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pickDescriptorFields(value) {
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
73
|
+
const descriptor = {};
|
|
74
|
+
const mappings = [
|
|
75
|
+
["taskId", ["taskId", "id"]],
|
|
76
|
+
["title", ["title", "taskTitle", "name"]],
|
|
77
|
+
["summary", ["summary", "message", "result"]],
|
|
78
|
+
["branch", ["branch", "branchName"]],
|
|
79
|
+
["baseBranch", ["baseBranch"]],
|
|
80
|
+
["repoRoot", ["repoRoot", "cwd"]],
|
|
81
|
+
["repoSlug", ["repoSlug"]],
|
|
82
|
+
["workspace", ["workspace"]],
|
|
83
|
+
["repository", ["repository", "repo"]],
|
|
84
|
+
["status", ["status"]],
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const [targetKey, candidateKeys] of mappings) {
|
|
88
|
+
for (const key of candidateKeys) {
|
|
89
|
+
const candidate = value[key];
|
|
90
|
+
if (candidate == null || candidate === "") continue;
|
|
91
|
+
descriptor[targetKey] = candidate;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const paths = value.paths || value.filePaths || value.files;
|
|
97
|
+
if (Array.isArray(paths) && paths.length > 0) {
|
|
98
|
+
descriptor.paths = paths.filter(Boolean).map((entry) => String(entry)).slice(0, 50);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Object.keys(descriptor).length > 0 ? descriptor : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function toMinimalDescriptor(value) {
|
|
105
|
+
const descriptor = pickDescriptorFields(value);
|
|
106
|
+
if (descriptor) return descriptor;
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
return {
|
|
109
|
+
items: value.slice(0, 10).map((entry) => toMinimalDescriptor(entry)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
summary: coerceText(value).slice(0, 4000),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function defaultPrepareStageInput(previousRecord, initialInput) {
|
|
118
|
+
if (!previousRecord) return toMinimalDescriptor(initialInput);
|
|
119
|
+
return previousRecord.descriptor || toMinimalDescriptor(previousRecord.output);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function defaultGetTokensUsed(result) {
|
|
123
|
+
const candidates = [
|
|
124
|
+
result?.tokensUsed,
|
|
125
|
+
result?.usage?.totalTokens,
|
|
126
|
+
result?.usage?.total_tokens,
|
|
127
|
+
result?.tokenUsage?.total,
|
|
128
|
+
result?.metrics?.tokensUsed,
|
|
129
|
+
];
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
const parsed = Number(candidate);
|
|
132
|
+
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createBaseContext({
|
|
138
|
+
pipelineType,
|
|
139
|
+
runId,
|
|
140
|
+
stage,
|
|
141
|
+
index,
|
|
142
|
+
initialInput,
|
|
143
|
+
stageInput,
|
|
144
|
+
previousRecord,
|
|
145
|
+
signal,
|
|
146
|
+
options,
|
|
147
|
+
}) {
|
|
148
|
+
return {
|
|
149
|
+
runId,
|
|
150
|
+
pipelineType,
|
|
151
|
+
stageId: stage.id,
|
|
152
|
+
stageName: stage.name,
|
|
153
|
+
stageIndex: index,
|
|
154
|
+
initialInput: toMinimalDescriptor(initialInput),
|
|
155
|
+
input: toMinimalDescriptor(stageInput),
|
|
156
|
+
previousOutput: previousRecord ? previousRecord.descriptor : null,
|
|
157
|
+
freshContext: true,
|
|
158
|
+
signal,
|
|
159
|
+
options,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function createStageRecord({ stage, index, input, result, startedAt, endedAt, successOverride }) {
|
|
164
|
+
const rawOutput = result && typeof result === "object" && Object.hasOwn(result, "output")
|
|
165
|
+
? result.output
|
|
166
|
+
: result;
|
|
167
|
+
const success =
|
|
168
|
+
typeof successOverride === "boolean"
|
|
169
|
+
? successOverride
|
|
170
|
+
: !(result && typeof result === "object" && result.success === false);
|
|
171
|
+
return {
|
|
172
|
+
stageId: stage.id,
|
|
173
|
+
stageName: stage.name,
|
|
174
|
+
stageIndex: index,
|
|
175
|
+
input: toMinimalDescriptor(input),
|
|
176
|
+
output: rawOutput,
|
|
177
|
+
descriptor: toMinimalDescriptor(
|
|
178
|
+
result && typeof result === "object" && result.descriptor
|
|
179
|
+
? result.descriptor
|
|
180
|
+
: rawOutput,
|
|
181
|
+
),
|
|
182
|
+
success,
|
|
183
|
+
tokensUsed: defaultGetTokensUsed(result),
|
|
184
|
+
meta: result && typeof result === "object" ? { ...result } : {},
|
|
185
|
+
startedAt,
|
|
186
|
+
endedAt,
|
|
187
|
+
durationMs: Math.max(0, endedAt - startedAt),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function createCancelledRecord(stage, index, input, reason) {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
return {
|
|
194
|
+
stageId: stage.id,
|
|
195
|
+
stageName: stage.name,
|
|
196
|
+
stageIndex: index,
|
|
197
|
+
input: toMinimalDescriptor(input),
|
|
198
|
+
output: null,
|
|
199
|
+
descriptor: { summary: reason },
|
|
200
|
+
success: false,
|
|
201
|
+
tokensUsed: 0,
|
|
202
|
+
meta: { cancelled: true, reason },
|
|
203
|
+
startedAt: now,
|
|
204
|
+
endedAt: now,
|
|
205
|
+
durationMs: 0,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function finalizePipelineResult(type, startedAt, outputs, errors, extra = {}) {
|
|
210
|
+
const endedAt = Date.now();
|
|
211
|
+
const tokensUsed = outputs.reduce(
|
|
212
|
+
(sum, record) => sum + (Number(record?.tokensUsed) || 0),
|
|
213
|
+
0,
|
|
214
|
+
);
|
|
215
|
+
return {
|
|
216
|
+
ok: errors.length === 0 && outputs.some((record) => record?.success !== false),
|
|
217
|
+
type,
|
|
218
|
+
outputs,
|
|
219
|
+
timing: {
|
|
220
|
+
startedAt,
|
|
221
|
+
endedAt,
|
|
222
|
+
durationMs: Math.max(0, endedAt - startedAt),
|
|
223
|
+
stages: outputs.map((record) => ({
|
|
224
|
+
stageId: record.stageId,
|
|
225
|
+
stageName: record.stageName,
|
|
226
|
+
startedAt: record.startedAt,
|
|
227
|
+
endedAt: record.endedAt,
|
|
228
|
+
durationMs: record.durationMs,
|
|
229
|
+
})),
|
|
230
|
+
},
|
|
231
|
+
tokensUsed,
|
|
232
|
+
errors,
|
|
233
|
+
...extra,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createPipeline(type, stages, options = {}, runner) {
|
|
238
|
+
const normalizedStages = normalizeStages(stages);
|
|
239
|
+
const pipelineOptions = { ...options };
|
|
240
|
+
return Object.freeze({
|
|
241
|
+
type,
|
|
242
|
+
stages: normalizedStages.map((stage) => ({ ...stage.meta, id: stage.id, name: stage.name })),
|
|
243
|
+
options: pipelineOptions,
|
|
244
|
+
async run(initialInput, runtimeOptions = {}) {
|
|
245
|
+
return runner(normalizedStages, initialInput, { ...pipelineOptions, ...runtimeOptions });
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function executeStage(stage, input, baseContext, options) {
|
|
251
|
+
const createContext =
|
|
252
|
+
typeof options.createContext === "function"
|
|
253
|
+
? options.createContext
|
|
254
|
+
: (ctx) => ctx;
|
|
255
|
+
const context = createContext(baseContext);
|
|
256
|
+
if (context?.signal?.aborted) {
|
|
257
|
+
const error = new Error(String(context.signal.reason || "aborted"));
|
|
258
|
+
error.name = "AbortError";
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
return stage.run(input, context);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function SequentialPipeline(stages, options = {}) {
|
|
265
|
+
return createPipeline("sequential", stages, options, async (normalizedStages, initialInput, runtimeOptions) => {
|
|
266
|
+
const startedAt = Date.now();
|
|
267
|
+
const runId = String(runtimeOptions.runId || randomUUID());
|
|
268
|
+
const outputs = [];
|
|
269
|
+
const errors = [];
|
|
270
|
+
const prepareStageInput =
|
|
271
|
+
typeof runtimeOptions.prepareStageInput === "function"
|
|
272
|
+
? runtimeOptions.prepareStageInput
|
|
273
|
+
: defaultPrepareStageInput;
|
|
274
|
+
|
|
275
|
+
let previousRecord = null;
|
|
276
|
+
for (const [index, stage] of normalizedStages.entries()) {
|
|
277
|
+
const stageInput =
|
|
278
|
+
index === 0
|
|
279
|
+
? initialInput
|
|
280
|
+
: prepareStageInput(previousRecord, initialInput, stage, index, outputs.slice());
|
|
281
|
+
const started = Date.now();
|
|
282
|
+
try {
|
|
283
|
+
runtimeOptions.onStageStart?.(stage, stageInput, index);
|
|
284
|
+
const result = await executeStage(
|
|
285
|
+
stage,
|
|
286
|
+
stageInput,
|
|
287
|
+
createBaseContext({
|
|
288
|
+
pipelineType: "sequential",
|
|
289
|
+
runId,
|
|
290
|
+
stage,
|
|
291
|
+
index,
|
|
292
|
+
initialInput,
|
|
293
|
+
stageInput,
|
|
294
|
+
previousRecord,
|
|
295
|
+
signal: runtimeOptions.signal || null,
|
|
296
|
+
options: runtimeOptions,
|
|
297
|
+
}),
|
|
298
|
+
runtimeOptions,
|
|
299
|
+
);
|
|
300
|
+
const record = createStageRecord({
|
|
301
|
+
stage,
|
|
302
|
+
index,
|
|
303
|
+
input: stageInput,
|
|
304
|
+
result,
|
|
305
|
+
startedAt: started,
|
|
306
|
+
endedAt: Date.now(),
|
|
307
|
+
});
|
|
308
|
+
outputs.push(record);
|
|
309
|
+
previousRecord = record;
|
|
310
|
+
runtimeOptions.onStageComplete?.(record, index);
|
|
311
|
+
if (record.success === false) {
|
|
312
|
+
errors.push({ stageId: stage.id, stageName: stage.name, error: normalizeError(record.meta?.error || "Stage returned success=false") });
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const normalized = normalizeError(error);
|
|
317
|
+
outputs.push(
|
|
318
|
+
createStageRecord({
|
|
319
|
+
stage,
|
|
320
|
+
index,
|
|
321
|
+
input: stageInput,
|
|
322
|
+
result: { output: null, error: normalized.message, success: false },
|
|
323
|
+
startedAt: started,
|
|
324
|
+
endedAt: Date.now(),
|
|
325
|
+
successOverride: false,
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
errors.push({ stageId: stage.id, stageName: stage.name, error: normalized });
|
|
329
|
+
runtimeOptions.onStageError?.(normalized, stage, index);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return finalizePipelineResult("sequential", startedAt, outputs, errors, {
|
|
335
|
+
finalOutput: outputs.at(-1)?.output ?? null,
|
|
336
|
+
runId,
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function FanoutPipeline(stages, options = {}) {
|
|
342
|
+
return createPipeline("fanout", stages, options, async (normalizedStages, initialInput, runtimeOptions) => {
|
|
343
|
+
const startedAt = Date.now();
|
|
344
|
+
const runId = String(runtimeOptions.runId || randomUUID());
|
|
345
|
+
const outputs = new Array(normalizedStages.length);
|
|
346
|
+
const errors = [];
|
|
347
|
+
const prepareStageInput =
|
|
348
|
+
typeof runtimeOptions.prepareStageInput === "function"
|
|
349
|
+
? runtimeOptions.prepareStageInput
|
|
350
|
+
: (_previousRecord, seed) => toMinimalDescriptor(seed);
|
|
351
|
+
|
|
352
|
+
await Promise.allSettled(
|
|
353
|
+
normalizedStages.map(async (stage, index) => {
|
|
354
|
+
const stageInput = prepareStageInput(null, initialInput, stage, index, []);
|
|
355
|
+
const started = Date.now();
|
|
356
|
+
try {
|
|
357
|
+
runtimeOptions.onStageStart?.(stage, stageInput, index);
|
|
358
|
+
const result = await executeStage(
|
|
359
|
+
stage,
|
|
360
|
+
stageInput,
|
|
361
|
+
createBaseContext({
|
|
362
|
+
pipelineType: "fanout",
|
|
363
|
+
runId,
|
|
364
|
+
stage,
|
|
365
|
+
index,
|
|
366
|
+
initialInput,
|
|
367
|
+
stageInput,
|
|
368
|
+
previousRecord: null,
|
|
369
|
+
signal: runtimeOptions.signal || null,
|
|
370
|
+
options: runtimeOptions,
|
|
371
|
+
}),
|
|
372
|
+
runtimeOptions,
|
|
373
|
+
);
|
|
374
|
+
outputs[index] = createStageRecord({
|
|
375
|
+
stage,
|
|
376
|
+
index,
|
|
377
|
+
input: stageInput,
|
|
378
|
+
result,
|
|
379
|
+
startedAt: started,
|
|
380
|
+
endedAt: Date.now(),
|
|
381
|
+
});
|
|
382
|
+
runtimeOptions.onStageComplete?.(outputs[index], index);
|
|
383
|
+
if (outputs[index].success === false) {
|
|
384
|
+
errors.push({ stageId: stage.id, stageName: stage.name, error: normalizeError(outputs[index].meta?.error || "Stage returned success=false") });
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
const normalized = normalizeError(error);
|
|
388
|
+
outputs[index] = createStageRecord({
|
|
389
|
+
stage,
|
|
390
|
+
index,
|
|
391
|
+
input: stageInput,
|
|
392
|
+
result: { output: null, error: normalized.message, success: false },
|
|
393
|
+
startedAt: started,
|
|
394
|
+
endedAt: Date.now(),
|
|
395
|
+
successOverride: false,
|
|
396
|
+
});
|
|
397
|
+
errors.push({ stageId: stage.id, stageName: stage.name, error: normalized });
|
|
398
|
+
runtimeOptions.onStageError?.(normalized, stage, index);
|
|
399
|
+
}
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
return finalizePipelineResult(
|
|
404
|
+
"fanout",
|
|
405
|
+
startedAt,
|
|
406
|
+
outputs.filter(Boolean),
|
|
407
|
+
errors,
|
|
408
|
+
{ runId },
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function RacePipeline(stages, options = {}) {
|
|
414
|
+
return createPipeline("race", stages, options, async (normalizedStages, initialInput, runtimeOptions) => {
|
|
415
|
+
const startedAt = Date.now();
|
|
416
|
+
const runId = String(runtimeOptions.runId || randomUUID());
|
|
417
|
+
const outputs = new Array(normalizedStages.length);
|
|
418
|
+
const errors = [];
|
|
419
|
+
const prepareStageInput =
|
|
420
|
+
typeof runtimeOptions.prepareStageInput === "function"
|
|
421
|
+
? runtimeOptions.prepareStageInput
|
|
422
|
+
: (_previousRecord, seed) => toMinimalDescriptor(seed);
|
|
423
|
+
|
|
424
|
+
const controllers = normalizedStages.map(() => new AbortController());
|
|
425
|
+
if (runtimeOptions.signal) {
|
|
426
|
+
const propagateAbort = () => {
|
|
427
|
+
for (const controller of controllers) {
|
|
428
|
+
if (!controller.signal.aborted) {
|
|
429
|
+
controller.abort(runtimeOptions.signal.reason || "aborted");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
if (runtimeOptions.signal.aborted) {
|
|
434
|
+
propagateAbort();
|
|
435
|
+
} else {
|
|
436
|
+
runtimeOptions.signal.addEventListener("abort", propagateAbort, { once: true });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let winner = null;
|
|
441
|
+
let resolved = false;
|
|
442
|
+
|
|
443
|
+
const settleWinner = (record, index) => {
|
|
444
|
+
winner = { ...record, stageIndex: index };
|
|
445
|
+
for (const [controllerIndex, controller] of controllers.entries()) {
|
|
446
|
+
if (controllerIndex === index || controller.signal.aborted) continue;
|
|
447
|
+
controller.abort("race_won");
|
|
448
|
+
if (!outputs[controllerIndex]) {
|
|
449
|
+
outputs[controllerIndex] = createCancelledRecord(
|
|
450
|
+
normalizedStages[controllerIndex],
|
|
451
|
+
controllerIndex,
|
|
452
|
+
prepareStageInput(null, initialInput, normalizedStages[controllerIndex], controllerIndex, []),
|
|
453
|
+
"Cancelled after another stage won the race",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
resolved = true;
|
|
458
|
+
return finalizePipelineResult(
|
|
459
|
+
"race",
|
|
460
|
+
startedAt,
|
|
461
|
+
outputs.filter(Boolean),
|
|
462
|
+
errors,
|
|
463
|
+
{ winner, finalOutput: winner.output, runId },
|
|
464
|
+
);
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const pending = normalizedStages.map((stage, index) => (async () => {
|
|
468
|
+
const stageInput = prepareStageInput(null, initialInput, stage, index, []);
|
|
469
|
+
const started = Date.now();
|
|
470
|
+
try {
|
|
471
|
+
runtimeOptions.onStageStart?.(stage, stageInput, index);
|
|
472
|
+
const result = await executeStage(
|
|
473
|
+
stage,
|
|
474
|
+
stageInput,
|
|
475
|
+
createBaseContext({
|
|
476
|
+
pipelineType: "race",
|
|
477
|
+
runId,
|
|
478
|
+
stage,
|
|
479
|
+
index,
|
|
480
|
+
initialInput,
|
|
481
|
+
stageInput,
|
|
482
|
+
previousRecord: null,
|
|
483
|
+
signal: controllers[index].signal,
|
|
484
|
+
options: runtimeOptions,
|
|
485
|
+
}),
|
|
486
|
+
runtimeOptions,
|
|
487
|
+
);
|
|
488
|
+
const record = createStageRecord({
|
|
489
|
+
stage,
|
|
490
|
+
index,
|
|
491
|
+
input: stageInput,
|
|
492
|
+
result,
|
|
493
|
+
startedAt: started,
|
|
494
|
+
endedAt: Date.now(),
|
|
495
|
+
});
|
|
496
|
+
outputs[index] = record;
|
|
497
|
+
runtimeOptions.onStageComplete?.(record, index);
|
|
498
|
+
if (record.success !== false && !resolved) {
|
|
499
|
+
return settleWinner(record, index);
|
|
500
|
+
}
|
|
501
|
+
if (record.success === false) {
|
|
502
|
+
errors.push({ stageId: stage.id, stageName: stage.name, error: normalizeError(record.meta?.error || "Stage returned success=false") });
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
const normalized = normalizeError(error);
|
|
507
|
+
const isAbort = normalized.name === "AbortError" || /aborted|race_won/i.test(normalized.message);
|
|
508
|
+
outputs[index] = createStageRecord({
|
|
509
|
+
stage,
|
|
510
|
+
index,
|
|
511
|
+
input: stageInput,
|
|
512
|
+
result: { output: null, error: normalized.message, success: false },
|
|
513
|
+
startedAt: started,
|
|
514
|
+
endedAt: Date.now(),
|
|
515
|
+
successOverride: false,
|
|
516
|
+
});
|
|
517
|
+
if (!isAbort || !resolved) {
|
|
518
|
+
errors.push({ stageId: stage.id, stageName: stage.name, error: normalized });
|
|
519
|
+
runtimeOptions.onStageError?.(normalized, stage, index);
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
})());
|
|
524
|
+
|
|
525
|
+
const firstResult = await Promise.race(pending);
|
|
526
|
+
if (firstResult) return firstResult;
|
|
527
|
+
|
|
528
|
+
await Promise.allSettled(pending);
|
|
529
|
+
return finalizePipelineResult(
|
|
530
|
+
"race",
|
|
531
|
+
startedAt,
|
|
532
|
+
outputs.filter(Boolean),
|
|
533
|
+
errors,
|
|
534
|
+
{ winner: null, finalOutput: null, runId },
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export default {
|
|
540
|
+
SequentialPipeline,
|
|
541
|
+
FanoutPipeline,
|
|
542
|
+
RacePipeline,
|
|
543
|
+
toMinimalDescriptor,
|
|
544
|
+
};
|
package/task/task-cli.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { homedir } from "node:os";
|
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
32
32
|
import { randomUUID } from "node:crypto";
|
|
33
|
+
import { getTaskLifetimeTotals } from "../infra/runtime-accumulator.mjs";
|
|
33
34
|
|
|
34
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
35
36
|
const __dirname = dirname(__filename);
|
|
@@ -359,6 +360,34 @@ export async function taskList(filters = {}) {
|
|
|
359
360
|
return tasks;
|
|
360
361
|
}
|
|
361
362
|
|
|
363
|
+
function withTaskLifetimeTotals(task) {
|
|
364
|
+
if (!task || typeof task !== "object") return task;
|
|
365
|
+
const taskId = String(task.id || task.taskId || "").trim();
|
|
366
|
+
const lifetimeTotals = taskId ? getTaskLifetimeTotals(taskId) : null;
|
|
367
|
+
return {
|
|
368
|
+
...task,
|
|
369
|
+
lifetimeTotals,
|
|
370
|
+
meta: {
|
|
371
|
+
...(task.meta || {}),
|
|
372
|
+
lifetimeTotals,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function formatDurationMs(ms) {
|
|
378
|
+
const value = Number(ms || 0);
|
|
379
|
+
if (!Number.isFinite(value) || value <= 0) return "0s";
|
|
380
|
+
if (value < 1000) return `${Math.round(value)}ms`;
|
|
381
|
+
const seconds = Math.round(value / 1000);
|
|
382
|
+
if (seconds < 60) return `${seconds}s`;
|
|
383
|
+
const minutes = Math.floor(seconds / 60);
|
|
384
|
+
const remSeconds = seconds % 60;
|
|
385
|
+
if (minutes < 60) return remSeconds > 0 ? `${minutes}m ${remSeconds}s` : `${minutes}m`;
|
|
386
|
+
const hours = Math.floor(minutes / 60);
|
|
387
|
+
const remMinutes = minutes % 60;
|
|
388
|
+
return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
|
|
389
|
+
}
|
|
390
|
+
|
|
362
391
|
/**
|
|
363
392
|
* Get a single task by ID.
|
|
364
393
|
* @param {string} id - Task ID (UUID or partial prefix)
|
|
@@ -369,12 +398,12 @@ export async function taskGet(id) {
|
|
|
369
398
|
|
|
370
399
|
// Try exact match first
|
|
371
400
|
let task = store.getTask(id);
|
|
372
|
-
if (task) return task;
|
|
401
|
+
if (task) return withTaskLifetimeTotals(task);
|
|
373
402
|
|
|
374
403
|
// Try prefix match
|
|
375
404
|
const all = store.getAllTasks();
|
|
376
405
|
const matches = all.filter((t) => t.id?.startsWith(id));
|
|
377
|
-
if (matches.length === 1) return matches[0];
|
|
406
|
+
if (matches.length === 1) return withTaskLifetimeTotals(matches[0]);
|
|
378
407
|
if (matches.length > 1) {
|
|
379
408
|
throw new Error(
|
|
380
409
|
`Ambiguous task ID prefix "${id}" — matches ${matches.length} tasks. Use a longer prefix.`,
|
|
@@ -1234,6 +1263,12 @@ async function cliGet(args) {
|
|
|
1234
1263
|
console.log(` Branch: ${task.baseBranch || "main"}`);
|
|
1235
1264
|
console.log(` Created: ${task.createdAt || "?"}`);
|
|
1236
1265
|
console.log(` Updated: ${task.updatedAt || "?"}`);
|
|
1266
|
+
const lifetimeTotals = task.lifetimeTotals || task.meta?.lifetimeTotals || null;
|
|
1267
|
+
if (lifetimeTotals) {
|
|
1268
|
+
console.log(` Attempts count: ${lifetimeTotals.attemptsCount || 0}`);
|
|
1269
|
+
console.log(` Total tokens across all attempts: ${lifetimeTotals.tokenCount || 0}`);
|
|
1270
|
+
console.log(` Total runtime across all attempts: ${formatDurationMs(lifetimeTotals.durationMs || 0)}`);
|
|
1271
|
+
}
|
|
1237
1272
|
if (task.workspace) console.log(` Workspace: ${task.workspace}`);
|
|
1238
1273
|
if (task.repository) console.log(` Repository: ${task.repository}`);
|
|
1239
1274
|
if (task.description) {
|
|
@@ -1662,3 +1697,4 @@ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
|
|
|
1662
1697
|
process.exit(1);
|
|
1663
1698
|
});
|
|
1664
1699
|
}
|
|
1700
|
+
|