@evandu/super-pi 0.1.0 → 0.1.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/agents/super-pi-task/core.ts +748 -485
- package/agents/super-pi-task/index.ts +198 -141
- package/dist/client/assets/index-CHw4ZoY4.js +43 -0
- package/dist/client/assets/{index-CsbZzuMy.css → index-WlIFA_Dt.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +48 -1
- package/dist/server/application/run-service.js +78 -2
- package/dist/server/capability.d.ts +89 -0
- package/dist/server/capability.js +136 -0
- package/dist/server/command-handler.js +4 -0
- package/dist/server/http-server.js +20 -0
- package/dist/server/index.js +25 -2
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/runtime.js +13 -2
- package/dist/server/store.d.ts +42 -0
- package/dist/server/store.js +144 -1
- package/dist/server/task-tool.d.ts +21 -0
- package/dist/server/task-tool.js +21 -0
- package/dist/server/types.d.ts +34 -0
- package/dist/server/workflow-engine.d.ts +129 -0
- package/dist/server/workflow-engine.js +253 -0
- package/dist/server/workflow.d.ts +287 -0
- package/dist/server/workflow.js +387 -0
- package/package.json +5 -1
- package/dist/client/assets/index-BMQsEdio.js +0 -24
package/dist/server/task-tool.js
CHANGED
|
@@ -61,6 +61,27 @@ export const TASK_ROSTER_ENV = "SUPER_PI_TASK_ROSTER";
|
|
|
61
61
|
export const TASK_CONVERSATION_ENV = "SUPER_PI_TASK_CONVERSATION";
|
|
62
62
|
/** How many task hops deep this process already is. A primary is `0`. See `DEFAULT_SUBAGENT_DEPTH`. */
|
|
63
63
|
export const TASK_DEPTH_ENV = "SUPER_PI_TASK_DEPTH";
|
|
64
|
+
/**
|
|
65
|
+
* The name the model calls to hand an ordered plan of sub-agent steps to the server.
|
|
66
|
+
*
|
|
67
|
+
* A THIRD tool rather than an extra parameter on `task`, and the reason is what the server does with the
|
|
68
|
+
* two. `task` is one delegation whose result the model reads back and acts on itself; `workflow` is a
|
|
69
|
+
* plan the server walks, carrying each step's output into the next without the model in the middle. One
|
|
70
|
+
* tool with a `steps` array would make every single delegation carry a shape it cannot use, and would make
|
|
71
|
+
* "dispatch one agent" and "run this pipeline" indistinguishable in the transcript - which is the opposite
|
|
72
|
+
* of what the model needs to choose between them.
|
|
73
|
+
*/
|
|
74
|
+
export const WORKFLOW_TOOL_NAME = "workflow";
|
|
75
|
+
/**
|
|
76
|
+
* The endpoint the `workflow` tool dials. A FIFTH name in the same wire as the four above, written out on
|
|
77
|
+
* both sides for the same reason.
|
|
78
|
+
*
|
|
79
|
+
* A SEPARATE variable rather than a path derived from `TASK_ENDPOINT_ENV`, for the reason
|
|
80
|
+
* `TODO_ENDPOINT_ENV` already states: a Pi can legitimately be started with a dispatch endpoint and no
|
|
81
|
+
* workflow endpoint, and a derived path could not tell that apart from a misconfiguration. The tool then
|
|
82
|
+
* refuses in words instead of posting somewhere that does not exist.
|
|
83
|
+
*/
|
|
84
|
+
export const WORKFLOW_ENDPOINT_ENV = "SUPER_PI_WORKFLOW_ENDPOINT";
|
|
64
85
|
/**
|
|
65
86
|
* The dispatchable roster, in the order it will be injected.
|
|
66
87
|
*
|
package/dist/server/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WorkflowRun } from "./workflow.js";
|
|
1
2
|
export type RunStatus = "idle" | "running" | "completed" | "stopped" | "failed";
|
|
2
3
|
export type MessageRole = "user" | "assistant" | "tool" | "system";
|
|
3
4
|
/**
|
|
@@ -477,6 +478,16 @@ export type BackgroundTaskSummary = {
|
|
|
477
478
|
startedAt: string;
|
|
478
479
|
resultBlockId: string;
|
|
479
480
|
};
|
|
481
|
+
/**
|
|
482
|
+
* Orchestrated plans, optional for the same reason `todos` is: a `state.json` written before this
|
|
483
|
+
* feature existed has no `workflows` key, and must read correctly rather than looking like an install
|
|
484
|
+
* that has considered a plan and found none. Absence and an empty list mean the same thing to every
|
|
485
|
+
* reader, so the store deletes the key rather than storing `[]`.
|
|
486
|
+
*
|
|
487
|
+
* The workflow's own types live in `workflow.ts` next to the rules that build them. This is a type-only
|
|
488
|
+
* import and is erased at compile time, so it does not close a runtime cycle with the rules that
|
|
489
|
+
* construct them - the same relationship `SubagentRun` already has with `subagent-runs.ts`.
|
|
490
|
+
*/
|
|
480
491
|
export type AppState = {
|
|
481
492
|
workspaces: Workspace[];
|
|
482
493
|
conversations: Conversation[];
|
|
@@ -484,6 +495,7 @@ export type AppState = {
|
|
|
484
495
|
queuedMessages: QueuedMessage[];
|
|
485
496
|
backgroundTasks: BackgroundTaskSummary[];
|
|
486
497
|
todos?: TodoItem[];
|
|
498
|
+
workflows?: WorkflowRun[];
|
|
487
499
|
};
|
|
488
500
|
export type TodoItem = {
|
|
489
501
|
id?: string;
|
|
@@ -790,6 +802,12 @@ export type ClientCommand = {
|
|
|
790
802
|
type: "subagent.stop";
|
|
791
803
|
runId: string;
|
|
792
804
|
}
|
|
805
|
+
/** Stops one orchestrated plan. Distinct from `subagent.stop` because the engine holds the loop and
|
|
806
|
+
* the signal, and stopping only the run would leave the plan marching on to the next step. */
|
|
807
|
+
| {
|
|
808
|
+
type: "workflow.stop";
|
|
809
|
+
workflowId: string;
|
|
810
|
+
}
|
|
793
811
|
/** Asks for the `mode: subagent` half of the agent directory. Answered by `subagent.catalog`. */
|
|
794
812
|
| {
|
|
795
813
|
type: "subagent.catalog.fetch";
|
|
@@ -905,6 +923,22 @@ export type ServerEvent = {
|
|
|
905
923
|
type: "subagent.event";
|
|
906
924
|
run: SubagentRun;
|
|
907
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* The current SNAPSHOT of one orchestrated plan, sent on every step transition and nothing else.
|
|
928
|
+
*
|
|
929
|
+
* WHOLE SNAPSHOT, like `subagent.event`, for the same reason: a timeline assembled from missed events
|
|
930
|
+
* is a timeline that can be wrong, and this one has to survive a reload and a reconnect race.
|
|
931
|
+
*
|
|
932
|
+
* ON TRANSITIONS ONLY, never on a stream delta, and that asymmetry is deliberate rather than an
|
|
933
|
+
* oversight. A step's own progress already arrives as `subagent.event` for the run it is executing, so
|
|
934
|
+
* a workflow event on every delta would broadcast the whole plan - every step's output, accumulated -
|
|
935
|
+
* once per TOKEN of one child. That is the one way this feature could make the app feel broken. Two
|
|
936
|
+
* events per step (begin, settle) is what a timeline actually needs; the rest is the run card's job.
|
|
937
|
+
*/
|
|
938
|
+
| {
|
|
939
|
+
type: "workflow.event";
|
|
940
|
+
workflow: WorkflowRun;
|
|
941
|
+
}
|
|
908
942
|
/**
|
|
909
943
|
* Pi's own queue snapshot, forwarded the moment `queue_update` arrives on the child's stdout.
|
|
910
944
|
*
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { SubagentRunRecorder } from "./subagent-runs.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import type { ModelSelection, ServerEvent } from "./types.js";
|
|
4
|
+
import { type WorkflowPlan, type WorkflowRun } from "./workflow.js";
|
|
5
|
+
/**
|
|
6
|
+
* Walking an orchestrated plan: the half of the feature that DECIDES anything.
|
|
7
|
+
*
|
|
8
|
+
* WHY THIS FILE HOLDS NO SPAWNING, NO STREAMING AND NO PROCESS TEARDOWN. Every one of those already
|
|
9
|
+
* exists and is already proved - `subagents.ts` spawns and drains, `subagent-runs.ts` records and
|
|
10
|
+
* publishes. The engine's whole job is to call `dispatch` in the right ORDER and decide what to do when
|
|
11
|
+
* a step does not succeed, and `SubagentRunRecorder.dispatch` is the seam that makes that possible: it
|
|
12
|
+
* ALWAYS RESOLVES with a settled record, including for a dispatch that never started (`subagent-runs.ts:159`).
|
|
13
|
+
* So an engine step is `await` one call, read `status` off the record, and move on. There is no error
|
|
14
|
+
* parsing here, no exception handling around a spawn, and no way for a child to be left running because
|
|
15
|
+
* the thing above it threw - which is the entire reason this feature is small.
|
|
16
|
+
*
|
|
17
|
+
* WHAT THE ENGINE IS NOT ALLOWED TO DO, stated as the two rules it is built around:
|
|
18
|
+
*
|
|
19
|
+
* 1. IT DOES NOT DECIDE WHAT A STEP MEANS. `role` is written through to the run record and read by
|
|
20
|
+
* nobody in this process. A step that says "verify" and a step that says "look at it" execute the same
|
|
21
|
+
* way. The rules about who does what live in `agents/*.md` and in the model that wrote the plan, not
|
|
22
|
+
* in a dispatcher holding a table of role names.
|
|
23
|
+
* 2. IT DOES NOT JUDGE RESULTS. A `verifier` step that completes with 结论:失败 has SUCCEEDED, as far as
|
|
24
|
+
* this loop is concerned, and the plan advances. The verdict lives in the text, and the component that
|
|
25
|
+
* can read the text - the primary agent - is what decides whether to run another round. A server that
|
|
26
|
+
* parsed 通过/失败 would be guessing at a convention `verifier.md` states in prose and `scout.md` and
|
|
27
|
+
* `critic.md` do not have at all, and would have no correct answer when the text said neither.
|
|
28
|
+
*/
|
|
29
|
+
/** What the engine needs from the server around it. Injected, so this file holds no globals. */
|
|
30
|
+
export type WorkflowEngineHooks = {
|
|
31
|
+
store: Store;
|
|
32
|
+
/** Publishes one event to whoever is listening. A plain function, so a test can pass an array. */
|
|
33
|
+
broadcast: (event: ServerEvent) => void;
|
|
34
|
+
};
|
|
35
|
+
/** Everything one plan needs to run. The plan itself arrives already validated and normalized. */
|
|
36
|
+
export type WorkflowRunInput = {
|
|
37
|
+
conversationId: string;
|
|
38
|
+
/** The dispatching conversation's workspace, resolved by the caller. Never defaulted to the server's own directory. */
|
|
39
|
+
cwd: string;
|
|
40
|
+
plan: WorkflowPlan;
|
|
41
|
+
model?: ModelSelection;
|
|
42
|
+
/** Tools the primary agent denied, inherited by every step, exactly as a single dispatch inherits them. */
|
|
43
|
+
denyTools?: readonly string[];
|
|
44
|
+
/** Cancels the whole plan. Each step is dispatched under it, so one signal reaches the loop and the child. */
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
};
|
|
47
|
+
/** The outcome of a plan, as the endpoint reports it. Always produced, never thrown. */
|
|
48
|
+
export type WorkflowOutcome = {
|
|
49
|
+
workflow: WorkflowRun;
|
|
50
|
+
};
|
|
51
|
+
export declare class WorkflowEngine {
|
|
52
|
+
private readonly recorder;
|
|
53
|
+
private readonly hooks;
|
|
54
|
+
/**
|
|
55
|
+
* One controller per in-flight plan.
|
|
56
|
+
*
|
|
57
|
+
* NEEDED SEPARATELY FROM THE RECORDER'S, and the difference is what stops a plan between steps. The
|
|
58
|
+
* recorder's map only holds runs that are executing right now, so a plan halted by a signal while no
|
|
59
|
+
* child is out would have nothing to abort and would happily start the next one. This map holds the
|
|
60
|
+
* PLAN, which exists for its whole length, so `stop` reaches it in every phase.
|
|
61
|
+
*/
|
|
62
|
+
private readonly controllers;
|
|
63
|
+
constructor(recorder: SubagentRunRecorder, hooks: WorkflowEngineHooks);
|
|
64
|
+
/**
|
|
65
|
+
* Stops ONE plan, and says whether there was anything to stop.
|
|
66
|
+
*
|
|
67
|
+
* `false` for an unknown or already-finished id is deliberate and matches `SubagentRunRecorder.stop`:
|
|
68
|
+
* the click that produces it raced the plan's own terminal event, and an error reply would put a
|
|
69
|
+
* failure banner on a timeline that says it finished normally.
|
|
70
|
+
*
|
|
71
|
+
* The abort reaches the child too, because every step is dispatched under this controller's signal and
|
|
72
|
+
* `dispatch` turns it into the same abort-then-killTree the recorder uses. Stopping a plan really does
|
|
73
|
+
* stop the process it is on.
|
|
74
|
+
*/
|
|
75
|
+
stop(workflowId: string): boolean;
|
|
76
|
+
/** Stops every plan in flight for one conversation, and returns how many were signalled. */
|
|
77
|
+
stopConversation(conversationId: string): number;
|
|
78
|
+
/** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
|
|
79
|
+
abortAll(): void;
|
|
80
|
+
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
81
|
+
private conversationOf;
|
|
82
|
+
/**
|
|
83
|
+
* Which conversation each in-flight plan belongs to.
|
|
84
|
+
*
|
|
85
|
+
* Held beside the controllers rather than read back from the store, because the store is the right
|
|
86
|
+
* place for what a person may read later and the wrong place for a synchronous lookup in a stop
|
|
87
|
+
* handler - it clones the entire state, and this runs on a click.
|
|
88
|
+
*/
|
|
89
|
+
private readonly owners;
|
|
90
|
+
/**
|
|
91
|
+
* Executes one plan to a terminal state, and returns the record as it ended.
|
|
92
|
+
*
|
|
93
|
+
* ALWAYS RESOLVES, like `dispatch` does and for the same reason: by the time the first await runs, the
|
|
94
|
+
* whole plan is already on disk, so the honest account of a plan that cannot proceed is a record that
|
|
95
|
+
* says why, rather than a rejection that leaves it claiming to be running until the next restart.
|
|
96
|
+
*/
|
|
97
|
+
run(input: WorkflowRunInput): Promise<WorkflowOutcome>;
|
|
98
|
+
/**
|
|
99
|
+
* Runs one step until it succeeds, is skipped, or stops the plan. Returns whether the PLAN should halt.
|
|
100
|
+
*
|
|
101
|
+
* The retry is a loop rather than a recursive call so the bound in the policy is the loop's own
|
|
102
|
+
* condition and cannot be exceeded by a bug in the call structure - the one property a retry policy has
|
|
103
|
+
* to have absolutely.
|
|
104
|
+
*/
|
|
105
|
+
private runStep;
|
|
106
|
+
/**
|
|
107
|
+
* Marks every step from `from` onward as stopped, because none of them will run.
|
|
108
|
+
*
|
|
109
|
+
* LEAVING THEM `pending` WOULD BE A LIE IN THE SPECIFIC DIRECTION THAT MATTERS: a browser renders
|
|
110
|
+
* "未开始" for a pending step, so a plan that died after step one of three would show three steps with
|
|
111
|
+
* two still waiting, and a person would reasonably expect the rest to happen. They will not.
|
|
112
|
+
*/
|
|
113
|
+
private haltRemaining;
|
|
114
|
+
/**
|
|
115
|
+
* Persists a plan, THEN announces it.
|
|
116
|
+
*
|
|
117
|
+
* PERSIST FIRST, ANNOUNCE SECOND, and the order is load-bearing here for exactly the reason it is in
|
|
118
|
+
* `SubagentRunRecorder.settle`: a browser that is told a step finished while `state.json` still says it
|
|
119
|
+
* is running will show a timeline a reload immediately contradicts, and the startup reconciliation that
|
|
120
|
+
* would fix it runs on a SERVER restart, not a page refresh.
|
|
121
|
+
*
|
|
122
|
+
* Unlike the recorder, this AWAITS both halves rather than writing fire-and-forget, and that is not an
|
|
123
|
+
* inconsistency. The recorder cannot await: it publishes on every text delta, and a drain loop that
|
|
124
|
+
* flushed to disk per token would throttle the child it is streaming. This publishes twice per step -
|
|
125
|
+
* at most sixteen times for a whole plan - so awaiting costs nothing measurable and buys the strong
|
|
126
|
+
* version of the guarantee.
|
|
127
|
+
*/
|
|
128
|
+
private commit;
|
|
129
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { attemptFromRun, composeStepPrompt, newWorkflowRunId, retriesFor, } from "./workflow.js";
|
|
2
|
+
const now = () => new Date().toISOString();
|
|
3
|
+
export class WorkflowEngine {
|
|
4
|
+
recorder;
|
|
5
|
+
hooks;
|
|
6
|
+
/**
|
|
7
|
+
* One controller per in-flight plan.
|
|
8
|
+
*
|
|
9
|
+
* NEEDED SEPARATELY FROM THE RECORDER'S, and the difference is what stops a plan between steps. The
|
|
10
|
+
* recorder's map only holds runs that are executing right now, so a plan halted by a signal while no
|
|
11
|
+
* child is out would have nothing to abort and would happily start the next one. This map holds the
|
|
12
|
+
* PLAN, which exists for its whole length, so `stop` reaches it in every phase.
|
|
13
|
+
*/
|
|
14
|
+
controllers = new Map();
|
|
15
|
+
constructor(recorder, hooks) {
|
|
16
|
+
this.recorder = recorder;
|
|
17
|
+
this.hooks = hooks;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Stops ONE plan, and says whether there was anything to stop.
|
|
21
|
+
*
|
|
22
|
+
* `false` for an unknown or already-finished id is deliberate and matches `SubagentRunRecorder.stop`:
|
|
23
|
+
* the click that produces it raced the plan's own terminal event, and an error reply would put a
|
|
24
|
+
* failure banner on a timeline that says it finished normally.
|
|
25
|
+
*
|
|
26
|
+
* The abort reaches the child too, because every step is dispatched under this controller's signal and
|
|
27
|
+
* `dispatch` turns it into the same abort-then-killTree the recorder uses. Stopping a plan really does
|
|
28
|
+
* stop the process it is on.
|
|
29
|
+
*/
|
|
30
|
+
stop(workflowId) {
|
|
31
|
+
const controller = this.controllers.get(workflowId);
|
|
32
|
+
if (!controller)
|
|
33
|
+
return false;
|
|
34
|
+
controller.abort();
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/** Stops every plan in flight for one conversation, and returns how many were signalled. */
|
|
38
|
+
stopConversation(conversationId) {
|
|
39
|
+
let stopped = 0;
|
|
40
|
+
for (const [id, controller] of this.controllers) {
|
|
41
|
+
if (this.conversationOf(id) === conversationId) {
|
|
42
|
+
controller.abort();
|
|
43
|
+
stopped += 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return stopped;
|
|
47
|
+
}
|
|
48
|
+
/** Aborts every plan in flight. Called on shutdown, so no child outlives the server that owns it. */
|
|
49
|
+
abortAll() {
|
|
50
|
+
for (const controller of this.controllers.values())
|
|
51
|
+
controller.abort();
|
|
52
|
+
}
|
|
53
|
+
/** The conversation a plan belongs to, which only this map knows while the plan is running. */
|
|
54
|
+
conversationOf(workflowId) {
|
|
55
|
+
return this.owners.get(workflowId);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Which conversation each in-flight plan belongs to.
|
|
59
|
+
*
|
|
60
|
+
* Held beside the controllers rather than read back from the store, because the store is the right
|
|
61
|
+
* place for what a person may read later and the wrong place for a synchronous lookup in a stop
|
|
62
|
+
* handler - it clones the entire state, and this runs on a click.
|
|
63
|
+
*/
|
|
64
|
+
owners = new Map();
|
|
65
|
+
/**
|
|
66
|
+
* Executes one plan to a terminal state, and returns the record as it ended.
|
|
67
|
+
*
|
|
68
|
+
* ALWAYS RESOLVES, like `dispatch` does and for the same reason: by the time the first await runs, the
|
|
69
|
+
* whole plan is already on disk, so the honest account of a plan that cannot proceed is a record that
|
|
70
|
+
* says why, rather than a rejection that leaves it claiming to be running until the next restart.
|
|
71
|
+
*/
|
|
72
|
+
async run(input) {
|
|
73
|
+
const id = newWorkflowRunId();
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const onCallerAbort = () => controller.abort();
|
|
76
|
+
input.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
77
|
+
this.controllers.set(id, controller);
|
|
78
|
+
this.owners.set(id, input.conversationId);
|
|
79
|
+
// THE WHOLE PLAN, IN FULL, BEFORE STEP ONE RUNS. See `Store.beginWorkflow`: this is the write that
|
|
80
|
+
// makes the record a claim the loop makes good rather than a log that fills in after the fact.
|
|
81
|
+
const plan = {
|
|
82
|
+
id,
|
|
83
|
+
conversationId: input.conversationId,
|
|
84
|
+
summary: input.plan.summary,
|
|
85
|
+
onFailure: input.plan.onFailure,
|
|
86
|
+
status: "running",
|
|
87
|
+
steps: input.plan.steps.map((step, index) => ({
|
|
88
|
+
index,
|
|
89
|
+
agentId: step.agentId,
|
|
90
|
+
...(step.label ? { label: step.label } : {}),
|
|
91
|
+
prompt: step.prompt,
|
|
92
|
+
attempts: [],
|
|
93
|
+
status: "pending",
|
|
94
|
+
})),
|
|
95
|
+
currentIndex: 0,
|
|
96
|
+
startedAt: now(),
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
// The INSERT, distinct from every later `commit`: this is the one write that makes the plan exist,
|
|
100
|
+
// and it happens before the loop rather than inside it. Separate from `commit` rather than folded
|
|
101
|
+
// into it because "insert if absent" is a different store operation with a different failure mode -
|
|
102
|
+
// a `commit` that upserted would let a plan start executing without ever having been written down.
|
|
103
|
+
const created = await this.hooks.store.beginWorkflow(plan);
|
|
104
|
+
this.hooks.broadcast({ type: "workflow.event", workflow: created });
|
|
105
|
+
for (let index = 0; index < plan.steps.length; index += 1) {
|
|
106
|
+
// Checked BEFORE each step, not only after one: a plan stopped between two children must not
|
|
107
|
+
// start the second one, and the signal that stopped it may have arrived while no child was out.
|
|
108
|
+
if (controller.signal.aborted)
|
|
109
|
+
break;
|
|
110
|
+
const halted = await this.runStep(plan, plan.steps[index], input, controller.signal);
|
|
111
|
+
if (halted)
|
|
112
|
+
break;
|
|
113
|
+
plan.currentIndex = index + 1;
|
|
114
|
+
await this.commit(plan);
|
|
115
|
+
}
|
|
116
|
+
// The terminal status is DERIVED, not set optimistically as the loop goes. A plan that finished all
|
|
117
|
+
// its steps including deliberate skips is `completed` - skips are the plan's own declared intent,
|
|
118
|
+
// not a shortfall. A plan that ran out of steps because one failed under a `stop` policy is `failed`.
|
|
119
|
+
//
|
|
120
|
+
// BEING HALTED OUTRANKS ALL OF IT, and it is keyed on a STEP that ended `stopped` rather than only
|
|
121
|
+
// on this engine's own signal - because a plan can be halted by a button that never touches the
|
|
122
|
+
// engine at all. Pressing stop on a step's OWN run card aborts the child through the recorder, which
|
|
123
|
+
// settles that run as `stopped`; the engine's controller is still perfectly un-aborted at that point.
|
|
124
|
+
// Keying only on the signal reported that plan as `failed`, which is a verdict nothing established:
|
|
125
|
+
// the work was not broken, a person stopped it. So the test is whether anything in the plan ended
|
|
126
|
+
// `stopped`, whoever did the stopping.
|
|
127
|
+
if (controller.signal.aborted || plan.steps.some((step) => step.status === "stopped")) {
|
|
128
|
+
this.haltRemaining(plan, plan.currentIndex);
|
|
129
|
+
plan.status = "stopped";
|
|
130
|
+
}
|
|
131
|
+
else if (plan.steps.every((step) => step.status === "completed" || step.status === "skipped")) {
|
|
132
|
+
plan.status = "completed";
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
this.haltRemaining(plan, plan.currentIndex);
|
|
136
|
+
plan.status = "failed";
|
|
137
|
+
}
|
|
138
|
+
plan.finishedAt = now();
|
|
139
|
+
await this.commit(plan);
|
|
140
|
+
return { workflow: structuredClone(plan) };
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
input.signal?.removeEventListener("abort", onCallerAbort);
|
|
144
|
+
this.controllers.delete(id);
|
|
145
|
+
this.owners.delete(id);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Runs one step until it succeeds, is skipped, or stops the plan. Returns whether the PLAN should halt.
|
|
150
|
+
*
|
|
151
|
+
* The retry is a loop rather than a recursive call so the bound in the policy is the loop's own
|
|
152
|
+
* condition and cannot be exceeded by a bug in the call structure - the one property a retry policy has
|
|
153
|
+
* to have absolutely.
|
|
154
|
+
*/
|
|
155
|
+
async runStep(plan, step, input, signal) {
|
|
156
|
+
const retries = retriesFor(plan.onFailure);
|
|
157
|
+
let attempt = 0;
|
|
158
|
+
while (attempt <= retries) {
|
|
159
|
+
if (signal.aborted) {
|
|
160
|
+
step.status = "stopped";
|
|
161
|
+
await this.commit(plan);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
attempt += 1;
|
|
165
|
+
step.status = "running";
|
|
166
|
+
plan.currentIndex = step.index;
|
|
167
|
+
// Recomposed EVERY attempt, not once before the loop. A retry exists because the first answer was
|
|
168
|
+
// not acceptable, so handing the second attempt the same inputs would make the policy a no-op that
|
|
169
|
+
// burns the same sub-agent's answer twice and reports it as progress.
|
|
170
|
+
const composed = composeStepPrompt(step.prompt, plan.steps.slice(0, step.index));
|
|
171
|
+
await this.commit(plan);
|
|
172
|
+
const run = await this.recorder.dispatch({
|
|
173
|
+
conversationId: input.conversationId,
|
|
174
|
+
cwd: input.cwd,
|
|
175
|
+
agentId: step.agentId,
|
|
176
|
+
task: composed.prompt,
|
|
177
|
+
dispatchedBy: "model",
|
|
178
|
+
// The two fields that were carried in every run record from the start and read by nothing until
|
|
179
|
+
// now. A step's run therefore names the plan it belongs to and the label the model gave it, which
|
|
180
|
+
// is what lets the timeline link a card back to its step without any new field on `SubagentRun`.
|
|
181
|
+
loopId: plan.id,
|
|
182
|
+
role: step.label,
|
|
183
|
+
model: input.model,
|
|
184
|
+
denyTools: input.denyTools,
|
|
185
|
+
signal,
|
|
186
|
+
});
|
|
187
|
+
// Appended, never assigned over: the attempt that failed is exactly what makes the retry legible,
|
|
188
|
+
// and it is the record a person reads when a verifier failed twice.
|
|
189
|
+
step.attempts.push(attemptFromRun(run, attempt));
|
|
190
|
+
if (run.status === "completed") {
|
|
191
|
+
step.status = "completed";
|
|
192
|
+
await this.commit(plan);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
if (run.status === "stopped") {
|
|
196
|
+
step.status = "stopped";
|
|
197
|
+
await this.commit(plan);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
// Failed. The policy decides whether this step is tried again, walked past, or ends the plan.
|
|
201
|
+
if (attempt <= retries) {
|
|
202
|
+
await this.commit(plan);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (plan.onFailure === "skip") {
|
|
206
|
+
// Marked `skipped` rather than `failed`, because the two mean different things to what comes
|
|
207
|
+
// after: a skipped step contributed no output and says so in the next step's handoff block, while
|
|
208
|
+
// a failed one contributed output that failed. Both are visible; conflating them would lose which.
|
|
209
|
+
step.status = "skipped";
|
|
210
|
+
await this.commit(plan);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
step.status = "failed";
|
|
214
|
+
await this.commit(plan);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
// Unreachable: the loop returns on every path that sets a terminal step status, and the bound is
|
|
218
|
+
// checked on entry. Present so a future edit that adds a path fails to compile rather than silently
|
|
219
|
+
// leaving a step `running` with no plan status to match it.
|
|
220
|
+
step.status = "failed";
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Marks every step from `from` onward as stopped, because none of them will run.
|
|
225
|
+
*
|
|
226
|
+
* LEAVING THEM `pending` WOULD BE A LIE IN THE SPECIFIC DIRECTION THAT MATTERS: a browser renders
|
|
227
|
+
* "未开始" for a pending step, so a plan that died after step one of three would show three steps with
|
|
228
|
+
* two still waiting, and a person would reasonably expect the rest to happen. They will not.
|
|
229
|
+
*/
|
|
230
|
+
haltRemaining(plan, from) {
|
|
231
|
+
for (const step of plan.steps.slice(from))
|
|
232
|
+
if (step.status === "pending" || step.status === "running")
|
|
233
|
+
step.status = "stopped";
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Persists a plan, THEN announces it.
|
|
237
|
+
*
|
|
238
|
+
* PERSIST FIRST, ANNOUNCE SECOND, and the order is load-bearing here for exactly the reason it is in
|
|
239
|
+
* `SubagentRunRecorder.settle`: a browser that is told a step finished while `state.json` still says it
|
|
240
|
+
* is running will show a timeline a reload immediately contradicts, and the startup reconciliation that
|
|
241
|
+
* would fix it runs on a SERVER restart, not a page refresh.
|
|
242
|
+
*
|
|
243
|
+
* Unlike the recorder, this AWAITS both halves rather than writing fire-and-forget, and that is not an
|
|
244
|
+
* inconsistency. The recorder cannot await: it publishes on every text delta, and a drain loop that
|
|
245
|
+
* flushed to disk per token would throttle the child it is streaming. This publishes twice per step -
|
|
246
|
+
* at most sixteen times for a whole plan - so awaiting costs nothing measurable and buys the strong
|
|
247
|
+
* version of the guarantee.
|
|
248
|
+
*/
|
|
249
|
+
async commit(plan) {
|
|
250
|
+
const persisted = await this.hooks.store.setWorkflow(plan);
|
|
251
|
+
this.hooks.broadcast({ type: "workflow.event", workflow: persisted });
|
|
252
|
+
}
|
|
253
|
+
}
|