@agwab/pi-workflow 0.1.2 → 0.2.1
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/README.md +9 -13
- package/dist/compiler.d.ts +5 -5
- package/dist/compiler.js +82 -24
- package/dist/dynamic-generated-task-runtime.d.ts +2 -0
- package/dist/dynamic-generated-task-runtime.js +21 -8
- package/dist/engine.d.ts +6 -5
- package/dist/engine.js +39 -54
- package/dist/extension.js +211 -24
- package/dist/store.d.ts +3 -1
- package/dist/store.js +135 -38
- package/dist/subagent-backend.d.ts +4 -0
- package/dist/subagent-backend.js +128 -4
- package/dist/types.d.ts +5 -0
- package/dist/workflow-progress-health.d.ts +37 -0
- package/dist/workflow-progress-health.js +296 -0
- package/dist/workflow-runtime.d.ts +8 -0
- package/dist/workflow-runtime.js +63 -10
- package/dist/workflow-view.d.ts +2 -0
- package/dist/workflow-view.js +97 -18
- package/dist/workflow-web-source.js +32 -14
- package/docs/usage.md +12 -1
- package/package.json +6 -6
- package/src/compiler.ts +136 -41
- package/src/dynamic-generated-task-runtime.ts +47 -12
- package/src/engine.ts +55 -100
- package/src/extension.ts +270 -34
- package/src/store.ts +180 -44
- package/src/subagent-backend.ts +170 -6
- package/src/types.ts +10 -0
- package/src/workflow-progress-health.ts +461 -0
- package/src/workflow-runtime.ts +85 -13
- package/src/workflow-view.ts +186 -41
- package/src/workflow-web-source.ts +192 -69
- package/workflows/deep-research/helpers/claim-evidence-gate.mjs +111 -37
- package/workflows/deep-research/helpers/final-audit-packet.mjs +191 -14
- package/workflows/deep-research/helpers/normalize-input-packet.mjs +159 -50
- package/workflows/deep-research/helpers/render-executive.mjs +671 -37
- package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +624 -0
- package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +2 -0
- package/workflows/deep-research/schemas/deep-research-final-synthesis-control.schema.json +110 -0
- package/workflows/deep-research/spec.json +41 -11
package/dist/subagent-backend.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { copyFile, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
4
|
+
import { availableParallelism } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { fromProjectPath, isTerminalTaskStatus, nowIso, toProjectPath, writeRunRecord, } from "./store.js";
|
|
6
7
|
import { applyTaskResultArtifact, isTaskTimedOut, markTaskTimedOut, } from "./result.js";
|
|
@@ -15,6 +16,10 @@ const FETCH_CONTENT_CACHE_ENV = "PI_WORKFLOW_FETCH_CONTENT_CACHE";
|
|
|
15
16
|
const LEGACY_FETCH_CACHE_ENV = "PI_WORKFLOW_FETCH_CACHE";
|
|
16
17
|
const DEFAULT_TRANSIENT_MODEL_FAILURE_RETRIES = 5;
|
|
17
18
|
const DEFAULT_ARTIFACT_OUTPUT_RETRIES = 2;
|
|
19
|
+
const MAX_CONCURRENT_LAUNCHES_ENV = "PI_WORKFLOW_MAX_CONCURRENT_LAUNCHES";
|
|
20
|
+
const DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS = 3_000;
|
|
21
|
+
const MIN_TRANSIENT_RETRY_JITTER_MS = 1_000;
|
|
22
|
+
const MAX_TRANSIENT_RETRY_JITTER_MS = 5_000;
|
|
18
23
|
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
19
24
|
const MODULE_DIR = dirname(MODULE_PATH);
|
|
20
25
|
const BUNDLED_PI_WEB_ACCESS_EXTENSION = bundledNodeModulePath("pi-web-access", "index.ts");
|
|
@@ -47,6 +52,81 @@ async function loadSubagentApi() {
|
|
|
47
52
|
cachedSubagentApi ??= import(subagentApiSpecifier).then((mod) => mod);
|
|
48
53
|
return cachedSubagentApi;
|
|
49
54
|
}
|
|
55
|
+
let launchSlotReleaseDelayMs = DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS;
|
|
56
|
+
let transientRetryJitterForTests;
|
|
57
|
+
const launchWaitQueue = [];
|
|
58
|
+
let activeLaunchSlots = 0;
|
|
59
|
+
function resolveMaxConcurrentLaunches() {
|
|
60
|
+
const override = Number.parseInt(process.env[MAX_CONCURRENT_LAUNCHES_ENV] ?? "", 10);
|
|
61
|
+
if (Number.isFinite(override))
|
|
62
|
+
return Math.max(1, Math.floor(override));
|
|
63
|
+
return Math.max(2, Math.floor(availableParallelism() / 2));
|
|
64
|
+
}
|
|
65
|
+
function isLaunchGateSaturated() {
|
|
66
|
+
return activeLaunchSlots >= resolveMaxConcurrentLaunches();
|
|
67
|
+
}
|
|
68
|
+
async function acquireLaunchSlot() {
|
|
69
|
+
if (!isLaunchGateSaturated()) {
|
|
70
|
+
activeLaunchSlots += 1;
|
|
71
|
+
return releaseLaunchSlot;
|
|
72
|
+
}
|
|
73
|
+
await new Promise((resolveWait) => launchWaitQueue.push(resolveWait));
|
|
74
|
+
return releaseLaunchSlot;
|
|
75
|
+
}
|
|
76
|
+
function releaseLaunchSlot() {
|
|
77
|
+
const next = launchWaitQueue.shift();
|
|
78
|
+
if (next) {
|
|
79
|
+
// Transfer the occupied slot directly to the queued launcher.
|
|
80
|
+
next();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
activeLaunchSlots = Math.max(0, activeLaunchSlots - 1);
|
|
84
|
+
}
|
|
85
|
+
function releaseLaunchSlotAfterDelay(delayMs, release) {
|
|
86
|
+
if (delayMs <= 0) {
|
|
87
|
+
release();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const timer = setTimeout(release, delayMs);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
async function runWithLaunchSlot(action) {
|
|
94
|
+
const release = await acquireLaunchSlot();
|
|
95
|
+
let holdAfterReturn = false;
|
|
96
|
+
try {
|
|
97
|
+
const result = await action();
|
|
98
|
+
holdAfterReturn = true;
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
releaseLaunchSlotAfterDelay(holdAfterReturn ? launchSlotReleaseDelayMs : 0, release);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function transientRetryJitterMs() {
|
|
106
|
+
if (transientRetryJitterForTests)
|
|
107
|
+
return transientRetryJitterForTests();
|
|
108
|
+
return (MIN_TRANSIENT_RETRY_JITTER_MS +
|
|
109
|
+
Math.floor(Math.random() *
|
|
110
|
+
(MAX_TRANSIENT_RETRY_JITTER_MS - MIN_TRANSIENT_RETRY_JITTER_MS + 1)));
|
|
111
|
+
}
|
|
112
|
+
function sleep(ms) {
|
|
113
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
114
|
+
}
|
|
115
|
+
export function setSubagentLaunchControlsForTests(options) {
|
|
116
|
+
launchSlotReleaseDelayMs =
|
|
117
|
+
options?.releaseDelayMs === undefined
|
|
118
|
+
? DEFAULT_LAUNCH_SLOT_RELEASE_DELAY_MS
|
|
119
|
+
: Math.max(0, Math.floor(options.releaseDelayMs));
|
|
120
|
+
transientRetryJitterForTests =
|
|
121
|
+
options?.retryJitterMs === undefined
|
|
122
|
+
? undefined
|
|
123
|
+
: typeof options.retryJitterMs === "function"
|
|
124
|
+
? options.retryJitterMs
|
|
125
|
+
: () => Math.max(0, Math.floor(options.retryJitterMs));
|
|
126
|
+
activeLaunchSlots = 0;
|
|
127
|
+
while (launchWaitQueue.length > 0)
|
|
128
|
+
launchWaitQueue.shift()?.();
|
|
129
|
+
}
|
|
50
130
|
export async function cleanupSubagentRun(_cwd, run) {
|
|
51
131
|
for (const task of run.tasks) {
|
|
52
132
|
if (isTerminalTaskStatus(task.status))
|
|
@@ -77,6 +157,14 @@ export async function launchSubagentTask(cwd, run, task, compiledTask) {
|
|
|
77
157
|
message: "fast:on is not supported for pi-workflow execution.",
|
|
78
158
|
};
|
|
79
159
|
}
|
|
160
|
+
if ((task.launchRetry?.attempts ?? 0) > 0) {
|
|
161
|
+
const jitterMs = transientRetryJitterMs();
|
|
162
|
+
task.statusDetail = "retry_model_failure";
|
|
163
|
+
task.lastMessage = `waiting ${jitterMs}ms before retrying transient-model launch`;
|
|
164
|
+
await writeRunRecord(cwd, run);
|
|
165
|
+
if (jitterMs > 0)
|
|
166
|
+
await sleep(jitterMs);
|
|
167
|
+
}
|
|
80
168
|
const systemPromptFile = fromProjectPath(cwd, task.files.systemPrompt);
|
|
81
169
|
const taskPromptFile = fromProjectPath(cwd, task.files.taskPrompt);
|
|
82
170
|
const outputFile = fromProjectPath(cwd, task.files.output);
|
|
@@ -126,7 +214,11 @@ export async function launchSubagentTask(cwd, run, task, compiledTask) {
|
|
|
126
214
|
subagentOptions.extensions = extensions;
|
|
127
215
|
if (captureToolCallsEnabled())
|
|
128
216
|
subagentOptions.captureToolCalls = true;
|
|
129
|
-
|
|
217
|
+
if (isLaunchGateSaturated()) {
|
|
218
|
+
task.lastMessage = `waiting for pi-subagent launch slot (${resolveMaxConcurrentLaunches()} max)`;
|
|
219
|
+
await writeRunRecord(cwd, run).catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
launched = await runWithLaunchSlot(() => api.runSubagent(subagentOptions));
|
|
130
222
|
}
|
|
131
223
|
catch (error) {
|
|
132
224
|
task.status = "pending";
|
|
@@ -259,8 +351,23 @@ async function materializeTerminalSubagentResult(cwd, run, task, snapshot) {
|
|
|
259
351
|
: undefined;
|
|
260
352
|
const toolCalls = await readToolCallsSummary(snapshot, subagentResult, artifactRoot);
|
|
261
353
|
const outputText = await readFile(outputFile, "utf8").catch(() => "");
|
|
354
|
+
const stderrText = await readFile(stderrFile, "utf8").catch(() => "");
|
|
262
355
|
const outputBytes = Buffer.byteLength(outputText, "utf8");
|
|
263
|
-
|
|
356
|
+
let statusInfo = workflowStatusFromSubagent(snapshot, subagentResult, outputBytes);
|
|
357
|
+
const deterministicBootFailure = classifyDeterministicBootFailure({
|
|
358
|
+
statusInfo,
|
|
359
|
+
stderrText,
|
|
360
|
+
outputBytes,
|
|
361
|
+
contextLengthExceeded: Boolean(subagentResult?.metadata?.contextLengthExceeded ??
|
|
362
|
+
snapshot.metadata?.contextLengthExceeded),
|
|
363
|
+
});
|
|
364
|
+
if (deterministicBootFailure) {
|
|
365
|
+
statusInfo = {
|
|
366
|
+
status: "failed",
|
|
367
|
+
failureKind: "deterministic_boot",
|
|
368
|
+
errorMessage: deterministicBootFailure,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
264
371
|
const completedAt = typeof subagentResult?.completedAt === "string"
|
|
265
372
|
? subagentResult.completedAt
|
|
266
373
|
: (snapshot.completedAt ?? nowIso());
|
|
@@ -685,6 +792,23 @@ function failArtifactGraphTask(task, options) {
|
|
|
685
792
|
task.lastMessage = options.message;
|
|
686
793
|
return true;
|
|
687
794
|
}
|
|
795
|
+
function classifyDeterministicBootFailure(options) {
|
|
796
|
+
if (options.statusInfo.status !== "failed" ||
|
|
797
|
+
options.statusInfo.failureKind !== "model" ||
|
|
798
|
+
options.outputBytes !== 0 ||
|
|
799
|
+
options.contextLengthExceeded) {
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
const text = options.stderrText;
|
|
803
|
+
const deterministicPattern = /(Failed to load extension|Cannot find module|(?:failed to load|invalid|missing) (?:workflow )?config(?:uration)?|config(?:uration)? (?:error|failed|invalid))/i;
|
|
804
|
+
if (!deterministicPattern.test(text))
|
|
805
|
+
return undefined;
|
|
806
|
+
const excerpt = text
|
|
807
|
+
.split(/\r?\n/)
|
|
808
|
+
.map((line) => line.trim())
|
|
809
|
+
.find((line) => deterministicPattern.test(line)) ?? text.trim();
|
|
810
|
+
return `deterministic-boot failure: ${excerpt.slice(0, 500)}`;
|
|
811
|
+
}
|
|
688
812
|
function shouldRetryTransientModelFailure(statusInfo, workflowResult, outputBytes) {
|
|
689
813
|
return (statusInfo.status === "failed" &&
|
|
690
814
|
statusInfo.failureKind === "model" &&
|
|
@@ -714,14 +838,14 @@ function retryOrFailTransientSubagentFailure(task, options) {
|
|
|
714
838
|
if (!exhausted) {
|
|
715
839
|
task.status = "pending";
|
|
716
840
|
task.statusDetail = "retry_model_failure";
|
|
717
|
-
task.lastMessage = `${options.message}; retrying transient
|
|
841
|
+
task.lastMessage = `${options.message}; retrying transient-model failure (${attempt}/${maxAttempts})`;
|
|
718
842
|
return true;
|
|
719
843
|
}
|
|
720
844
|
task.status = "failed";
|
|
721
845
|
task.statusDetail = task.launchRetry.reason ?? "model_exhausted";
|
|
722
846
|
task.exitCode = 1;
|
|
723
847
|
task.completedAt = nowIso();
|
|
724
|
-
task.lastMessage = `${options.message}; transient
|
|
848
|
+
task.lastMessage = `${options.message}; transient-model failure retries exhausted (${maxAttempts})`;
|
|
725
849
|
return true;
|
|
726
850
|
}
|
|
727
851
|
function retryOrFailArtifactGraphTask(task, options) {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WorkflowModelInfo, WorkflowRuntimeDefaults, WorkflowRuntimeThinkingResolution } from "./workflow-runtime.js";
|
|
1
2
|
export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
2
3
|
export declare const FAST_MODES: readonly ["inherit", "off"];
|
|
3
4
|
export declare const APPROVAL_MODES: readonly ["non-interactive", "on-request"];
|
|
@@ -249,6 +250,7 @@ export interface PermissionPreview {
|
|
|
249
250
|
export interface CompiledTaskRuntime {
|
|
250
251
|
model?: string;
|
|
251
252
|
thinking?: ThinkingLevel;
|
|
253
|
+
thinkingResolution?: WorkflowRuntimeThinkingResolution;
|
|
252
254
|
fast?: FastMode;
|
|
253
255
|
approvalMode: ApprovalMode;
|
|
254
256
|
tools?: string[];
|
|
@@ -418,6 +420,8 @@ export interface CompiledDynamicWorkflowTask {
|
|
|
418
420
|
helpers: Record<string, CompiledDynamicWorkflowHelper>;
|
|
419
421
|
workflows: Record<string, CompiledDynamicNestedWorkflow>;
|
|
420
422
|
decisionLoop?: CompiledDynamicDecisionLoop;
|
|
423
|
+
runtimeOverrides?: WorkflowRuntimeDefaults;
|
|
424
|
+
availableModels?: WorkflowModelInfo[];
|
|
421
425
|
}
|
|
422
426
|
export interface CompiledArtifactGraphTask {
|
|
423
427
|
enabled: true;
|
|
@@ -505,6 +509,7 @@ export interface WorkflowTaskRunRecord {
|
|
|
505
509
|
runtime: {
|
|
506
510
|
model?: string;
|
|
507
511
|
thinking?: ThinkingLevel;
|
|
512
|
+
thinkingResolution?: WorkflowRuntimeThinkingResolution;
|
|
508
513
|
fast?: FastMode;
|
|
509
514
|
approvalMode: ApprovalMode;
|
|
510
515
|
maxRuntimeMs?: number;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TaskRunStatus, WorkflowRunRecord, WorkflowTaskRunRecord } from "./types.js";
|
|
2
|
+
export type WorkflowHealthState = "completed" | "pending" | "active" | "long-tail" | "stalled" | "likely-stuck" | "needs-action";
|
|
3
|
+
export type WorkflowHealthTone = "success" | "accent" | "warning" | "error" | "dim";
|
|
4
|
+
export type WorkflowDurationClass = "short" | "medium" | "long";
|
|
5
|
+
export type WorkflowHealthSuggestion = "wait" | "inspect" | "resume" | "review";
|
|
6
|
+
export interface WorkflowHealthTaskSummary {
|
|
7
|
+
taskId?: string;
|
|
8
|
+
displayName?: string;
|
|
9
|
+
stageId?: string;
|
|
10
|
+
status?: TaskRunStatus;
|
|
11
|
+
elapsedMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface WorkflowProgressHealth {
|
|
14
|
+
state: WorkflowHealthState;
|
|
15
|
+
label: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
tone: WorkflowHealthTone;
|
|
18
|
+
suggestion: WorkflowHealthSuggestion;
|
|
19
|
+
reason: string;
|
|
20
|
+
durationClass?: WorkflowDurationClass;
|
|
21
|
+
currentTask?: WorkflowHealthTaskSummary;
|
|
22
|
+
lastActivityAt?: string;
|
|
23
|
+
lastActivityAgeMs?: number;
|
|
24
|
+
heartbeatAt?: string;
|
|
25
|
+
heartbeatAgeMs?: number;
|
|
26
|
+
}
|
|
27
|
+
type TaskHealthInput = Pick<WorkflowTaskRunRecord, "taskId" | "specId" | "displayName" | "status" | "statusDetail" | "stageId" | "kind" | "startedAt" | "lastMessage" | "runtime" | "backendHandle" | "pid">;
|
|
28
|
+
type RunHealthInput = Pick<WorkflowRunRecord, "status" | "taskSummary" | "createdAt" | "updatedAt"> & {
|
|
29
|
+
tasks?: TaskHealthInput[];
|
|
30
|
+
};
|
|
31
|
+
export interface WorkflowHealthOptions {
|
|
32
|
+
nowMs?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function diagnoseWorkflowRunHealth(run: RunHealthInput, options?: WorkflowHealthOptions): WorkflowProgressHealth;
|
|
35
|
+
export declare function diagnoseWorkflowTaskHealth(task: TaskHealthInput, run?: Pick<WorkflowRunRecord, "updatedAt">, options?: WorkflowHealthOptions): WorkflowProgressHealth;
|
|
36
|
+
export declare function classifyWorkflowTaskDuration(task: Pick<WorkflowTaskRunRecord, "stageId" | "displayName" | "specId" | "kind" | "statusDetail" | "runtime">): WorkflowDurationClass;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const ACTIVE_ACTIVITY_MS = 2 * 60_000;
|
|
2
|
+
const LONG_TAIL_ELAPSED_MS = 8 * 60_000;
|
|
3
|
+
const STALL_BY_DURATION = {
|
|
4
|
+
short: 5 * 60_000,
|
|
5
|
+
medium: 10 * 60_000,
|
|
6
|
+
long: 20 * 60_000,
|
|
7
|
+
};
|
|
8
|
+
const STUCK_BY_DURATION = {
|
|
9
|
+
short: 15 * 60_000,
|
|
10
|
+
medium: 30 * 60_000,
|
|
11
|
+
long: 60 * 60_000,
|
|
12
|
+
};
|
|
13
|
+
export function diagnoseWorkflowRunHealth(run, options = {}) {
|
|
14
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
15
|
+
const runningTask = currentRunningTask(run.tasks ?? []);
|
|
16
|
+
if (runningTask)
|
|
17
|
+
return diagnoseWorkflowTaskHealth(runningTask, run, { nowMs });
|
|
18
|
+
const problem = (run.tasks ?? []).find((task) => isProblemStatus(task.status));
|
|
19
|
+
if (problem)
|
|
20
|
+
return problemRunHealth(problem, nowMs);
|
|
21
|
+
if (isProblemStatus(run.status))
|
|
22
|
+
return problemWorkflowHealth(run.status);
|
|
23
|
+
if (run.status === "completed")
|
|
24
|
+
return completedWorkflowHealth();
|
|
25
|
+
return waitingWorkflowHealth(run, nowMs);
|
|
26
|
+
}
|
|
27
|
+
export function diagnoseWorkflowTaskHealth(task, run, options = {}) {
|
|
28
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
29
|
+
if (task.status !== "running")
|
|
30
|
+
return terminalTaskHealth(task, nowMs);
|
|
31
|
+
return runningTaskHealth(runningContext(task, run, nowMs));
|
|
32
|
+
}
|
|
33
|
+
export function classifyWorkflowTaskDuration(task) {
|
|
34
|
+
const text = [
|
|
35
|
+
task.stageId,
|
|
36
|
+
task.displayName,
|
|
37
|
+
task.specId,
|
|
38
|
+
task.kind,
|
|
39
|
+
task.statusDetail,
|
|
40
|
+
]
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.join(" ")
|
|
43
|
+
.toLowerCase();
|
|
44
|
+
if (/\b(render|helper|support|schema|partition|gate)\b/.test(text))
|
|
45
|
+
return "short";
|
|
46
|
+
if (/\b(research|audit|synthesis|review|verify|verifier|normalize|plan|impact|spec)\b/.test(text))
|
|
47
|
+
return "long";
|
|
48
|
+
const maxRuntimeMs = task.runtime?.maxRuntimeMs;
|
|
49
|
+
if (maxRuntimeMs !== undefined && Number.isFinite(maxRuntimeMs)) {
|
|
50
|
+
if (maxRuntimeMs <= 5 * 60_000)
|
|
51
|
+
return "short";
|
|
52
|
+
if (maxRuntimeMs >= 60 * 60_000)
|
|
53
|
+
return "long";
|
|
54
|
+
}
|
|
55
|
+
return "medium";
|
|
56
|
+
}
|
|
57
|
+
function currentRunningTask(tasks) {
|
|
58
|
+
return tasks
|
|
59
|
+
.filter((task) => task.status === "running")
|
|
60
|
+
.sort((left, right) => (parseTime(left.startedAt) ?? Number.POSITIVE_INFINITY) -
|
|
61
|
+
(parseTime(right.startedAt) ?? Number.POSITIVE_INFINITY))[0];
|
|
62
|
+
}
|
|
63
|
+
function completedWorkflowHealth() {
|
|
64
|
+
return {
|
|
65
|
+
state: "completed",
|
|
66
|
+
label: "completed",
|
|
67
|
+
summary: "run completed",
|
|
68
|
+
tone: "success",
|
|
69
|
+
suggestion: "review",
|
|
70
|
+
reason: "all tasks reached a terminal successful state",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function problemWorkflowHealth(status) {
|
|
74
|
+
return {
|
|
75
|
+
state: "needs-action",
|
|
76
|
+
label: "needs action",
|
|
77
|
+
summary: `run ${status}`,
|
|
78
|
+
tone: "error",
|
|
79
|
+
suggestion: "inspect",
|
|
80
|
+
reason: `workflow status is ${status}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function problemRunHealth(task, nowMs) {
|
|
84
|
+
return {
|
|
85
|
+
state: "needs-action",
|
|
86
|
+
label: "needs action",
|
|
87
|
+
summary: `${task.displayName ?? task.taskId ?? "task"} needs attention`,
|
|
88
|
+
tone: "error",
|
|
89
|
+
suggestion: "inspect",
|
|
90
|
+
reason: task.lastMessage ?? task.statusDetail ?? "task did not complete",
|
|
91
|
+
currentTask: taskSummary(task, nowMs),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function waitingWorkflowHealth(run, nowMs) {
|
|
95
|
+
const hasPending = run.taskSummary.pending > 0;
|
|
96
|
+
return {
|
|
97
|
+
state: hasPending ? "pending" : "active",
|
|
98
|
+
label: hasPending ? "pending" : "active",
|
|
99
|
+
summary: hasPending
|
|
100
|
+
? "waiting for the next schedulable task"
|
|
101
|
+
: "run is active",
|
|
102
|
+
tone: hasPending ? "dim" : "accent",
|
|
103
|
+
suggestion: "wait",
|
|
104
|
+
reason: hasPending
|
|
105
|
+
? "no task is currently running"
|
|
106
|
+
: "workflow is still in progress",
|
|
107
|
+
lastActivityAt: run.updatedAt,
|
|
108
|
+
lastActivityAgeMs: ageMs(run.updatedAt, nowMs),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function runningTaskHealth(context) {
|
|
112
|
+
return (runtimeExceededHealth(context) ??
|
|
113
|
+
staleRunningHealth(context) ??
|
|
114
|
+
longTailHealth(context) ??
|
|
115
|
+
activeRunningHealth(context));
|
|
116
|
+
}
|
|
117
|
+
function runtimeExceededHealth(context) {
|
|
118
|
+
const maxRuntimeMs = context.task.runtime?.maxRuntimeMs;
|
|
119
|
+
const hasElapsed = context.elapsedMs !== undefined;
|
|
120
|
+
const hasBudget = maxRuntimeMs !== undefined && Number.isFinite(maxRuntimeMs);
|
|
121
|
+
if (!hasElapsed || !hasBudget)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (maxRuntimeMs <= 0 || context.elapsedMs <= maxRuntimeMs)
|
|
124
|
+
return undefined;
|
|
125
|
+
return runningHealth(context, {
|
|
126
|
+
state: "likely-stuck",
|
|
127
|
+
label: "runtime exceeded",
|
|
128
|
+
summary: "task exceeded its runtime budget",
|
|
129
|
+
tone: "error",
|
|
130
|
+
suggestion: "resume",
|
|
131
|
+
reason: "elapsed time is past runtime.maxRuntimeMs",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function staleRunningHealth(context) {
|
|
135
|
+
if (context.staleMs >= STUCK_BY_DURATION[context.durationClass] &&
|
|
136
|
+
!context.hasBackendSignal) {
|
|
137
|
+
return runningHealth(context, {
|
|
138
|
+
state: "likely-stuck",
|
|
139
|
+
label: "likely stuck",
|
|
140
|
+
summary: "no fresh backend or activity signal",
|
|
141
|
+
tone: "error",
|
|
142
|
+
suggestion: "resume",
|
|
143
|
+
reason: "running task has no backend signal and activity is stale",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (context.staleMs < STALL_BY_DURATION[context.durationClass])
|
|
147
|
+
return undefined;
|
|
148
|
+
return runningHealth(context, {
|
|
149
|
+
state: "stalled",
|
|
150
|
+
label: "possibly stalled",
|
|
151
|
+
summary: "no recent visible progress",
|
|
152
|
+
tone: "warning",
|
|
153
|
+
suggestion: "inspect",
|
|
154
|
+
reason: context.hasBackendSignal
|
|
155
|
+
? "backend signal exists, but activity is stale"
|
|
156
|
+
: "activity is stale",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function longTailHealth(context) {
|
|
160
|
+
const isLongTail = context.durationClass === "long" &&
|
|
161
|
+
context.elapsedMs !== undefined &&
|
|
162
|
+
context.elapsedMs >= LONG_TAIL_ELAPSED_MS;
|
|
163
|
+
if (!isLongTail)
|
|
164
|
+
return undefined;
|
|
165
|
+
return runningHealth(context, {
|
|
166
|
+
state: "long-tail",
|
|
167
|
+
label: "long-tail active",
|
|
168
|
+
summary: "slow task with fresh liveness signals",
|
|
169
|
+
tone: "accent",
|
|
170
|
+
suggestion: "wait",
|
|
171
|
+
reason: context.staleMs <= ACTIVE_ACTIVITY_MS
|
|
172
|
+
? "liveness signal is fresh"
|
|
173
|
+
: "long-running stage is still within the stale threshold",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function activeRunningHealth(context) {
|
|
177
|
+
return runningHealth(context, {
|
|
178
|
+
state: "active",
|
|
179
|
+
label: "active",
|
|
180
|
+
summary: "task is running",
|
|
181
|
+
tone: "accent",
|
|
182
|
+
suggestion: "wait",
|
|
183
|
+
reason: context.staleMs <= ACTIVE_ACTIVITY_MS
|
|
184
|
+
? "liveness signal is fresh"
|
|
185
|
+
: "activity remains within the expected window",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function runningContext(task, run, nowMs) {
|
|
189
|
+
const durationClass = classifyWorkflowTaskDuration(task);
|
|
190
|
+
const startedAtMs = parseTime(task.startedAt);
|
|
191
|
+
const heartbeatAt = parseHeartbeatAt(task.lastMessage);
|
|
192
|
+
const activityAt = latestIso([heartbeatAt, run?.updatedAt, task.startedAt]);
|
|
193
|
+
const lastActivityAgeMs = ageMs(activityAt, nowMs);
|
|
194
|
+
return {
|
|
195
|
+
task,
|
|
196
|
+
nowMs,
|
|
197
|
+
durationClass,
|
|
198
|
+
elapsedMs: startedAtMs === undefined ? undefined : Math.max(0, nowMs - startedAtMs),
|
|
199
|
+
activityAt,
|
|
200
|
+
lastActivityAgeMs,
|
|
201
|
+
heartbeatAt,
|
|
202
|
+
heartbeatAgeMs: ageMs(heartbeatAt, nowMs),
|
|
203
|
+
hasBackendSignal: Boolean(task.backendHandle || task.pid || heartbeatAt),
|
|
204
|
+
staleMs: lastActivityAgeMs ?? Number.POSITIVE_INFINITY,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function terminalTaskHealth(task, nowMs) {
|
|
208
|
+
if (task.status === "completed" || task.status === "skipped") {
|
|
209
|
+
return {
|
|
210
|
+
state: "completed",
|
|
211
|
+
label: task.status === "skipped" ? "skipped" : "completed",
|
|
212
|
+
summary: task.status === "skipped" ? "task skipped" : "task completed",
|
|
213
|
+
tone: "success",
|
|
214
|
+
suggestion: "review",
|
|
215
|
+
reason: task.statusDetail,
|
|
216
|
+
currentTask: taskSummary(task, nowMs),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (task.status === "pending") {
|
|
220
|
+
return {
|
|
221
|
+
state: "pending",
|
|
222
|
+
label: "pending",
|
|
223
|
+
summary: "waiting for dependencies or scheduler",
|
|
224
|
+
tone: "dim",
|
|
225
|
+
suggestion: "wait",
|
|
226
|
+
reason: task.statusDetail,
|
|
227
|
+
currentTask: taskSummary(task, nowMs),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
state: "needs-action",
|
|
232
|
+
label: "needs action",
|
|
233
|
+
summary: `${task.status} task needs attention`,
|
|
234
|
+
tone: "error",
|
|
235
|
+
suggestion: "inspect",
|
|
236
|
+
reason: task.lastMessage ?? task.statusDetail,
|
|
237
|
+
currentTask: taskSummary(task, nowMs),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function runningHealth(context, health) {
|
|
241
|
+
return {
|
|
242
|
+
...health,
|
|
243
|
+
durationClass: context.durationClass,
|
|
244
|
+
currentTask: taskSummary(context.task, context.nowMs),
|
|
245
|
+
lastActivityAt: context.activityAt,
|
|
246
|
+
lastActivityAgeMs: context.lastActivityAgeMs,
|
|
247
|
+
heartbeatAt: context.heartbeatAt,
|
|
248
|
+
heartbeatAgeMs: context.heartbeatAgeMs,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function taskSummary(task, nowMs) {
|
|
252
|
+
const startedAtMs = parseTime(task.startedAt);
|
|
253
|
+
return {
|
|
254
|
+
taskId: task.taskId,
|
|
255
|
+
displayName: task.displayName,
|
|
256
|
+
stageId: task.stageId,
|
|
257
|
+
status: task.status,
|
|
258
|
+
...(startedAtMs === undefined
|
|
259
|
+
? {}
|
|
260
|
+
: { elapsedMs: Math.max(0, nowMs - startedAtMs) }),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function isProblemStatus(status) {
|
|
264
|
+
return (status === "failed" || status === "blocked" || status === "interrupted");
|
|
265
|
+
}
|
|
266
|
+
function parseHeartbeatAt(message) {
|
|
267
|
+
if (!message)
|
|
268
|
+
return undefined;
|
|
269
|
+
const match = /heartbeat\s+(\d{4}-\d{2}-\d{2}T\S+?Z)/i.exec(message);
|
|
270
|
+
if (!match)
|
|
271
|
+
return undefined;
|
|
272
|
+
const value = match[1];
|
|
273
|
+
return parseTime(value) === undefined ? undefined : value;
|
|
274
|
+
}
|
|
275
|
+
function latestIso(values) {
|
|
276
|
+
let latest;
|
|
277
|
+
let latestMs = Number.NEGATIVE_INFINITY;
|
|
278
|
+
for (const value of values) {
|
|
279
|
+
const time = parseTime(value);
|
|
280
|
+
if (time === undefined || time <= latestMs)
|
|
281
|
+
continue;
|
|
282
|
+
latest = value;
|
|
283
|
+
latestMs = time;
|
|
284
|
+
}
|
|
285
|
+
return latest;
|
|
286
|
+
}
|
|
287
|
+
function ageMs(value, nowMs) {
|
|
288
|
+
const time = parseTime(value);
|
|
289
|
+
return time === undefined ? undefined : Math.max(0, nowMs - time);
|
|
290
|
+
}
|
|
291
|
+
function parseTime(value) {
|
|
292
|
+
if (!value)
|
|
293
|
+
return undefined;
|
|
294
|
+
const parsed = Date.parse(value);
|
|
295
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
296
|
+
}
|
|
@@ -11,9 +11,15 @@ export interface WorkflowRuntimeDefaults {
|
|
|
11
11
|
model?: string;
|
|
12
12
|
thinking?: ThinkingLevel;
|
|
13
13
|
}
|
|
14
|
+
export interface WorkflowRuntimeThinkingResolution {
|
|
15
|
+
requested?: ThinkingLevel;
|
|
16
|
+
resolved?: ThinkingLevel;
|
|
17
|
+
reason?: string;
|
|
18
|
+
}
|
|
14
19
|
export interface WorkflowRuntimeResolutionInput {
|
|
15
20
|
model?: string;
|
|
16
21
|
thinking?: ThinkingLevel;
|
|
22
|
+
thinkingResolution?: WorkflowRuntimeThinkingResolution;
|
|
17
23
|
}
|
|
18
24
|
export interface WorkflowRuntimeResolutionContext {
|
|
19
25
|
taskKey: string;
|
|
@@ -29,6 +35,8 @@ export interface ResolveWorkflowRuntimeOptions {
|
|
|
29
35
|
availableModels?: WorkflowModelInfo[];
|
|
30
36
|
prompt?: WorkflowRuntimePrompt;
|
|
31
37
|
}
|
|
38
|
+
export type WorkflowRuntimeLayer = WorkflowRuntimeDefaults | undefined;
|
|
39
|
+
export declare function selectWorkflowRuntime(...layers: WorkflowRuntimeLayer[]): WorkflowRuntimeResolutionInput;
|
|
32
40
|
export declare function toWorkflowModelInfo(model: {
|
|
33
41
|
provider: string;
|
|
34
42
|
id: string;
|
package/dist/workflow-runtime.js
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
import { THINKING_LEVELS } from "./types.js";
|
|
2
|
+
export function selectWorkflowRuntime(...layers) {
|
|
3
|
+
const modelLayer = layers.find((layer) => modelOf(layer));
|
|
4
|
+
const model = modelOf(modelLayer);
|
|
5
|
+
let thinking;
|
|
6
|
+
for (const layer of layers) {
|
|
7
|
+
if (!layer)
|
|
8
|
+
continue;
|
|
9
|
+
if (layer.thinking) {
|
|
10
|
+
thinking = layer.thinking;
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
const layerModel = modelOf(layer);
|
|
14
|
+
const modelThinking = layerModel
|
|
15
|
+
? splitKnownThinkingSuffix(layerModel).thinking
|
|
16
|
+
: undefined;
|
|
17
|
+
if (modelThinking) {
|
|
18
|
+
thinking = modelThinking;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
...(model ? { model } : {}),
|
|
24
|
+
...(thinking ? { thinking } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function modelOf(layer) {
|
|
28
|
+
return typeof layer?.model === "string" && layer.model.trim()
|
|
29
|
+
? layer.model.trim()
|
|
30
|
+
: undefined;
|
|
31
|
+
}
|
|
2
32
|
export function toWorkflowModelInfo(model) {
|
|
3
33
|
return {
|
|
4
34
|
provider: model.provider,
|
|
@@ -15,10 +45,13 @@ export async function resolveWorkflowRuntime(runtime, context, options) {
|
|
|
15
45
|
: { baseModel: undefined, thinking: undefined };
|
|
16
46
|
const model = await resolveModel(baseModel, context, options);
|
|
17
47
|
const effectiveThinking = runtime.thinking ?? thinking ?? options.defaults?.thinking;
|
|
18
|
-
const
|
|
48
|
+
const thinkingResolution = await resolveThinking(model, effectiveThinking, context, options);
|
|
19
49
|
return {
|
|
20
50
|
...(model ? { model } : {}),
|
|
21
|
-
...(
|
|
51
|
+
...(thinkingResolution?.resolved
|
|
52
|
+
? { thinking: thinkingResolution.resolved }
|
|
53
|
+
: {}),
|
|
54
|
+
...(thinkingResolution ? { thinkingResolution } : {}),
|
|
22
55
|
};
|
|
23
56
|
}
|
|
24
57
|
export function splitKnownThinkingSuffix(model) {
|
|
@@ -101,21 +134,41 @@ async function resolveThinking(modelId, requested, context, options) {
|
|
|
101
134
|
return undefined;
|
|
102
135
|
const model = findModelInfo(modelId, options.availableModels ?? []);
|
|
103
136
|
const supported = getSupportedThinkingLevels(model);
|
|
104
|
-
if (supported.includes(requested))
|
|
105
|
-
return requested;
|
|
106
|
-
if (!options.prompt) {
|
|
107
|
-
const modelLabel = modelId ?? "selected model";
|
|
108
|
-
throw new Error(`${modelLabel} does not support reasoning level "${requested}" for ${context.taskKey}. Supported: ${supported.join(", ") || "none"}`);
|
|
137
|
+
if (supported.includes(requested)) {
|
|
138
|
+
return { requested, resolved: requested };
|
|
109
139
|
}
|
|
110
140
|
if (supported.length === 0) {
|
|
111
141
|
throw new Error(`${modelId ?? "selected model"} does not expose any supported reasoning levels for ${context.taskKey}`);
|
|
112
142
|
}
|
|
113
|
-
const
|
|
143
|
+
const downgradeOptions = lowerOrEqualSupportedThinking(requested, supported);
|
|
144
|
+
if (downgradeOptions.length === 0) {
|
|
145
|
+
const modelLabel = modelId ?? "selected model";
|
|
146
|
+
throw new Error(`${modelLabel} does not support reasoning level "${requested}" for ${context.taskKey}, and no lower-or-equal fallback is available. Supported: ${supported.join(", ") || "none"}`);
|
|
147
|
+
}
|
|
148
|
+
if (!options.prompt) {
|
|
149
|
+
const resolved = downgradeOptions[downgradeOptions.length - 1];
|
|
150
|
+
return {
|
|
151
|
+
requested,
|
|
152
|
+
resolved,
|
|
153
|
+
reason: `requested ${requested} is unsupported by ${modelId ?? "selected model"}; using ${resolved}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const selected = await options.prompt.select(`${modelId ?? "Selected model"} does not support reasoning "${requested}" for ${context.taskKey}. Choose a supported lower-or-equal level.`, downgradeOptions);
|
|
114
157
|
if (!selected)
|
|
115
158
|
throw new Error(`Reasoning selection cancelled for ${context.taskKey}`);
|
|
116
|
-
if (!isThinkingLevel(selected))
|
|
159
|
+
if (!isThinkingLevel(selected) || !downgradeOptions.includes(selected))
|
|
117
160
|
throw new Error(`Invalid reasoning selection "${selected}" for ${context.taskKey}`);
|
|
118
|
-
return
|
|
161
|
+
return {
|
|
162
|
+
requested,
|
|
163
|
+
resolved: selected,
|
|
164
|
+
reason: `selected supported reasoning ${selected} for unsupported request ${requested}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function lowerOrEqualSupportedThinking(requested, supported) {
|
|
168
|
+
const requestedIndex = THINKING_LEVELS.indexOf(requested);
|
|
169
|
+
if (requestedIndex < 0)
|
|
170
|
+
return [];
|
|
171
|
+
return THINKING_LEVELS.slice(0, requestedIndex + 1).filter((level) => supported.includes(level));
|
|
119
172
|
}
|
|
120
173
|
function findModelInfo(modelId, available) {
|
|
121
174
|
if (!modelId)
|