@parcha/agentrun-pi 0.1.0-beta.11 → 0.1.0-beta.13
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 +20 -8
- package/dist/cli.js +10 -1
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -0
- package/dist/durable/decision-recovery.d.ts +23 -0
- package/dist/durable/decision-recovery.js +57 -0
- package/dist/durable/decisions.d.ts +113 -0
- package/dist/durable/decisions.js +71 -0
- package/dist/durable/index.d.ts +19 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/node-runner.d.ts +100 -0
- package/dist/durable/node-runner.js +243 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +81 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +25 -0
- package/dist/durable/readers.js +64 -0
- package/dist/durable/receipts.d.ts +52 -0
- package/dist/durable/receipts.js +91 -0
- package/dist/durable/record-file.d.ts +10 -0
- package/dist/durable/record-file.js +56 -0
- package/dist/durable/record-tool.d.ts +76 -0
- package/dist/durable/record-tool.js +123 -0
- package/dist/durable/record.d.ts +157 -0
- package/dist/durable/record.js +402 -0
- package/dist/durable/recording.d.ts +90 -0
- package/dist/durable/recording.js +292 -0
- package/dist/durable/store.d.ts +23 -0
- package/dist/durable/store.js +192 -0
- package/dist/durable/task.d.ts +129 -0
- package/dist/durable/task.js +395 -0
- package/dist/extension-service.js +14 -1
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +98 -13
- package/dist/host-session.js +3 -1
- package/dist/run-error.d.ts +6 -0
- package/dist/run-error.js +13 -0
- package/dist/runner.d.ts +1 -6
- package/dist/runner.js +2 -12
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/workflow-session.d.ts +4 -0
- package/dist/workflow-session.js +3 -2
- package/package.json +43 -8
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// A workflow run as one pi-durable task: `agentrun.workflow`, version 1, one phase `run`. The phase opens the recovery
|
|
2
|
+
// driver on the document store, runs the interpreter to its end inside one invocation, and makes one terminal commit;
|
|
3
|
+
// every journal commit in between leaves the task's own state unchanged. The journal is what resumes: a process that
|
|
4
|
+
// dies leaves the task pending, and the next process's `resume()` runs the phase again, which answers what committed
|
|
5
|
+
// from the journal. Three rules from the task spike hold here:
|
|
6
|
+
// - a typed failure is a committed outcome (`failed`, with the code in `error.detail`), never a throw, so pi records
|
|
7
|
+
// it whole instead of faulting the task with a message;
|
|
8
|
+
// - a pause is taken at a seam: the driver stops admitting, an effect in flight completes and commits, the journal
|
|
9
|
+
// commits `paused`, and the task stays pending until the host closes the Harness; reopening continues it;
|
|
10
|
+
// - a cancel is pi's `abortTask`, which returns once the task is marked, before it is terminal: pi ends the run
|
|
11
|
+
// invocation and the conversations the task owns first, then the abort handler commits `cancelled` in the journal
|
|
12
|
+
// and an `aborted` outcome. A cancelled run never resumes.
|
|
13
|
+
// A host that continues an escalated run (its ladder) does so inside the same invocation, through `escalated`, so the
|
|
14
|
+
// continuation it opens is a conversation the task owns: the run's stops reach it the way they reach the run's steps.
|
|
15
|
+
import { defineDocFamily, defineExtension, defineTask } from "@earendil-works/pi-durable";
|
|
16
|
+
import { runWorkflow } from "@parcha/agentrun-dsl";
|
|
17
|
+
import { bindingDigests, bindingMismatch, canonicalHash, isRecoveryError, openRecovery, recoveryBound, RecoveryError, withRecovery } from "@parcha/agentrun-dsl/recovery";
|
|
18
|
+
import { storageError } from "./pi-versions.js";
|
|
19
|
+
import { documentStore } from "./store.js";
|
|
20
|
+
const RunDoc = defineDocFamily({ kind: "agentrun.task", version: 1, scope: "session", family: true, initial: () => ({ task: null, stop: null }) });
|
|
21
|
+
/** Records a stop on a run's entry. A recorded cancel stands, so the first cancel names the run's source; a recorded
|
|
22
|
+
* pause is replaced by the stop that follows it. */
|
|
23
|
+
const recordStop = (run, stop) => { if (run.stop?.action !== "cancel")
|
|
24
|
+
run.stop = stop; };
|
|
25
|
+
/** The source of the cancel a run's entry records. */
|
|
26
|
+
const cancelSource = (run) => (run?.stop?.action === "cancel" ? run.stop.source ?? undefined : undefined);
|
|
27
|
+
const cancelled = (source) => ({ status: "aborted", reason: source ? `cancelled by ${source}` : "cancelled" });
|
|
28
|
+
const json = (value) => (value === undefined ? null : JSON.parse(JSON.stringify(value)));
|
|
29
|
+
/** Why a request is not the run a task holds, or undefined when it is: the same binding (the workflow as authored and
|
|
30
|
+
* what the host binds beside it, as the driver binds its journal) and the same input, each by its canonical digest, so
|
|
31
|
+
* key order does not matter. The refusal is the driver's binding mismatch and names what moved: the workflow's parts,
|
|
32
|
+
* what the host binds, or the input's fields (`input.<field>`). Each part is compared over its own digests, so a bound
|
|
33
|
+
* key named like the input (`input`) hides neither; when both moved, the refusal holds both parts' messages and names. */
|
|
34
|
+
const requestMoved = (held, asked) => {
|
|
35
|
+
const was = recoveryBound(held.workflow, held.bind), is = recoveryBound(asked.workflow, asked.bind);
|
|
36
|
+
const parts = [
|
|
37
|
+
was.binding !== is.binding && bindingMismatch(JSON.stringify(was.inputs), is.inputs),
|
|
38
|
+
canonicalHash(held.input) !== canonicalHash(asked.input) && bindingMismatch(JSON.stringify(bindingDigests({ input: held.input })), bindingDigests({ input: asked.input })),
|
|
39
|
+
].filter((part) => Boolean(part));
|
|
40
|
+
if (parts.length < 2)
|
|
41
|
+
return parts[0];
|
|
42
|
+
return Object.assign(new RecoveryError(parts.map((part) => part.message).join("; "), "RUN_STORE_BINDING_MISMATCH"), { moved: parts.flatMap((part) => part.moved ?? []) });
|
|
43
|
+
};
|
|
44
|
+
const codeOf = (error) => {
|
|
45
|
+
const code = error?.code;
|
|
46
|
+
return typeof code === "string" && code ? code : "WORKFLOW_FAILED";
|
|
47
|
+
};
|
|
48
|
+
const messageOf = (error) => String(error?.message ?? error);
|
|
49
|
+
/** A failure's own fields that are plain JSON (a host's typed failure: its stage, its node, its cost), so the host can
|
|
50
|
+
* read back the failure it raised. The stack and the cause stay out. */
|
|
51
|
+
const ownFields = (error) => {
|
|
52
|
+
const out = {};
|
|
53
|
+
if (!error || typeof error !== "object")
|
|
54
|
+
return out;
|
|
55
|
+
for (const [key, value] of Object.entries(error)) {
|
|
56
|
+
if (key === "stack" || key === "cause" || value === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
out[key] = json(value);
|
|
60
|
+
}
|
|
61
|
+
catch { /* not plain JSON: left out */ }
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
/** The document store over one invocation's commits: a commit after the task was aborted or the Harness closed is
|
|
66
|
+
* refused by pi, so the driver can never write past its run's end. The task is the journal's one owner across its
|
|
67
|
+
* invocations: its abort handler takes the journal over from a run invocation that never closed it. */
|
|
68
|
+
function invocationStore(runtime, context, key) {
|
|
69
|
+
const host = {
|
|
70
|
+
commit: async (change) => {
|
|
71
|
+
let result;
|
|
72
|
+
await runtime.commit(async (tx) => { result = await change(tx); return undefined; }, context);
|
|
73
|
+
return result;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return documentStore(host, key, { owner: `agentrun.workflow:${runtime.taskId}`, context });
|
|
77
|
+
}
|
|
78
|
+
const uncommitted = new Map();
|
|
79
|
+
const uncommittedId = (key, taskId) => JSON.stringify([key, taskId]);
|
|
80
|
+
const uncommittedOf = (key, taskId) => {
|
|
81
|
+
const id = uncommittedId(key, taskId);
|
|
82
|
+
let entry = uncommitted.get(id);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
let reject;
|
|
85
|
+
const promise = new Promise((_, fail) => { reject = fail; });
|
|
86
|
+
promise.catch(() => undefined);
|
|
87
|
+
const made = { promise, failed: false, waits: 0, fail: (error) => { made.failed = true; reject(error); } };
|
|
88
|
+
entry = made;
|
|
89
|
+
uncommitted.set(id, entry);
|
|
90
|
+
}
|
|
91
|
+
return entry;
|
|
92
|
+
};
|
|
93
|
+
/** Runs where this process holds them, by key: the driver a pause is handed to, and when the run ended. */
|
|
94
|
+
const live = new Map();
|
|
95
|
+
/** The task definition, the extension that carries it, and the host's three operations on a run. */
|
|
96
|
+
export function createWorkflowExtension(host) {
|
|
97
|
+
/** The run's driver over its journal, with the host's options. */
|
|
98
|
+
const openDriver = async (task, runtime, context, phase) => {
|
|
99
|
+
const { key, workflow, bind } = task.input;
|
|
100
|
+
const options = await host.options?.({ key, taskId: task.id, runtime, context, phase });
|
|
101
|
+
return openRecovery(invocationStore(runtime, context, key), workflow, { ...options, key, ...(bind ? { bind } : {}) });
|
|
102
|
+
};
|
|
103
|
+
/** The driver, then what the interpreter runs with: a host that fails to give it lets the driver go. */
|
|
104
|
+
const open = async (task, runtime, context) => {
|
|
105
|
+
const driver = await openDriver(task, runtime, context, "run");
|
|
106
|
+
try {
|
|
107
|
+
const parts = await host.run({ key: task.input.key, taskId: task.id, runtime, context, driver });
|
|
108
|
+
const deps = "deps" in parts
|
|
109
|
+
? { ...parts.deps, signal: AbortSignal.any([driver.signal, runtime.signal, ...(parts.deps.signal ? [parts.deps.signal] : [])]) }
|
|
110
|
+
: withRecovery(driver, { ...parts.adapters, signal: runtime.signal }, { durableNodes: parts.durableNodes });
|
|
111
|
+
return { deps, driver, interpret: ("deps" in parts && parts.interpret) || runWorkflow };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
await driver.close().catch(() => undefined);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const failed = (error) => ({ status: "failed", error: { message: messageOf(error), detail: { ...ownFields(error), code: codeOf(error) } } });
|
|
119
|
+
const Workflow = defineTask({
|
|
120
|
+
name: "agentrun.workflow", version: 1, initial: () => ({ phase: "run" }),
|
|
121
|
+
phases: {
|
|
122
|
+
run: async (task, runtime, context) => {
|
|
123
|
+
const { key, workflow, input } = task.input;
|
|
124
|
+
/** The run's one terminal commit. One the Session refuses (not an abort's) hands this process's waiters the run's
|
|
125
|
+
* own failure, or the refusal. */
|
|
126
|
+
const commitEnd = async (outcome, cause) => {
|
|
127
|
+
try {
|
|
128
|
+
await runtime.commit(() => ({ status: "terminal", outcome }), context);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (!runtime.signal.aborted)
|
|
132
|
+
uncommittedOf(key, task.id).fail(cause ?? error);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
let opened;
|
|
137
|
+
try {
|
|
138
|
+
opened = await open(task, runtime, context);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (runtime.signal.aborted)
|
|
142
|
+
throw error;
|
|
143
|
+
await commitEnd(failed(error), error);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const { deps, driver, interpret } = opened;
|
|
147
|
+
// A stop the run's entry records is the driver's before anything runs. The entry lets go of a pause once the
|
|
148
|
+
// journal holds it, so the next start runs again. A failure here lets the driver go and is the run's failure,
|
|
149
|
+
// as a failure to open is.
|
|
150
|
+
let early;
|
|
151
|
+
try {
|
|
152
|
+
early = (await runtime.snapshot(RunDoc, key, context))?.stop ?? null;
|
|
153
|
+
if (early?.action === "pause") {
|
|
154
|
+
driver.stop({ action: "pause", ...(early.source ? { source: early.source } : {}) });
|
|
155
|
+
await driver.flush().catch(() => undefined);
|
|
156
|
+
await runtime.commit(async (tx) => { const run = await tx.doc(RunDoc, key, null); if (run.stop?.action === "pause")
|
|
157
|
+
run.stop = null; return undefined; }, context);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
await driver.close().catch(() => undefined);
|
|
162
|
+
if (runtime.signal.aborted)
|
|
163
|
+
throw error;
|
|
164
|
+
await commitEnd(failed(error), error);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let end;
|
|
168
|
+
live.set(key, { driver, ended: new Promise((resolve) => { end = resolve; }) });
|
|
169
|
+
let outcome;
|
|
170
|
+
let cause;
|
|
171
|
+
const ladder = host.escalated;
|
|
172
|
+
/** The run continues on the host's ladder; the row the host committed rides on the outcome. */
|
|
173
|
+
const handOff = async (ending) => {
|
|
174
|
+
const continuation = await ladder(ending, { key, taskId: task.id, runtime, context, driver, signal: AbortSignal.any([runtime.signal, driver.signal]) });
|
|
175
|
+
await driver.flush();
|
|
176
|
+
const row = driver.escalation();
|
|
177
|
+
if (!row)
|
|
178
|
+
throw Object.assign(new Error("The host continued the run without committing its escalation"), { code: "ESCALATION_NOT_COMMITTED" });
|
|
179
|
+
return { status: "completed", result: { status: "escalated", escalation: json(row), state: json(ending.state), continuation: json(continuation) } };
|
|
180
|
+
};
|
|
181
|
+
/** A pause the driver took: the task stays pending until the host closes the Harness. */
|
|
182
|
+
const paused = () => driver.signal.aborted && driver.signal.reason?.code === "FROZEN_PAUSED";
|
|
183
|
+
try {
|
|
184
|
+
const prior = ladder ? driver.escalation() : undefined;
|
|
185
|
+
if (early?.action === "cancel") {
|
|
186
|
+
// A cancel recorded before pi marked the task (the process died between the two) is taken before any step
|
|
187
|
+
// and before the host's ladder on a run reopened after its escalation: this invocation ends the run as the
|
|
188
|
+
// abort handler would have.
|
|
189
|
+
driver.stop({ action: "cancel", ...(early.source ? { source: early.source } : {}) });
|
|
190
|
+
await driver.flush();
|
|
191
|
+
outcome = cancelled(early.source ?? undefined);
|
|
192
|
+
}
|
|
193
|
+
else if (prior) {
|
|
194
|
+
driver.checkStop();
|
|
195
|
+
outcome = await handOff({ row: prior, state: prior.state });
|
|
196
|
+
}
|
|
197
|
+
else if (driver.preStepRefusal) {
|
|
198
|
+
// A refusal found before any step: nothing runs. With a ladder, the host continues the run from it.
|
|
199
|
+
if (!ladder)
|
|
200
|
+
throw driver.preStepRefusal;
|
|
201
|
+
outcome = await handOff({ refusal: driver.preStepRefusal, state: { ...input } });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
let run;
|
|
205
|
+
try {
|
|
206
|
+
run = await interpret(workflow, input, deps);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
// A refusal, a stop or an abort never ladders.
|
|
210
|
+
if (!ladder || runtime.signal.aborted || driver.signal.aborted || isRecoveryError(error))
|
|
211
|
+
throw error;
|
|
212
|
+
outcome = await handOff({ error, state: driver.lastState() });
|
|
213
|
+
}
|
|
214
|
+
if (run)
|
|
215
|
+
outcome = run.status === "complete"
|
|
216
|
+
? { status: "completed", result: { status: "complete", output: json(run.output), state: json(run.state) } }
|
|
217
|
+
: ladder ? await handOff({ escalation: run.escalation, state: run.state })
|
|
218
|
+
: { status: "completed", result: { status: "escalated", escalation: json(run.escalation), state: json(run.state) } };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// An abort or a close: this invocation may not decide the outcome (its commits are refused), so it only ends.
|
|
223
|
+
if (runtime.signal.aborted)
|
|
224
|
+
throw error;
|
|
225
|
+
if (!paused()) {
|
|
226
|
+
outcome = failed(error);
|
|
227
|
+
cause = error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
// A paused run stays live here until its invocation ends, so a pause handed over meanwhile is answered.
|
|
232
|
+
if (!paused())
|
|
233
|
+
live.delete(key);
|
|
234
|
+
await driver.close().catch(() => undefined);
|
|
235
|
+
end();
|
|
236
|
+
}
|
|
237
|
+
if (paused() || !outcome) {
|
|
238
|
+
// The pause is in the journal once the driver's commits have landed; the task stays pending, and its
|
|
239
|
+
// invocation ends when the host closes the Harness.
|
|
240
|
+
await new Promise((resolve) => { if (runtime.signal.aborted)
|
|
241
|
+
resolve();
|
|
242
|
+
else
|
|
243
|
+
runtime.signal.addEventListener("abort", () => resolve(), { once: true }); });
|
|
244
|
+
if (live.get(key)?.driver === driver)
|
|
245
|
+
live.delete(key);
|
|
246
|
+
throw runtime.signal.reason;
|
|
247
|
+
}
|
|
248
|
+
await commitEnd(outcome, cause);
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
abort: async (task, runtime, context) => {
|
|
252
|
+
const { key } = task.input;
|
|
253
|
+
const source = cancelSource(await runtime.snapshot(RunDoc, key, context).catch(() => undefined));
|
|
254
|
+
// The journal records the cancel, so no later open of it resumes. A journal the driver refuses (a binding that
|
|
255
|
+
// moved, a checkpoint it cannot read) is left as it is; the task still ends aborted.
|
|
256
|
+
try {
|
|
257
|
+
const driver = await openDriver(task, runtime, context, "abort");
|
|
258
|
+
driver.stop({ action: "cancel", ...(source ? { source } : {}) });
|
|
259
|
+
await driver.close();
|
|
260
|
+
}
|
|
261
|
+
catch { /* the outcome below is committed whatever the journal said */ }
|
|
262
|
+
await runtime.commit(() => ({ status: "terminal", outcome: cancelled(source) }), context);
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const outcomeOf = async (harness, settled, key, context) => {
|
|
266
|
+
if (settled.state.status !== "terminal")
|
|
267
|
+
return undefined;
|
|
268
|
+
const outcome = settled.state.outcome;
|
|
269
|
+
if (outcome.status === "completed")
|
|
270
|
+
return outcome.result;
|
|
271
|
+
if (outcome.status === "aborted") {
|
|
272
|
+
const source = cancelSource(await harness.snapshot(RunDoc, key, context));
|
|
273
|
+
return { status: "stopped", stop: "cancelled", ...(source ? { source } : {}) };
|
|
274
|
+
}
|
|
275
|
+
const error = outcome.error;
|
|
276
|
+
return { status: "refused", code: error?.detail?.code ?? String(outcome.status).toUpperCase(), message: error?.message ?? String(outcome.reason ?? outcome.status), ...(error?.detail ? { detail: error.detail } : {}) };
|
|
277
|
+
};
|
|
278
|
+
const handle = (harness, key, taskId, started) => ({
|
|
279
|
+
key, taskId, started,
|
|
280
|
+
wait: async (context) => {
|
|
281
|
+
const failure = uncommittedOf(key, taskId);
|
|
282
|
+
failure.waits += 1;
|
|
283
|
+
try {
|
|
284
|
+
const settled = await Promise.race([harness.waitForTask(taskId, context), failure.promise]);
|
|
285
|
+
return (await outcomeOf(harness, settled, key, context));
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
// A Session that failed at a storage error rejects its waits with `SessionFailed` (pi-durable 1.2): the
|
|
289
|
+
// waiters get the storage's error, as 1.1 gave it.
|
|
290
|
+
throw storageError(error);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
// The last wait to end releases the entry, whether the task ended or the wait did; a failure keeps it.
|
|
294
|
+
failure.waits -= 1;
|
|
295
|
+
const id = uncommittedId(key, taskId);
|
|
296
|
+
if (failure.waits === 0 && !failure.failed && uncommitted.get(id) === failure)
|
|
297
|
+
uncommitted.delete(id);
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
extension: defineExtension({ name: "agentrun.workflow", tasks: [Workflow] }),
|
|
303
|
+
/** Start the run `request.key` names, or reach the task that already runs it. The run's entry and its task are
|
|
304
|
+
* created in one commit, so two starts of one key make one task. A start that reaches a task asks for the run it
|
|
305
|
+
* holds: its workflow, what the host binds and its input are the task's, by canonical digest, so key order does not
|
|
306
|
+
* matter. One that asks for another run is refused with the driver's binding mismatch (`RUN_STORE_BINDING_MISMATCH`,
|
|
307
|
+
* its `moved` naming the workflow's parts, what the host binds, or the input's fields as `input.<field>`) and
|
|
308
|
+
* commits nothing: no task, no stop, no cancel. Another run takes another key. A live task is attached to; a task
|
|
309
|
+
* that ended complete, escalated or cancelled is returned as it is; one that ended refused, failed or faulted is
|
|
310
|
+
* replaced by a new task over the same journal, which refuses again until the host reconciles it. A journal an
|
|
311
|
+
* older build started, with no task, gets one here, and its driver checks the binding as it opens. A stop the host
|
|
312
|
+
* gave before the run started (`options.stop`) is recorded in the commit that creates or reaches the task, and is
|
|
313
|
+
* taken before anything runs. A cancel aborts the task before the scheduler runs it, so its abort handler commits
|
|
314
|
+
* it; one a process recorded and died before pi marked the task is taken by the run's next invocation, before any
|
|
315
|
+
* step and before the host's ladder. A pause is handed to the driver by the run's phase as it opens, before any
|
|
316
|
+
* step. So a pause given with a start that attaches to a run already running in this process is not taken now: it
|
|
317
|
+
* is recorded, and taken when the run's next invocation opens. `stop` pauses a run that is running here. */
|
|
318
|
+
async start(harness, request, context, options = {}) {
|
|
319
|
+
const conversationId = options.conversationId ?? (await harness.root(context)).id;
|
|
320
|
+
const stop = options.stop ? { action: options.stop.action, source: options.stop.source ?? null } : null;
|
|
321
|
+
const asked = json(request);
|
|
322
|
+
const [taskId, started] = await harness.commit(async (tx) => {
|
|
323
|
+
const run = await tx.doc(RunDoc, request.key, null);
|
|
324
|
+
if (run.task !== null) {
|
|
325
|
+
const prior = await tx.task(run.task);
|
|
326
|
+
// A request for another run than the task holds is refused, and the throw discards this commit.
|
|
327
|
+
const moved = prior && requestMoved(prior.input, asked);
|
|
328
|
+
if (moved)
|
|
329
|
+
throw moved;
|
|
330
|
+
if (prior && prior.state.status !== "terminal") {
|
|
331
|
+
if (stop)
|
|
332
|
+
recordStop(run, stop);
|
|
333
|
+
return [run.task, "attached"];
|
|
334
|
+
}
|
|
335
|
+
const kept = prior?.state.status === "terminal" && (prior.state.outcome.status === "completed" || prior.state.outcome.status === "aborted");
|
|
336
|
+
if (kept)
|
|
337
|
+
return [run.task, "settled"];
|
|
338
|
+
}
|
|
339
|
+
const id = await tx.createTask(Workflow, asked, { ownership: { kind: "conversation" }, conversationId: conversationId, ...(options.background ? { background: true } : {}) });
|
|
340
|
+
run.task = id;
|
|
341
|
+
run.stop = stop;
|
|
342
|
+
return [id, "created"];
|
|
343
|
+
}, context);
|
|
344
|
+
// A failure an earlier invocation of this task left in this process is not this one's; one still awaited is kept,
|
|
345
|
+
// so the waiters it has and the invocation that may yet fail share it.
|
|
346
|
+
const earlier = uncommittedId(request.key, taskId);
|
|
347
|
+
if (uncommitted.get(earlier)?.failed)
|
|
348
|
+
uncommitted.delete(earlier);
|
|
349
|
+
if (stop?.action === "cancel" && started !== "settled")
|
|
350
|
+
await harness.abortTask(taskId, context);
|
|
351
|
+
harness.resume();
|
|
352
|
+
return handle(harness, request.key, taskId, started);
|
|
353
|
+
},
|
|
354
|
+
/** The run's task and, once it is terminal, its outcome; undefined when no start named the key. */
|
|
355
|
+
async read(harness, key, context) {
|
|
356
|
+
const run = await harness.snapshot(RunDoc, key, context);
|
|
357
|
+
if (!run || run.task === null)
|
|
358
|
+
return undefined;
|
|
359
|
+
const record = await harness.getTask(run.task, context);
|
|
360
|
+
if (!record)
|
|
361
|
+
return undefined;
|
|
362
|
+
const outcome = await outcomeOf(harness, record, key, context);
|
|
363
|
+
return { taskId: record.id, status: record.state.status, ...(outcome ? { outcome } : {}) };
|
|
364
|
+
},
|
|
365
|
+
/** Pause or cancel the run. A pause resolves once the journal holds it (an effect in flight completes and commits
|
|
366
|
+
* first) or the run ended; the host then closes the Harness, and the task stays pending. A cancel resolves when pi
|
|
367
|
+
* has marked the task, before it is terminal; `wait` gives the aborted outcome. */
|
|
368
|
+
async stop(harness, key, action, context, source) {
|
|
369
|
+
if (action === "pause") {
|
|
370
|
+
const running = live.get(key);
|
|
371
|
+
if (!running)
|
|
372
|
+
return "not-live";
|
|
373
|
+
running.driver.stop({ action: "pause", ...(source ? { source } : {}) });
|
|
374
|
+
// The run is paused once its work has stopped, not when the journal first holds the pause: a continuation the
|
|
375
|
+
// host's ladder runs stops through the run's signal and may take a while, and the host lets go of the run only
|
|
376
|
+
// after it has. Its driver's commits have landed by then.
|
|
377
|
+
await running.ended;
|
|
378
|
+
await running.driver.flush().catch(() => undefined);
|
|
379
|
+
return running.driver.signal.aborted ? "paused" : "ended";
|
|
380
|
+
}
|
|
381
|
+
const taskId = await harness.commit(async (tx) => {
|
|
382
|
+
const run = await tx.doc(RunDoc, key, null);
|
|
383
|
+
if (run.task === null)
|
|
384
|
+
return null;
|
|
385
|
+
// A finished run keeps what ended it, and the first cancel of a live one names its source.
|
|
386
|
+
if ((await tx.task(run.task))?.state.status !== "terminal")
|
|
387
|
+
recordStop(run, { action: "cancel", source: source ?? null });
|
|
388
|
+
return run.task;
|
|
389
|
+
}, context);
|
|
390
|
+
if (taskId === null)
|
|
391
|
+
return "not-live";
|
|
392
|
+
return harness.abortTask(taskId, context);
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { types as utilTypes } from 'node:util';
|
|
2
2
|
import { JevError, isJevResponseReason } from '@parcha/agentrun-jev';
|
|
3
|
-
import {
|
|
3
|
+
import { RecoveryError } from '@parcha/agentrun-dsl/recovery';
|
|
4
|
+
import { PiRunError } from './run-error.js';
|
|
4
5
|
import { ToolInputValidationError } from './tool-input-error.js';
|
|
5
6
|
import { inspectWorkflow, runWorkflow, validateWorkflow, candidatePolicyErrors, EffectOutcomeUnknownError, WorkflowInvalidError, WorkflowInputInvalidError, WorkflowOutputInvalidError, WorkflowCodeError, WorkflowStateError, EffectDeadlineExceededError, } from '@parcha/agentrun-dsl';
|
|
6
7
|
export class ExtensionServiceError extends Error {
|
|
@@ -93,7 +94,18 @@ async function withAdapterDiagnostic(stage, run) {
|
|
|
93
94
|
throw error;
|
|
94
95
|
}
|
|
95
96
|
}
|
|
97
|
+
/** The code of the first RecoveryError in an error's cause chain. */
|
|
98
|
+
function recoveryCode(error) {
|
|
99
|
+
for (let current = error, depth = 0; current && depth < 16; current = current.cause, depth += 1) {
|
|
100
|
+
if (current instanceof RecoveryError)
|
|
101
|
+
return current.code;
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
96
105
|
function localDiagnostic(error) {
|
|
106
|
+
if (recoveryCode(error) === 'FROZEN_EFFECT_UNKNOWN')
|
|
107
|
+
return { code: 'FROZEN_EFFECT_UNKNOWN',
|
|
108
|
+
message: 'A tool call of this run has no recorded outcome. It is not repeated. Find out whether it happened, then start a new run (change the input) or reconcile it in the run journal.' };
|
|
97
109
|
if (error instanceof AggregateError)
|
|
98
110
|
return error.errors.slice(0, 100).map(localDiagnostic).find(Boolean);
|
|
99
111
|
if (error instanceof AdapterDiagnosticError)
|
|
@@ -444,6 +456,7 @@ export class WorkflowExtensionService {
|
|
|
444
456
|
...(options.deps.skill ? { skill: options.deps.skill } : {}),
|
|
445
457
|
...(sections.length ? { sop: sections.map(([name, text]) => `## ${name}\n${text}`).join('\n\n') } : options.deps.sop ? { sop: options.deps.sop } : {}),
|
|
446
458
|
// Forward any set limit, so the interpreter rejects an invalid one rather than running without it.
|
|
459
|
+
...(options.deps.recovery ? { recovery: options.deps.recovery } : {}),
|
|
447
460
|
...(options.deps.maxQuestionsPerRequest !== undefined ? { maxQuestionsPerRequest: options.deps.maxQuestionsPerRequest } : {}),
|
|
448
461
|
...(options.deps.maxStateBytesPerRequest !== undefined ? { maxStateBytesPerRequest: options.deps.maxStateBytesPerRequest } : {}),
|
|
449
462
|
...(options.deps.runNode ? { runNode: params => dispatch('agent', params.signal, () => withAdapterDiagnostic(params.label, () => options.deps.runNode({ ...params, tools: params.tools ?? [...this.allowedTools] })), true) } : {}),
|
package/dist/extension.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface AgentRunExtensionOptions {
|
|
|
34
34
|
onWorkflowEvent?: (frame: AgentRunWorkflowEvent) => unknown;
|
|
35
35
|
/** Explicit null disables an execution limit; omitted fields retain finite defaults. */
|
|
36
36
|
runtimeLimits?: Partial<AgentRunRuntimeLimits>;
|
|
37
|
+
/** The host's SOP text for workflows that name `sopSection`s. It outranks a file the operator supplies with /agentrun sop. */
|
|
38
|
+
sop?: string;
|
|
37
39
|
}
|
|
38
40
|
/** Configure the native extension without granting configuration to workflow authors. */
|
|
39
41
|
export declare function createAgentRunExtension(options?: AgentRunExtensionOptions): (pi: ExtensionAPI) => void;
|