@dynamicagents/core 0.9.0
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/LICENSE +201 -0
- package/README.md +522 -0
- package/dist/a2a/agent-stub.d.ts +60 -0
- package/dist/a2a/agent-stub.js +1 -0
- package/dist/a2a/caller-token.d.ts +44 -0
- package/dist/a2a/caller-token.js +61 -0
- package/dist/a2a/caller.d.ts +23 -0
- package/dist/a2a/caller.js +33 -0
- package/dist/a2a/card.d.ts +158 -0
- package/dist/a2a/card.js +163 -0
- package/dist/a2a/context.d.ts +19 -0
- package/dist/a2a/context.js +61 -0
- package/dist/a2a/deliver.d.ts +159 -0
- package/dist/a2a/deliver.js +185 -0
- package/dist/a2a/executor.d.ts +84 -0
- package/dist/a2a/executor.js +106 -0
- package/dist/a2a/index.d.ts +35 -0
- package/dist/a2a/index.js +34 -0
- package/dist/a2a/notify.d.ts +106 -0
- package/dist/a2a/notify.js +180 -0
- package/dist/a2a/parts.d.ts +45 -0
- package/dist/a2a/parts.js +94 -0
- package/dist/a2a/push.d.ts +70 -0
- package/dist/a2a/push.js +53 -0
- package/dist/a2a/self-origin.d.ts +91 -0
- package/dist/a2a/self-origin.js +114 -0
- package/dist/a2a/task-store.d.ts +44 -0
- package/dist/a2a/task-store.js +99 -0
- package/dist/a2a/task.d.ts +85 -0
- package/dist/a2a/task.js +15 -0
- package/dist/a2a/verify.d.ts +80 -0
- package/dist/a2a/verify.js +143 -0
- package/dist/agent/budget.d.ts +46 -0
- package/dist/agent/budget.js +42 -0
- package/dist/agent/control.d.ts +109 -0
- package/dist/agent/control.js +115 -0
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +49 -0
- package/dist/agent/final-reply.js +68 -0
- package/dist/agent/history.d.ts +97 -0
- package/dist/agent/history.js +133 -0
- package/dist/agent/index.d.ts +29 -0
- package/dist/agent/index.js +29 -0
- package/dist/agent/inference.d.ts +110 -0
- package/dist/agent/inference.js +120 -0
- package/dist/agent/model.d.ts +90 -0
- package/dist/agent/model.js +1 -0
- package/dist/agent/session.d.ts +100 -0
- package/dist/agent/session.js +82 -0
- package/dist/agent/workers-ai/index.d.ts +23 -0
- package/dist/agent/workers-ai/index.js +23 -0
- package/dist/agent/workers-ai/runtime.d.ts +42 -0
- package/dist/agent/workers-ai/runtime.js +63 -0
- package/dist/alarm/index.d.ts +77 -0
- package/dist/alarm/index.js +116 -0
- package/dist/config.d.ts +202 -0
- package/dist/config.js +135 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +8 -0
- package/dist/contract/plugin.d.ts +324 -0
- package/dist/contract/plugin.js +114 -0
- package/dist/contract/recipe.d.ts +180 -0
- package/dist/contract/recipe.js +1 -0
- package/dist/contract/validation.d.ts +91 -0
- package/dist/contract/validation.js +84 -0
- package/dist/db/db.d.ts +147 -0
- package/dist/db/db.js +90 -0
- package/dist/db/index.d.ts +8 -0
- package/dist/db/index.js +8 -0
- package/dist/db/migrations/index.d.ts +20 -0
- package/dist/db/migrations/index.js +60 -0
- package/dist/db/models/subtasks.d.ts +100 -0
- package/dist/db/models/subtasks.js +241 -0
- package/dist/db/models/tasks.d.ts +118 -0
- package/dist/db/models/tasks.js +274 -0
- package/dist/db/schema.d.ts +468 -0
- package/dist/db/schema.js +88 -0
- package/dist/env.d.ts +53 -0
- package/dist/env.js +47 -0
- package/dist/host/agent.d.ts +305 -0
- package/dist/host/agent.js +400 -0
- package/dist/host/index.d.ts +20 -0
- package/dist/host/index.js +19 -0
- package/dist/host/plugin-host.d.ts +42 -0
- package/dist/host/plugin-host.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/job/index.d.ts +21 -0
- package/dist/job/index.js +21 -0
- package/dist/job/lifecycle.d.ts +176 -0
- package/dist/job/lifecycle.js +230 -0
- package/dist/job/state.d.ts +92 -0
- package/dist/job/state.js +40 -0
- package/dist/platform.d.ts +138 -0
- package/dist/platform.js +140 -0
- package/dist/round/agent.d.ts +271 -0
- package/dist/round/agent.js +678 -0
- package/dist/round/index.d.ts +25 -0
- package/dist/round/index.js +23 -0
- package/dist/round/policy.d.ts +98 -0
- package/dist/round/policy.js +1 -0
- package/dist/round/subagent.d.ts +87 -0
- package/dist/round/subagent.js +108 -0
- package/dist/round/turn.d.ts +249 -0
- package/dist/round/turn.js +564 -0
- package/dist/round/workflow.d.ts +147 -0
- package/dist/round/workflow.js +387 -0
- package/dist/runtime/index.d.ts +127 -0
- package/dist/runtime/index.js +186 -0
- package/dist/runtime/tool-families.d.ts +32 -0
- package/dist/runtime/tool-families.js +61 -0
- package/dist/subagent/fingerprint.d.ts +37 -0
- package/dist/subagent/fingerprint.js +92 -0
- package/dist/subagent/index.d.ts +169 -0
- package/dist/subagent/index.js +330 -0
- package/dist/subagent/prompt.d.ts +32 -0
- package/dist/subagent/prompt.js +37 -0
- package/dist/subagent/run.d.ts +157 -0
- package/dist/subagent/run.js +540 -0
- package/dist/subagent/workspace.d.ts +85 -0
- package/dist/subagent/workspace.js +127 -0
- package/dist/subtasks/catalog.d.ts +40 -0
- package/dist/subtasks/catalog.js +36 -0
- package/dist/subtasks/decomposition.d.ts +85 -0
- package/dist/subtasks/decomposition.js +156 -0
- package/dist/subtasks/delegate.d.ts +120 -0
- package/dist/subtasks/delegate.js +131 -0
- package/dist/subtasks/index.d.ts +9 -0
- package/dist/subtasks/index.js +9 -0
- package/dist/subtasks/subtask-types.d.ts +91 -0
- package/dist/subtasks/subtask-types.js +103 -0
- package/dist/subtasks/types.d.ts +295 -0
- package/dist/subtasks/types.js +15 -0
- package/dist/testing/auth.d.ts +34 -0
- package/dist/testing/auth.js +35 -0
- package/dist/testing/do.d.ts +29 -0
- package/dist/testing/do.js +25 -0
- package/dist/testing/fake-session.d.ts +26 -0
- package/dist/testing/fake-session.js +37 -0
- package/dist/testing/fixtures.d.ts +64 -0
- package/dist/testing/fixtures.js +104 -0
- package/dist/testing/harness.d.ts +97 -0
- package/dist/testing/harness.js +138 -0
- package/dist/testing/index.d.ts +31 -0
- package/dist/testing/index.js +35 -0
- package/dist/testing/mock-model.d.ts +77 -0
- package/dist/testing/mock-model.js +136 -0
- package/dist/testing/node.d.ts +56 -0
- package/dist/testing/node.js +56 -0
- package/dist/testing/vcr-global-setup.d.ts +12 -0
- package/dist/testing/vcr-global-setup.js +15 -0
- package/dist/testing/vcr-shared.d.ts +38 -0
- package/dist/testing/vcr-shared.js +33 -0
- package/dist/testing/vcr-spec.d.ts +25 -0
- package/dist/testing/vcr-spec.js +124 -0
- package/dist/testing/vcr-store.d.ts +86 -0
- package/dist/testing/vcr-store.js +191 -0
- package/dist/testing/vcr.d.ts +117 -0
- package/dist/testing/vcr.js +275 -0
- package/dist/worker/define-agent.d.ts +123 -0
- package/dist/worker/define-agent.js +20 -0
- package/dist/worker/index.d.ts +218 -0
- package/dist/worker/index.js +369 -0
- package/eslint-rules/index.js +31 -0
- package/eslint-rules/no-deprecated-object-properties.js +81 -0
- package/package.json +178 -0
- package/scripts/generate-keys.mjs +48 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { WakeMap } from "../alarm/index.js";
|
|
2
|
+
import { type JobState, type RunningJob } from "./state.js";
|
|
3
|
+
/**
|
|
4
|
+
* The choreography around a long job a Durable Object owns through its alarm.
|
|
5
|
+
*
|
|
6
|
+
* The job itself — what command, where, and what its output means — belongs to
|
|
7
|
+
* the owner. What lives here is the part that is the same every time and is
|
|
8
|
+
* wrong in the same four ways every time:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Arming writes `running` before anything runs.** The alarm has not fired
|
|
11
|
+
* yet, and a `done` record in that window lets a gated caller through against
|
|
12
|
+
* a workspace that is not ready. Writing `running` first also makes arming
|
|
13
|
+
* self-limiting: the next call sees it and stops.
|
|
14
|
+
* 2. **One job at a time**, guarded by a read that goes *through* the staleness
|
|
15
|
+
* bound — so a `running` record left by a dead isolate resolves rather than
|
|
16
|
+
* blocking every retry forever.
|
|
17
|
+
* 3. **A drain can outlive the job it watched.** `ctx.waitUntil` keeps running
|
|
18
|
+
* after the RPC returns, and a late drain writing its verdict over a record
|
|
19
|
+
* describing a *live* job is silent corruption. {@link generation} is the
|
|
20
|
+
* marker that makes it harmless.
|
|
21
|
+
* 4. **Nobody may be draining at all.** A watch intent re-attaches to a job
|
|
22
|
+
* whose isolate went away mid-flight.
|
|
23
|
+
*
|
|
24
|
+
* What is deliberately *not* here is the drain loop. Two real consumers want
|
|
25
|
+
* different ones — an install runs to completion under `waitUntil` and writes a
|
|
26
|
+
* single verdict; a coding-agent run is drained in bounded windows and reports
|
|
27
|
+
* partial progress between them. They share the four rules above and nothing
|
|
28
|
+
* below them, so the loop stays with the owner.
|
|
29
|
+
*
|
|
30
|
+
* ## Storage keys
|
|
31
|
+
*
|
|
32
|
+
* Derived from {@link JobLifecycleOptions.id} so one object can own several
|
|
33
|
+
* jobs. For `id: "install"` they come out as `install`, `install:armed`,
|
|
34
|
+
* `install:last-armed`, `install:context`, and the wake intents `install-run`
|
|
35
|
+
* and `install-watch` — the exact keys the predecessor wrote by hand, which is
|
|
36
|
+
* why adopting this needs no storage migration.
|
|
37
|
+
*/
|
|
38
|
+
/** What a job's result looks like to the lifecycle. Deliberately minimal. */
|
|
39
|
+
export interface JobResult {
|
|
40
|
+
exitCode: number;
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
}
|
|
44
|
+
/** A running command, reduced to what the lifecycle needs of it. */
|
|
45
|
+
export interface JobHandle {
|
|
46
|
+
result(): Promise<JobResult>;
|
|
47
|
+
[Symbol.dispose](): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The per-job record naming what is running and *which* run it is.
|
|
51
|
+
*
|
|
52
|
+
* `startedAt` is the generation marker, so it is the one required field: a drain
|
|
53
|
+
* compares the stamp it captured against the stamp on disk, and a mismatch means
|
|
54
|
+
* it has been superseded and has nothing useful left to say.
|
|
55
|
+
*/
|
|
56
|
+
export interface JobContext {
|
|
57
|
+
startedAt: number;
|
|
58
|
+
}
|
|
59
|
+
export interface JobLifecycleOptions {
|
|
60
|
+
/** Namespaces every key and intent. Also the state record's own key. */
|
|
61
|
+
id: string;
|
|
62
|
+
storage: DurableObjectStorage;
|
|
63
|
+
wake: WakeMap;
|
|
64
|
+
/**
|
|
65
|
+
* How long a `running` record may stand before it is presumed dead.
|
|
66
|
+
*
|
|
67
|
+
* Measured from `startedAt` and compared against the job's own timeout plus
|
|
68
|
+
* this, never against this alone — the point is to outlast a job that is
|
|
69
|
+
* merely slow, and only then to declare one that is gone.
|
|
70
|
+
*/
|
|
71
|
+
staleMs?: number;
|
|
72
|
+
/** How often the watch intent re-checks a job nobody is draining. */
|
|
73
|
+
watchMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* The floor between two arming attempts.
|
|
76
|
+
*
|
|
77
|
+
* Without it a job that cannot start re-arms on every call into the object.
|
|
78
|
+
*/
|
|
79
|
+
armCooldownMs?: number;
|
|
80
|
+
}
|
|
81
|
+
export declare class JobLifecycle<TExtra extends object = Record<never, never>, TContext extends JobContext = JobContext> {
|
|
82
|
+
#private;
|
|
83
|
+
/** `install` — the state record. */
|
|
84
|
+
readonly stateKey: string;
|
|
85
|
+
/** `install:armed` — the stamp the arming path wrote, for the alarm to match. */
|
|
86
|
+
readonly armedKey: string;
|
|
87
|
+
/** `install:last-armed` — the cooldown floor. */
|
|
88
|
+
readonly lastArmedKey: string;
|
|
89
|
+
/** `install:context` — where the generation marker lives. */
|
|
90
|
+
readonly contextKey: string;
|
|
91
|
+
/** `install-run` — the intent that *runs* a job. */
|
|
92
|
+
readonly runIntent: string;
|
|
93
|
+
/** `install-watch` — the intent that re-attaches to one nobody is draining. */
|
|
94
|
+
readonly watchIntent: string;
|
|
95
|
+
constructor(options: JobLifecycleOptions);
|
|
96
|
+
/** The raw record, with no staleness repair. `idle` when nothing is written. */
|
|
97
|
+
read(): Promise<JobState<TExtra>>;
|
|
98
|
+
write(state: JobState<TExtra>): Promise<void>;
|
|
99
|
+
context(): Promise<TContext | undefined>;
|
|
100
|
+
/**
|
|
101
|
+
* Record which run this is, **before** spawning.
|
|
102
|
+
*
|
|
103
|
+
* The order is the whole point: a drain captures `startedAt` after the spawn,
|
|
104
|
+
* so a context written afterwards would let two runs share a generation.
|
|
105
|
+
*/
|
|
106
|
+
putContext(context: TContext): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Hand a cold job to the alarm, if one is not already pending.
|
|
109
|
+
*
|
|
110
|
+
* Returns the stamp it armed with, or `undefined` when it declined — the
|
|
111
|
+
* caller needs the stamp because it is what the alarm must present to
|
|
112
|
+
* {@link claim} to get past the single-flight guard.
|
|
113
|
+
*
|
|
114
|
+
* An arming caller must **not** own the run. The predecessor handed one to
|
|
115
|
+
* `ctx.waitUntil` from a gate poll that returned in milliseconds, and the
|
|
116
|
+
* drain was disposed underneath it mid-command. An alarm invocation belongs to
|
|
117
|
+
* the object rather than to any request, so nothing it awaits can be cut short.
|
|
118
|
+
*/
|
|
119
|
+
arm(placeholder: Omit<RunningJob<TExtra>, "state" | "startedAt">): Promise<number | undefined>;
|
|
120
|
+
/** The stamp {@link arm} wrote, so the alarm can recognise its own placeholder. */
|
|
121
|
+
armedAt(): Promise<number | undefined>;
|
|
122
|
+
clearArmed(): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether a new run may start.
|
|
125
|
+
*
|
|
126
|
+
* `takeOverArmedAt` is the one exemption and it is narrow on purpose. The
|
|
127
|
+
* alarm's placeholder *is* a `running` record for a job that has not started,
|
|
128
|
+
* so the alarm has to pass its own guard — and only its own. Matching the
|
|
129
|
+
* exact stamp it wrote is what stops this becoming "take over any running
|
|
130
|
+
* job", which is the displacement bug the guard exists to prevent: three
|
|
131
|
+
* callers spawning under one exec id in fifty seconds, each displacing the
|
|
132
|
+
* last, every displaced drain still attached and still writing verdicts.
|
|
133
|
+
*
|
|
134
|
+
* Applies the staleness bound **itself**, rather than trusting the caller to
|
|
135
|
+
* have repaired the record first. An earlier draft took an
|
|
136
|
+
* "already-repaired" state and said so in prose, which enforced nothing: the
|
|
137
|
+
* repaired and raw types are identical, so a caller passing a raw read got a
|
|
138
|
+
* `running` record that could never be claimed and a job wedged forever.
|
|
139
|
+
* `timeoutMs` is the job's own budget; see {@link isStale}.
|
|
140
|
+
*/
|
|
141
|
+
claim(state: JobState<TExtra>, timeoutMs: number, takeOverArmedAt?: number): {
|
|
142
|
+
ok: true;
|
|
143
|
+
} | {
|
|
144
|
+
ok: false;
|
|
145
|
+
current: RunningJob<TExtra>;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Whether a `running` record has stood long enough to be presumed dead.
|
|
149
|
+
*
|
|
150
|
+
* `timeoutMs` is the job's own budget; the bound is that plus `staleMs`, so a
|
|
151
|
+
* job that is merely slow is never declared gone.
|
|
152
|
+
*/
|
|
153
|
+
isStale(state: RunningJob<TExtra>, timeoutMs: number, now?: number): boolean;
|
|
154
|
+
/** Arm the watchdog that re-attaches to a job nobody is draining. */
|
|
155
|
+
armWatch(now?: number): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Disarm the watchdog.
|
|
158
|
+
*
|
|
159
|
+
* Never call this from a superseded drain: the watchdog belongs to whichever
|
|
160
|
+
* run owns the record *now*, and clearing it there disarms the one recovery
|
|
161
|
+
* path the live run has.
|
|
162
|
+
*/
|
|
163
|
+
clearWatch(): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* A predicate a drain calls before every write, to ask whether it still owns
|
|
166
|
+
* the record.
|
|
167
|
+
*
|
|
168
|
+
* Captures the stamp once, at drain start, and compares it against disk each
|
|
169
|
+
* time. The closure also latches, so a drain can ask afterwards whether it was
|
|
170
|
+
* superseded — which is what decides if it may touch the watchdog.
|
|
171
|
+
*/
|
|
172
|
+
generation(startedAt: number): {
|
|
173
|
+
stillMine: () => Promise<boolean>;
|
|
174
|
+
superseded: () => boolean;
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { isRearmable } from "./state.js";
|
|
2
|
+
/**
|
|
3
|
+
* `WakeMap`'s own storage row, spelled here rather than imported.
|
|
4
|
+
*
|
|
5
|
+
* Importing `WAKE_KEY` would be a *value* import from `../alarm`, and this
|
|
6
|
+
* module is careful to reach that package only for types — a runtime edge would
|
|
7
|
+
* pull the whole alarm module into any bundle that imports `/job`. So the string
|
|
8
|
+
* is duplicated, and `lifecycle.spec.ts` asserts it still equals `WAKE_KEY`;
|
|
9
|
+
* specs never ship, so the check costs nothing at runtime and fails loudly if
|
|
10
|
+
* the two ever drift.
|
|
11
|
+
*/
|
|
12
|
+
const WAKE_MAP_KEY = "wake";
|
|
13
|
+
const DEFAULT_STALE_MS = 5 * 60_000;
|
|
14
|
+
const DEFAULT_WATCH_MS = 60_000;
|
|
15
|
+
const DEFAULT_ARM_COOLDOWN_MS = 5 * 60_000;
|
|
16
|
+
export class JobLifecycle {
|
|
17
|
+
#o;
|
|
18
|
+
/** `install` — the state record. */
|
|
19
|
+
stateKey;
|
|
20
|
+
/** `install:armed` — the stamp the arming path wrote, for the alarm to match. */
|
|
21
|
+
armedKey;
|
|
22
|
+
/** `install:last-armed` — the cooldown floor. */
|
|
23
|
+
lastArmedKey;
|
|
24
|
+
/** `install:context` — where the generation marker lives. */
|
|
25
|
+
contextKey;
|
|
26
|
+
/** `install-run` — the intent that *runs* a job. */
|
|
27
|
+
runIntent;
|
|
28
|
+
/** `install-watch` — the intent that re-attaches to one nobody is draining. */
|
|
29
|
+
watchIntent;
|
|
30
|
+
constructor(options) {
|
|
31
|
+
/**
|
|
32
|
+
* An id is a storage key, so a bad one is not a bad name — it is a write
|
|
33
|
+
* landing on somebody else's row.
|
|
34
|
+
*
|
|
35
|
+
* `"wake"` is the one that matters and the reason this guard exists: it is
|
|
36
|
+
* `WakeMap`'s single row, so a job with that id would overwrite the whole
|
|
37
|
+
* intent map on its first state write, and the `wake.set()` immediately
|
|
38
|
+
* after would then read job fields as intents. Every pending wake-up on the
|
|
39
|
+
* object — not just this job's — silently stops happening.
|
|
40
|
+
*
|
|
41
|
+
* Empty is rejected for the same reason one level down: it yields the
|
|
42
|
+
* intents `-run` and `-watch`, which two differently-broken callers would
|
|
43
|
+
* share.
|
|
44
|
+
*/
|
|
45
|
+
if (!options.id)
|
|
46
|
+
throw new Error("a job id must be a non-empty string");
|
|
47
|
+
if (options.id === WAKE_MAP_KEY) {
|
|
48
|
+
throw new Error(`"${WAKE_MAP_KEY}" is reserved: it is WakeMap's storage row, and a job ` +
|
|
49
|
+
`with that id would overwrite every pending intent on this object`);
|
|
50
|
+
}
|
|
51
|
+
this.#o = {
|
|
52
|
+
...options,
|
|
53
|
+
staleMs: options.staleMs ?? DEFAULT_STALE_MS,
|
|
54
|
+
watchMs: options.watchMs ?? DEFAULT_WATCH_MS,
|
|
55
|
+
armCooldownMs: options.armCooldownMs ?? DEFAULT_ARM_COOLDOWN_MS
|
|
56
|
+
};
|
|
57
|
+
this.stateKey = options.id;
|
|
58
|
+
this.armedKey = `${options.id}:armed`;
|
|
59
|
+
this.lastArmedKey = `${options.id}:last-armed`;
|
|
60
|
+
this.contextKey = `${options.id}:context`;
|
|
61
|
+
this.runIntent = `${options.id}-run`;
|
|
62
|
+
this.watchIntent = `${options.id}-watch`;
|
|
63
|
+
}
|
|
64
|
+
// --- the record ------------------------------------------------------------
|
|
65
|
+
/** The raw record, with no staleness repair. `idle` when nothing is written. */
|
|
66
|
+
async read() {
|
|
67
|
+
return ((await this.#o.storage.get(this.stateKey)) ??
|
|
68
|
+
{ state: "idle" });
|
|
69
|
+
}
|
|
70
|
+
async write(state) {
|
|
71
|
+
await this.#o.storage.put(this.stateKey, state);
|
|
72
|
+
}
|
|
73
|
+
async context() {
|
|
74
|
+
return await this.#o.storage.get(this.contextKey);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Record which run this is, **before** spawning.
|
|
78
|
+
*
|
|
79
|
+
* The order is the whole point: a drain captures `startedAt` after the spawn,
|
|
80
|
+
* so a context written afterwards would let two runs share a generation.
|
|
81
|
+
*/
|
|
82
|
+
async putContext(context) {
|
|
83
|
+
await this.#o.storage.put(this.contextKey, context);
|
|
84
|
+
}
|
|
85
|
+
// --- arming ----------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Hand a cold job to the alarm, if one is not already pending.
|
|
88
|
+
*
|
|
89
|
+
* Returns the stamp it armed with, or `undefined` when it declined — the
|
|
90
|
+
* caller needs the stamp because it is what the alarm must present to
|
|
91
|
+
* {@link claim} to get past the single-flight guard.
|
|
92
|
+
*
|
|
93
|
+
* An arming caller must **not** own the run. The predecessor handed one to
|
|
94
|
+
* `ctx.waitUntil` from a gate poll that returned in milliseconds, and the
|
|
95
|
+
* drain was disposed underneath it mid-command. An alarm invocation belongs to
|
|
96
|
+
* the object rather than to any request, so nothing it awaits can be cut short.
|
|
97
|
+
*/
|
|
98
|
+
async arm(placeholder) {
|
|
99
|
+
const state = await this.read();
|
|
100
|
+
if (!isRearmable(state))
|
|
101
|
+
return undefined;
|
|
102
|
+
const lastArmed = await this.#o.storage.get(this.lastArmedKey);
|
|
103
|
+
if (lastArmed !== undefined &&
|
|
104
|
+
Date.now() - lastArmed < this.#o.armCooldownMs)
|
|
105
|
+
return undefined;
|
|
106
|
+
const armedAt = Date.now();
|
|
107
|
+
await this.write({
|
|
108
|
+
...placeholder,
|
|
109
|
+
state: "running",
|
|
110
|
+
startedAt: armedAt
|
|
111
|
+
});
|
|
112
|
+
await this.#o.storage.put(this.armedKey, armedAt);
|
|
113
|
+
// Kept even if the scheduling below fails, deliberately: a floor that only
|
|
114
|
+
// applied to *successful* arming would let a persistently failing schedule
|
|
115
|
+
// re-arm on every call into the object, which is what it exists to prevent.
|
|
116
|
+
await this.#o.storage.put(this.lastArmedKey, armedAt);
|
|
117
|
+
/**
|
|
118
|
+
* The placeholder and the alarm that owns it are two writes, and between
|
|
119
|
+
* them is the one window where this can strand a job: a `running` record no
|
|
120
|
+
* run intent points at, which every later {@link arm} then declines to
|
|
121
|
+
* replace *because* it is running.
|
|
122
|
+
*
|
|
123
|
+
* The staleness bound in {@link claim} would eventually free it, but only
|
|
124
|
+
* after a full timeout — so unwind instead, and leave the record exactly as
|
|
125
|
+
* re-armable as it was found.
|
|
126
|
+
*/
|
|
127
|
+
try {
|
|
128
|
+
await this.#o.wake.set({ key: this.runIntent, notBefore: armedAt });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
await this.write(state);
|
|
132
|
+
await this.#o.storage.delete(this.armedKey).catch(() => { });
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
return armedAt;
|
|
136
|
+
}
|
|
137
|
+
/** The stamp {@link arm} wrote, so the alarm can recognise its own placeholder. */
|
|
138
|
+
async armedAt() {
|
|
139
|
+
return await this.#o.storage.get(this.armedKey);
|
|
140
|
+
}
|
|
141
|
+
async clearArmed() {
|
|
142
|
+
await this.#o.storage.delete(this.armedKey);
|
|
143
|
+
}
|
|
144
|
+
// --- the single-flight guard ------------------------------------------------
|
|
145
|
+
/**
|
|
146
|
+
* Decide whether a new run may start.
|
|
147
|
+
*
|
|
148
|
+
* `takeOverArmedAt` is the one exemption and it is narrow on purpose. The
|
|
149
|
+
* alarm's placeholder *is* a `running` record for a job that has not started,
|
|
150
|
+
* so the alarm has to pass its own guard — and only its own. Matching the
|
|
151
|
+
* exact stamp it wrote is what stops this becoming "take over any running
|
|
152
|
+
* job", which is the displacement bug the guard exists to prevent: three
|
|
153
|
+
* callers spawning under one exec id in fifty seconds, each displacing the
|
|
154
|
+
* last, every displaced drain still attached and still writing verdicts.
|
|
155
|
+
*
|
|
156
|
+
* Applies the staleness bound **itself**, rather than trusting the caller to
|
|
157
|
+
* have repaired the record first. An earlier draft took an
|
|
158
|
+
* "already-repaired" state and said so in prose, which enforced nothing: the
|
|
159
|
+
* repaired and raw types are identical, so a caller passing a raw read got a
|
|
160
|
+
* `running` record that could never be claimed and a job wedged forever.
|
|
161
|
+
* `timeoutMs` is the job's own budget; see {@link isStale}.
|
|
162
|
+
*/
|
|
163
|
+
claim(state, timeoutMs, takeOverArmedAt) {
|
|
164
|
+
if (state.state !== "running")
|
|
165
|
+
return { ok: true };
|
|
166
|
+
// The alarm presenting its own placeholder — the one narrow exemption.
|
|
167
|
+
if (state.startedAt === takeOverArmedAt)
|
|
168
|
+
return { ok: true };
|
|
169
|
+
// A record whose isolate is gone must not block every later run.
|
|
170
|
+
if (this.isStale(state, timeoutMs))
|
|
171
|
+
return { ok: true };
|
|
172
|
+
return { ok: false, current: state };
|
|
173
|
+
}
|
|
174
|
+
// --- staleness and re-attach -------------------------------------------------
|
|
175
|
+
/**
|
|
176
|
+
* Whether a `running` record has stood long enough to be presumed dead.
|
|
177
|
+
*
|
|
178
|
+
* `timeoutMs` is the job's own budget; the bound is that plus `staleMs`, so a
|
|
179
|
+
* job that is merely slow is never declared gone.
|
|
180
|
+
*/
|
|
181
|
+
isStale(state, timeoutMs, now = Date.now()) {
|
|
182
|
+
return now - state.startedAt > timeoutMs + this.#o.staleMs;
|
|
183
|
+
}
|
|
184
|
+
/** Arm the watchdog that re-attaches to a job nobody is draining. */
|
|
185
|
+
async armWatch(now = Date.now()) {
|
|
186
|
+
await this.#o.wake.set({
|
|
187
|
+
key: this.watchIntent,
|
|
188
|
+
notBefore: now + this.#o.watchMs
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Disarm the watchdog.
|
|
193
|
+
*
|
|
194
|
+
* Never call this from a superseded drain: the watchdog belongs to whichever
|
|
195
|
+
* run owns the record *now*, and clearing it there disarms the one recovery
|
|
196
|
+
* path the live run has.
|
|
197
|
+
*/
|
|
198
|
+
async clearWatch() {
|
|
199
|
+
await this.#o.wake.clear(this.watchIntent).catch(() => { });
|
|
200
|
+
}
|
|
201
|
+
// --- generation --------------------------------------------------------------
|
|
202
|
+
/**
|
|
203
|
+
* A predicate a drain calls before every write, to ask whether it still owns
|
|
204
|
+
* the record.
|
|
205
|
+
*
|
|
206
|
+
* Captures the stamp once, at drain start, and compares it against disk each
|
|
207
|
+
* time. The closure also latches, so a drain can ask afterwards whether it was
|
|
208
|
+
* superseded — which is what decides if it may touch the watchdog.
|
|
209
|
+
*/
|
|
210
|
+
generation(startedAt) {
|
|
211
|
+
let superseded = false;
|
|
212
|
+
return {
|
|
213
|
+
stillMine: async () => {
|
|
214
|
+
// The latch is checked *before* the read, not after. Ownership is not
|
|
215
|
+
// recoverable: once another run has owned this record, a stamp that
|
|
216
|
+
// happens to match again does not hand it back, and a drain that
|
|
217
|
+
// regained write access here would be the corruption the marker exists
|
|
218
|
+
// to prevent.
|
|
219
|
+
if (superseded)
|
|
220
|
+
return false;
|
|
221
|
+
const now = await this.context();
|
|
222
|
+
if (now?.startedAt === startedAt)
|
|
223
|
+
return true;
|
|
224
|
+
superseded = true;
|
|
225
|
+
return false;
|
|
226
|
+
},
|
|
227
|
+
superseded: () => superseded
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record an alarm-owned job writes about itself, and the shape a gate reads.
|
|
3
|
+
*
|
|
4
|
+
* This is the contract between two halves that do not own each other: the
|
|
5
|
+
* Durable Object *runs* the job, and something else — a shell tool, a subagent
|
|
6
|
+
* executor — refuses to proceed while one is in flight. A shape they agreed on
|
|
7
|
+
* informally would drift, and the drift shows up as a command running halfway
|
|
8
|
+
* through the job it was supposed to wait for.
|
|
9
|
+
*
|
|
10
|
+
* ## Why `TExtra` intersects rather than nests
|
|
11
|
+
*
|
|
12
|
+
* The obvious generic is `{ state: "running"; meta: TExtra }`. It is wrong here,
|
|
13
|
+
* and expensively so: every existing reader spells the job's own field at the
|
|
14
|
+
* top level (`status.command`), so nesting would rewrite every read site and
|
|
15
|
+
* every spec assertion in both consumers to buy nothing. Intersecting keeps
|
|
16
|
+
* `JobState<{ command: string }>` *byte-identical* to the hand-written union it
|
|
17
|
+
* replaces, which is what makes adopting this a type change and not a refactor.
|
|
18
|
+
*
|
|
19
|
+
* The cost of the choice is that `TExtra` must not collide with the field names
|
|
20
|
+
* below. That is a real constraint, and it is why they are named for the
|
|
21
|
+
* mechanism (`startedAt`, `finishedAt`, `exitCode`) rather than for any job.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* A job's durable state.
|
|
25
|
+
*
|
|
26
|
+
* Five variants, and the two that look redundant are not:
|
|
27
|
+
*
|
|
28
|
+
* - `idle` — nothing has ever run. There is no context recording *where* or
|
|
29
|
+
* *what*, so a caller cannot re-drive it; that is the owner's job.
|
|
30
|
+
* - `skipped` — something looked and decided there was nothing to do. Terminal
|
|
31
|
+
* and *correct*, which is why it is not `done`: a gate must not treat a
|
|
32
|
+
* deliberate no-op as a failure to retry, and an arming path must not re-drive
|
|
33
|
+
* it forever.
|
|
34
|
+
* - `running` — in flight, or believed to be. Never trusted without the
|
|
35
|
+
* staleness bound in {@link JobLifecycle.claim}, because the isolate that
|
|
36
|
+
* wrote it may be long gone.
|
|
37
|
+
* - `done` / `failed` — terminal, carrying enough to explain the outcome without
|
|
38
|
+
* the caller reaching for the transcript.
|
|
39
|
+
*/
|
|
40
|
+
/** Nothing has ever run; no context exists naming what would. */
|
|
41
|
+
export type IdleJob = {
|
|
42
|
+
state: "idle";
|
|
43
|
+
};
|
|
44
|
+
/** Something looked and decided there was nothing to do. Terminal and correct. */
|
|
45
|
+
export type SkippedJob = {
|
|
46
|
+
state: "skipped";
|
|
47
|
+
reason: string;
|
|
48
|
+
};
|
|
49
|
+
/** In flight, or believed to be. Never trusted without the staleness bound. */
|
|
50
|
+
export type RunningJob<TExtra = Record<never, never>> = {
|
|
51
|
+
state: "running";
|
|
52
|
+
startedAt: number;
|
|
53
|
+
tail?: string;
|
|
54
|
+
} & TExtra;
|
|
55
|
+
export type DoneJob<TExtra = Record<never, never>> = {
|
|
56
|
+
state: "done";
|
|
57
|
+
exitCode: number;
|
|
58
|
+
finishedAt: number;
|
|
59
|
+
ms: number;
|
|
60
|
+
tail?: string;
|
|
61
|
+
} & TExtra;
|
|
62
|
+
export type FailedJob<TExtra = Record<never, never>> = {
|
|
63
|
+
state: "failed";
|
|
64
|
+
finishedAt: number;
|
|
65
|
+
error: string;
|
|
66
|
+
exitCode?: number;
|
|
67
|
+
tail?: string;
|
|
68
|
+
} & TExtra;
|
|
69
|
+
/**
|
|
70
|
+
* A job's durable state.
|
|
71
|
+
*
|
|
72
|
+
* The variants are named types rather than inlined into the union because
|
|
73
|
+
* `Extract<JobState<TExtra>, { state: "running" }>` cannot narrow while `TExtra`
|
|
74
|
+
* is generic — the compiler has no way to prove `DoneJob & TExtra` does not also
|
|
75
|
+
* carry `state: "running"`. Naming them is what lets a caller say
|
|
76
|
+
* `RunningJob<TExtra>` and get its fields.
|
|
77
|
+
*/
|
|
78
|
+
export type JobState<TExtra = Record<never, never>> = IdleJob | SkippedJob | RunningJob<TExtra> | DoneJob<TExtra> | FailedJob<TExtra>;
|
|
79
|
+
/**
|
|
80
|
+
* Whether a state is one a new run may start from.
|
|
81
|
+
*
|
|
82
|
+
* `done` and `failed` both qualify, and the second was a gap worth closing in
|
|
83
|
+
* the predecessor: arming used to require `done`, so one bad run left a record
|
|
84
|
+
* that declined to re-arm forever — one failure poisoning every task after it.
|
|
85
|
+
*
|
|
86
|
+
* `skipped` and `idle` are excluded for different reasons. `skipped` means the
|
|
87
|
+
* answer is already correct and permanent. `idle` means no context exists naming
|
|
88
|
+
* what to run, so there is nothing to re-drive.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isRearmable<TExtra extends object>(state: JobState<TExtra>): boolean;
|
|
91
|
+
/** Whether a state claims a job is in flight. Never conclusive on its own. */
|
|
92
|
+
export declare function isRunning<TExtra extends object>(state: JobState<TExtra>): state is RunningJob<TExtra>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record an alarm-owned job writes about itself, and the shape a gate reads.
|
|
3
|
+
*
|
|
4
|
+
* This is the contract between two halves that do not own each other: the
|
|
5
|
+
* Durable Object *runs* the job, and something else — a shell tool, a subagent
|
|
6
|
+
* executor — refuses to proceed while one is in flight. A shape they agreed on
|
|
7
|
+
* informally would drift, and the drift shows up as a command running halfway
|
|
8
|
+
* through the job it was supposed to wait for.
|
|
9
|
+
*
|
|
10
|
+
* ## Why `TExtra` intersects rather than nests
|
|
11
|
+
*
|
|
12
|
+
* The obvious generic is `{ state: "running"; meta: TExtra }`. It is wrong here,
|
|
13
|
+
* and expensively so: every existing reader spells the job's own field at the
|
|
14
|
+
* top level (`status.command`), so nesting would rewrite every read site and
|
|
15
|
+
* every spec assertion in both consumers to buy nothing. Intersecting keeps
|
|
16
|
+
* `JobState<{ command: string }>` *byte-identical* to the hand-written union it
|
|
17
|
+
* replaces, which is what makes adopting this a type change and not a refactor.
|
|
18
|
+
*
|
|
19
|
+
* The cost of the choice is that `TExtra` must not collide with the field names
|
|
20
|
+
* below. That is a real constraint, and it is why they are named for the
|
|
21
|
+
* mechanism (`startedAt`, `finishedAt`, `exitCode`) rather than for any job.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Whether a state is one a new run may start from.
|
|
25
|
+
*
|
|
26
|
+
* `done` and `failed` both qualify, and the second was a gap worth closing in
|
|
27
|
+
* the predecessor: arming used to require `done`, so one bad run left a record
|
|
28
|
+
* that declined to re-arm forever — one failure poisoning every task after it.
|
|
29
|
+
*
|
|
30
|
+
* `skipped` and `idle` are excluded for different reasons. `skipped` means the
|
|
31
|
+
* answer is already correct and permanent. `idle` means no context exists naming
|
|
32
|
+
* what to run, so there is nothing to re-drive.
|
|
33
|
+
*/
|
|
34
|
+
export function isRearmable(state) {
|
|
35
|
+
return state.state === "done" || state.state === "failed";
|
|
36
|
+
}
|
|
37
|
+
/** Whether a state claims a job is in flight. Never conclusive on its own. */
|
|
38
|
+
export function isRunning(state) {
|
|
39
|
+
return state.state === "running";
|
|
40
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { WorkflowStepConfig } from "cloudflare:workers";
|
|
2
|
+
/**
|
|
3
|
+
* What the Cloudflare Workflows runtime imposes, and the two numbers derived from
|
|
4
|
+
* it. Nothing here is a budget or a preference — see {@link file://./config.ts}
|
|
5
|
+
* for those, and note that no Recipe can reach these. They change when the
|
|
6
|
+
* platform changes, and for no other reason.
|
|
7
|
+
*
|
|
8
|
+
* The distinction is worth keeping sharp, because collapsing it produces a
|
|
9
|
+
* specific bug: using a *turn count* to keep a step under the step timeout only
|
|
10
|
+
* works if you can predict how long a turn takes. You cannot. Time bounds time
|
|
11
|
+
* here; turns bound cost, over in `config.ts`.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The step timeout **we configure**, passed as `WorkflowStepConfig.timeout` on
|
|
15
|
+
* every step that can hold a model call or a container command.
|
|
16
|
+
*
|
|
17
|
+
* This is the one value in this file that is **not** a platform fact, and
|
|
18
|
+
* treating it as one is the mistake to avoid: ten minutes is Workflows'
|
|
19
|
+
* *default* step timeout, not its ceiling, and a step that never passes a config
|
|
20
|
+
* inherits it silently. Sizing {@link CHUNK_SOFT_MS} against that inherited
|
|
21
|
+
* default is what once ran a production task as ten four-minute slices, one of
|
|
22
|
+
* which blew the ten minutes anyway and was retried whole.
|
|
23
|
+
*
|
|
24
|
+
* The genuine platform facts are narrower and do not bind us: wall-clock time per
|
|
25
|
+
* step is effectively unlimited, and a step is bounded by **CPU** time. Measured
|
|
26
|
+
* over a 59-minute task, the chunk steps used ~100 ms of CPU against 3,146 s of
|
|
27
|
+
* wall — this ceiling is nowhere near the real one.
|
|
28
|
+
*
|
|
29
|
+
* It is still a ceiling worth having, because it is what turns a hung container
|
|
30
|
+
* into a retry rather than a task that never ends. {@link CHUNK_SOFT_MS} is sized
|
|
31
|
+
* against it, and that relationship is asserted in `platform.spec.ts`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const STEP_TIMEOUT_MS: number;
|
|
34
|
+
/**
|
|
35
|
+
* Platform fact: a single Workflow instance may run 10,000 steps by default on the
|
|
36
|
+
* paid plan. Cloudflare will raise it to 25,000 on request — worth knowing, and
|
|
37
|
+
* worth not relying on: {@link MAX_CHUNKS_PER_BRANCH} is sized against the default
|
|
38
|
+
* so nothing here needs an account-level exception to be correct. See the
|
|
39
|
+
* worst-case product asserted in `platform.spec.ts`.
|
|
40
|
+
*/
|
|
41
|
+
export declare const STEPS_PER_INSTANCE = 10000;
|
|
42
|
+
/**
|
|
43
|
+
* How long one durable chunk may run before it checkpoints and yields a fresh
|
|
44
|
+
* step. Comfortably inside {@link STEP_TIMEOUT_MS} so a slow model turn in flight
|
|
45
|
+
* when the soft limit trips still has room to finish.
|
|
46
|
+
*
|
|
47
|
+
* This is the *only* thing keeping a step under the timeout. A subagent otherwise
|
|
48
|
+
* runs until its turn or wall-clock budget is spent, however many turns that takes
|
|
49
|
+
* — which is the point: the runner no longer guesses at turn duration.
|
|
50
|
+
*
|
|
51
|
+
* ## Why this is 15 minutes and not 4
|
|
52
|
+
*
|
|
53
|
+
* A chunk boundary is not free. It checkpoints, returns through two RPC hops,
|
|
54
|
+
* starts a fresh step, and re-hydrates the subagent — and for a coding agent it
|
|
55
|
+
* also means the container connection is re-established. Four minutes bought a
|
|
56
|
+
* boundary roughly every third tool call: a task that edited one README line spent
|
|
57
|
+
* 59 minutes across **ten** chunks, and the model was idle for most of each one,
|
|
58
|
+
* blocked on a single `sb_exec` running the project's test gate.
|
|
59
|
+
*
|
|
60
|
+
* ## Why it is not larger, which is the part that bit us
|
|
61
|
+
*
|
|
62
|
+
* This is a **soft** deadline, checked between turns (`stopWhen` in
|
|
63
|
+
* `subagent/run.ts`). A turn that starts one millisecond before it trips still runs
|
|
64
|
+
* to completion, so the real worst case is:
|
|
65
|
+
*
|
|
66
|
+
* chunk wall ≤ CHUNK_SOFT_MS + one whole turn
|
|
67
|
+
*
|
|
68
|
+
* and one turn is a model call plus a tool call. The old pair ignored that: four
|
|
69
|
+
* minutes soft under a ten-minute timeout looked like six minutes of headroom, but
|
|
70
|
+
* a single `sb_exec` may run for {@link MAX_TOOL_CALL_MS}, so a turn could add ten.
|
|
71
|
+
* That is not a hypothetical — it is the `WorkflowTimeoutError` that cost a
|
|
72
|
+
* production task ten minutes and a full chunk replay.
|
|
73
|
+
*
|
|
74
|
+
* So the headroom is sized against a whole turn, not against a guess:
|
|
75
|
+
* `STEP_TIMEOUT_MS - CHUNK_SOFT_MS` is 15 minutes, covering
|
|
76
|
+
* {@link MAX_TOOL_CALL_MS} of tool call plus five minutes for the model call and
|
|
77
|
+
* its provider retries. Asserted in `platform.spec.ts` — raise the step timeout
|
|
78
|
+
* before raising this.
|
|
79
|
+
*/
|
|
80
|
+
export declare const CHUNK_SOFT_MS: number;
|
|
81
|
+
/**
|
|
82
|
+
* The longest a **single tool call** may run, and a contract rather than a
|
|
83
|
+
* mechanism: core has no way to enforce it, because core installs no tools.
|
|
84
|
+
*
|
|
85
|
+
* It exists because {@link CHUNK_SOFT_MS} cannot be reasoned about without it. The
|
|
86
|
+
* soft deadline is checked between turns, so a host that lets one tool block for
|
|
87
|
+
* longer than the headroom under {@link STEP_TIMEOUT_MS} reintroduces exactly the
|
|
88
|
+
* step-timeout kill this pair is sized to prevent — and it reintroduces it
|
|
89
|
+
* invisibly, in a plugin, a long way from this file.
|
|
90
|
+
*
|
|
91
|
+
* A host installing a tool that can block (a shell, a container command, a fetch
|
|
92
|
+
* with no ceiling of its own) must bound it at or below this. See the `timeoutMs`
|
|
93
|
+
* passed to `@dynamicagents/plugins/computer` in starter.
|
|
94
|
+
*/
|
|
95
|
+
export declare const MAX_TOOL_CALL_MS: number;
|
|
96
|
+
/**
|
|
97
|
+
* Hard ceiling on durable chunk steps for one Subtask branch. A backstop, not a
|
|
98
|
+
* budget: the Workflow *fails* a branch that reaches it, so reaching it is a bug.
|
|
99
|
+
* It is held unreachable by two constraints, both asserted in
|
|
100
|
+
* `platform.spec.ts`:
|
|
101
|
+
*
|
|
102
|
+
* 1. It exceeds every Recipe's `maxTurns`. A chunk that yields always advanced at
|
|
103
|
+
* least one turn, so a run takes at most `maxTurns` chunks however short they
|
|
104
|
+
* are — and they do get short, because `CHUNK_SOFT_MS` and progress events both
|
|
105
|
+
* end one early. Counting turns is what makes the bound survive that; any
|
|
106
|
+
* estimate of turns-per-chunk would not, since neither of those two is
|
|
107
|
+
* predictable.
|
|
108
|
+
* 2. The worst-case step product stays under {@link STEPS_PER_INSTANCE}.
|
|
109
|
+
*/
|
|
110
|
+
export declare const MAX_CHUNKS_PER_BRANCH = 40;
|
|
111
|
+
/**
|
|
112
|
+
* What a step holding a model call or a container command configures instead of
|
|
113
|
+
* inheriting Workflows' defaults. Both defaults were measured wrong for this
|
|
114
|
+
* workload.
|
|
115
|
+
*
|
|
116
|
+
* **`timeout`.** The default is ten minutes. A step here holds a model call and
|
|
117
|
+
* its provider retries, or a container command running a project's test suite;
|
|
118
|
+
* neither fits in ten minutes reliably, and neither uses meaningful CPU while it
|
|
119
|
+
* waits. Left inherited, that default silently became the ceiling
|
|
120
|
+
* {@link CHUNK_SOFT_MS} was sized against.
|
|
121
|
+
*
|
|
122
|
+
* **`retries`.** The default is five attempts with exponential backoff from ten
|
|
123
|
+
* seconds. Against a fault that is not transient — a severed Durable Object stub
|
|
124
|
+
* — that produced five failures in under 10ms each, spread across 160 seconds of
|
|
125
|
+
* backoff that bought nothing. Three attempts still cover a genuinely transient
|
|
126
|
+
* fault, since the model call has its own provider-level retry underneath this,
|
|
127
|
+
* and a flat five-second delay stops a fast permanent failure being paid for at
|
|
128
|
+
* exponential rates.
|
|
129
|
+
*
|
|
130
|
+
* Here rather than in `/round` because the agent that most needs it may not be a
|
|
131
|
+
* round agent: a single-inference agent runs one model call in one step and has
|
|
132
|
+
* the same two problems, and importing this from `/round` would put the whole
|
|
133
|
+
* delegation engine in its bundle.
|
|
134
|
+
*
|
|
135
|
+
* For the **short** bookkeeping steps — `working`, `complete`, `notify` and
|
|
136
|
+
* friends — the defaults are fine and a shared config would only hide that.
|
|
137
|
+
*/
|
|
138
|
+
export declare const CHUNK_STEP: WorkflowStepConfig;
|