@relayflows/sdk 2.0.35 → 2.0.36

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.
Files changed (138) hide show
  1. package/dist/adapters/base.d.ts +16 -0
  2. package/dist/adapters/base.d.ts.map +1 -1
  3. package/dist/adapters/claude.d.ts.map +1 -1
  4. package/dist/adapters/claude.js +15 -0
  5. package/dist/adapters/claude.js.map +1 -1
  6. package/dist/adapters/codex.d.ts.map +1 -1
  7. package/dist/adapters/codex.js +4 -0
  8. package/dist/adapters/codex.js.map +1 -1
  9. package/dist/adapters/usage-limit.d.ts +2 -0
  10. package/dist/adapters/usage-limit.d.ts.map +1 -0
  11. package/dist/adapters/usage-limit.js +17 -0
  12. package/dist/adapters/usage-limit.js.map +1 -0
  13. package/dist/authored-activity.d.ts +17 -0
  14. package/dist/authored-activity.d.ts.map +1 -0
  15. package/dist/authored-activity.js +218 -0
  16. package/dist/authored-activity.js.map +1 -0
  17. package/dist/authored-child-run.d.ts +34 -0
  18. package/dist/authored-child-run.d.ts.map +1 -0
  19. package/dist/authored-child-run.js +128 -0
  20. package/dist/authored-child-run.js.map +1 -0
  21. package/dist/authored-flow-error.d.ts +32 -3
  22. package/dist/authored-flow-error.d.ts.map +1 -1
  23. package/dist/authored-flow-error.js +3 -1
  24. package/dist/authored-flow-error.js.map +1 -1
  25. package/dist/authored-flow-executor.d.ts +9 -1
  26. package/dist/authored-flow-executor.d.ts.map +1 -1
  27. package/dist/authored-flow-executor.js +30 -0
  28. package/dist/authored-flow-executor.js.map +1 -1
  29. package/dist/authored-mcp.js +2 -2
  30. package/dist/authored-mcp.js.map +1 -1
  31. package/dist/authored-node-entry.js +3 -1
  32. package/dist/authored-node-entry.js.map +1 -1
  33. package/dist/authored-node-runner.d.ts.map +1 -1
  34. package/dist/authored-node-runner.js +40 -8
  35. package/dist/authored-node-runner.js.map +1 -1
  36. package/dist/authored-plugin-effect.js +2 -2
  37. package/dist/authored-plugin-effect.js.map +1 -1
  38. package/dist/authored-root.d.ts +10 -7
  39. package/dist/authored-root.d.ts.map +1 -1
  40. package/dist/authored-root.js +19 -0
  41. package/dist/authored-root.js.map +1 -1
  42. package/dist/authored-step-output.d.ts +24 -8
  43. package/dist/authored-step-output.d.ts.map +1 -1
  44. package/dist/authored-step-output.js +81 -15
  45. package/dist/authored-step-output.js.map +1 -1
  46. package/dist/authored-subscription-park.d.ts +5 -0
  47. package/dist/authored-subscription-park.d.ts.map +1 -0
  48. package/dist/authored-subscription-park.js +47 -0
  49. package/dist/authored-subscription-park.js.map +1 -0
  50. package/dist/authored-worker-step.d.ts.map +1 -1
  51. package/dist/authored-worker-step.js +7 -2
  52. package/dist/authored-worker-step.js.map +1 -1
  53. package/dist/cli/check-activities.d.ts +8 -0
  54. package/dist/cli/check-activities.d.ts.map +1 -0
  55. package/dist/cli/check-activities.js +155 -0
  56. package/dist/cli/check-activities.js.map +1 -0
  57. package/dist/cli/cli-probe.d.ts.map +1 -1
  58. package/dist/cli/cli-probe.js +14 -2
  59. package/dist/cli/cli-probe.js.map +1 -1
  60. package/dist/cli/direct-run.d.ts.map +1 -1
  61. package/dist/cli/direct-run.js +10 -2
  62. package/dist/cli/direct-run.js.map +1 -1
  63. package/dist/cli/run.d.ts +18 -5
  64. package/dist/cli/run.d.ts.map +1 -1
  65. package/dist/cli/run.js +24 -5
  66. package/dist/cli/run.js.map +1 -1
  67. package/dist/cli/subscription-report.d.ts +5 -0
  68. package/dist/cli/subscription-report.d.ts.map +1 -0
  69. package/dist/cli/subscription-report.js +48 -0
  70. package/dist/cli/subscription-report.js.map +1 -0
  71. package/dist/cli-adapter.d.ts +4 -2
  72. package/dist/cli-adapter.d.ts.map +1 -1
  73. package/dist/cli-adapter.js +4 -0
  74. package/dist/cli-adapter.js.map +1 -1
  75. package/dist/cli.d.ts +1 -1
  76. package/dist/cli.d.ts.map +1 -1
  77. package/dist/cli.js +13 -5
  78. package/dist/cli.js.map +1 -1
  79. package/dist/failure-kinds.d.ts +2 -2
  80. package/dist/failure-kinds.d.ts.map +1 -1
  81. package/dist/failure-kinds.js +2 -0
  82. package/dist/failure-kinds.js.map +1 -1
  83. package/dist/index.d.ts +1 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js.map +1 -1
  86. package/dist/journal-client.d.ts +17 -2
  87. package/dist/journal-client.d.ts.map +1 -1
  88. package/dist/journal-client.js +33 -2
  89. package/dist/journal-client.js.map +1 -1
  90. package/dist/journal-reader.d.ts.map +1 -1
  91. package/dist/journal-reader.js +2 -0
  92. package/dist/journal-reader.js.map +1 -1
  93. package/dist/local-agent.d.ts +4 -1
  94. package/dist/local-agent.d.ts.map +1 -1
  95. package/dist/local-agent.js +14 -4
  96. package/dist/local-agent.js.map +1 -1
  97. package/dist/preflight.d.ts +6 -0
  98. package/dist/preflight.d.ts.map +1 -1
  99. package/dist/preflight.js +36 -10
  100. package/dist/preflight.js.map +1 -1
  101. package/dist/protocol.d.ts +166 -1
  102. package/dist/protocol.d.ts.map +1 -1
  103. package/dist/step-settling.d.ts +11 -0
  104. package/dist/step-settling.d.ts.map +1 -0
  105. package/dist/step-settling.js +22 -0
  106. package/dist/step-settling.js.map +1 -0
  107. package/package.json +2 -2
  108. package/src/adapters/base.ts +19 -0
  109. package/src/adapters/claude.ts +15 -0
  110. package/src/adapters/codex.ts +6 -0
  111. package/src/adapters/usage-limit.ts +17 -0
  112. package/src/authored-activity.ts +251 -0
  113. package/src/authored-child-run.ts +138 -0
  114. package/src/authored-flow-error.ts +33 -0
  115. package/src/authored-flow-executor.ts +40 -0
  116. package/src/authored-mcp.ts +2 -2
  117. package/src/authored-node-entry.ts +3 -1
  118. package/src/authored-node-runner.ts +41 -8
  119. package/src/authored-plugin-effect.ts +2 -2
  120. package/src/authored-root.ts +27 -4
  121. package/src/authored-step-output.ts +88 -15
  122. package/src/authored-subscription-park.ts +46 -0
  123. package/src/authored-worker-step.ts +7 -2
  124. package/src/cli/check-activities.ts +144 -0
  125. package/src/cli/cli-probe.ts +15 -3
  126. package/src/cli/direct-run.ts +9 -0
  127. package/src/cli/run.ts +46 -9
  128. package/src/cli/subscription-report.ts +51 -0
  129. package/src/cli-adapter.ts +8 -2
  130. package/src/cli.ts +13 -6
  131. package/src/failure-kinds.ts +2 -0
  132. package/src/index.ts +6 -0
  133. package/src/journal-client.ts +47 -3
  134. package/src/journal-reader.ts +2 -0
  135. package/src/local-agent.ts +17 -3
  136. package/src/preflight.ts +46 -10
  137. package/src/protocol.ts +129 -0
  138. package/src/step-settling.ts +19 -0
@@ -2,7 +2,9 @@ import type {
2
2
  CliAdapterIdentification,
3
3
  CliInvocation,
4
4
  HeadlessAdapter,
5
+ ModelProbeFailure,
5
6
  } from './base.js';
7
+ import { providerUsageLimited } from './usage-limit.js';
6
8
 
7
9
  const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.';
8
10
 
@@ -30,6 +32,19 @@ export const claudeAdapter: HeadlessAdapter = {
30
32
  };
31
33
  },
32
34
 
35
+ classifyModelProbeFailure(output: string): ModelProbeFailure | undefined {
36
+ // Checked first: an older Claude Code also prints its own
37
+ // `unrecognized_model` catalog warning for a model newer than it, which
38
+ // must not read as the model being unknown to the provider.
39
+ const outdated = /does not support this model; version (\S+) or newer is required/.exec(output);
40
+ if (outdated !== null) return { cause: 'cli_outdated', requiredVersion: outdated[1]! };
41
+ if (providerUsageLimited(output)) return { cause: 'provider_usage_limited' };
42
+ if (/issue with the selected model|not_found_error|may not exist or you may not have access/i.test(output)) {
43
+ return { cause: 'model_unknown' };
44
+ }
45
+ return undefined;
46
+ },
47
+
33
48
  buildAgentInvocation(instruction: string, model?: string): CliInvocation {
34
49
  return {
35
50
  args: [
@@ -2,7 +2,9 @@ import type {
2
2
  CliAdapterIdentification,
3
3
  CliInvocation,
4
4
  HeadlessAdapter,
5
+ ModelProbeFailure,
5
6
  } from './base.js';
7
+ import { providerUsageLimited } from './usage-limit.js';
6
8
 
7
9
  const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.';
8
10
 
@@ -29,6 +31,10 @@ export const codexAdapter: HeadlessAdapter = {
29
31
  };
30
32
  },
31
33
 
34
+ classifyModelProbeFailure(output: string): ModelProbeFailure | undefined {
35
+ return providerUsageLimited(output) ? { cause: 'provider_usage_limited' } : undefined;
36
+ },
37
+
32
38
  buildAgentInvocation(instruction: string, model?: string): CliInvocation {
33
39
  return {
34
40
  args: [
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Provider refusals that mean "this credential is out of capacity right now",
3
+ * not "this model does not exist". Shared by raw provider adapters because the
4
+ * wording overlaps (HTTP 429, `rate_limit_error`, subscription usage windows).
5
+ */
6
+ const USAGE_LIMITED = [
7
+ /usage limit/i,
8
+ /hit your (?:usage )?limit/i,
9
+ /(?:5-hour|weekly|session) limit reached/i,
10
+ /rate_limit_error/,
11
+ /API Error: 429\b/,
12
+ /credit balance is too low/i,
13
+ ];
14
+
15
+ export function providerUsageLimited(output: string): boolean {
16
+ return USAGE_LIMITED.some(pattern => pattern.test(output));
17
+ }
@@ -0,0 +1,251 @@
1
+ import type {
2
+ Activity,
3
+ ActivityDuration,
4
+ ActivityOptions,
5
+ EventFrame,
6
+ TriggerSource,
7
+ WebhookTriggerSource,
8
+ Wake,
9
+ } from '@relayflows/surface';
10
+ import type { SubscriptionNextResult } from './protocol.js';
11
+ import { AuthoredFlowExecutionError, type AuthoredFlowSuspension } from './authored-flow-error.js';
12
+ import { JournalClient } from './journal-client.js';
13
+
14
+ type CloseReason = 'closed' | 'run_completed' | 'canceled';
15
+
16
+ interface OpenActivity {
17
+ readonly activity: Activity;
18
+ close(reason: CloseReason): Promise<void>;
19
+ }
20
+
21
+ /** Owns body-local cursors that must close before the authored root completes. */
22
+ export class AuthoredActivities {
23
+ private readonly activities: OpenActivity[] = [];
24
+
25
+ constructor(
26
+ private readonly journal: JournalClient,
27
+ private readonly runId: string | undefined,
28
+ ) {}
29
+
30
+ open(source: TriggerSource, options: ActivityOptions): Activity {
31
+ if (source?.kind !== 'webhook') {
32
+ throw new AuthoredFlowExecutionError('unsupported_header',
33
+ 'f.on() requires an event source; schedule triggers start runs and cannot be awaited inside a body');
34
+ }
35
+ if (this.runId === undefined) {
36
+ throw new AuthoredFlowExecutionError('journal_protocol_violation', 'f.on() requires a durable authored root run');
37
+ }
38
+ const id = `activity-${this.activities.length + 1}`;
39
+ const activity = new JournalActivity(this.journal, this.runId, id, source, normalizeOptions(options));
40
+ this.activities.push(activity);
41
+ return activity.activity;
42
+ }
43
+
44
+ async closeAll(reason: Exclude<CloseReason, 'closed'>): Promise<void> {
45
+ for (const activity of this.activities) await activity.close(reason);
46
+ }
47
+ }
48
+
49
+ class JournalActivity implements OpenActivity {
50
+ readonly activity: Activity;
51
+ private readonly openPromise: Promise<void>;
52
+ private closed = false;
53
+ private nextSequence = 0;
54
+ private acknowledgeWaitId: string | undefined;
55
+
56
+ constructor(
57
+ private readonly journal: JournalClient,
58
+ private readonly runId: string,
59
+ private readonly subscriptionId: string,
60
+ private readonly source: WebhookTriggerSource,
61
+ private readonly options: NormalizedActivityOptions,
62
+ ) {
63
+ this.activity = Object.freeze({
64
+ next: () => this.next(),
65
+ close: () => this.close('closed'),
66
+ });
67
+ // Opening starts at f.on(), rather than at the first next(), so frames
68
+ // arriving while the body reads state or runs another step are inside the
69
+ // router's binding window. next()/close() await this same handshake.
70
+ this.openPromise = this.openNow();
71
+ void this.openPromise.catch(() => undefined);
72
+ }
73
+
74
+ async close(reason: CloseReason): Promise<void> {
75
+ if (this.closed) return;
76
+ try {
77
+ await this.ensureOpen();
78
+ } catch (error) {
79
+ // Failure cleanup has no active binding to close before activation.
80
+ // Re-throwing this handoff would disguise a real body failure as a
81
+ // normal suspension. Root termination fences any later activation.
82
+ if (reason !== 'closed' && error instanceof AuthoredFlowExecutionError
83
+ && error.code === 'subscription_suspended' && error.suspension?.kind === 'activation') return;
84
+ throw error;
85
+ }
86
+ await this.journal.subscriptionClose({
87
+ run_id: this.runId,
88
+ subscription_id: this.subscriptionId,
89
+ completion_reason: reason,
90
+ });
91
+ this.closed = true;
92
+ }
93
+
94
+ private async next(): Promise<Wake> {
95
+ if (this.closed) throw new AuthoredFlowExecutionError('activity_closed', 'activity is already closed');
96
+ await this.ensureOpen();
97
+ const result = await this.journal.subscriptionNext({
98
+ run_id: this.runId,
99
+ subscription_id: this.subscriptionId,
100
+ sequence: this.nextSequence,
101
+ ...(this.acknowledgeWaitId === undefined ? {} : { acknowledge_wait_id: this.acknowledgeWaitId }),
102
+ });
103
+ if (result.kind === 'suspended') {
104
+ throw suspended({
105
+ kind: 'event_wait', subscriptionId: result.subscription_id,
106
+ stream: result.stream, deadlineAtMs: result.deadline_at_ms,
107
+ }, this.runId);
108
+ }
109
+ const wake = decodeWake(result);
110
+ if (wake.kind === 'deadline' || wake.kind === 'overflow') {
111
+ this.closed = true;
112
+ } else {
113
+ this.acknowledgeWaitId = receiptId(result) ?? `${this.subscriptionId}/next/${this.nextSequence}`;
114
+ this.nextSequence += 1;
115
+ }
116
+ return wake;
117
+ }
118
+
119
+ private async ensureOpen(): Promise<void> {
120
+ await this.openPromise;
121
+ }
122
+
123
+ private async openNow(): Promise<void> {
124
+ const result = await this.journal.subscriptionOpen({
125
+ run_id: this.runId,
126
+ subscription_id: this.subscriptionId,
127
+ event_types: [this.source.name],
128
+ ...(this.source.filter === undefined ? {} : { pattern: this.source.filter }),
129
+ settle_ms: this.options.settleMs,
130
+ idle_ms: this.options.idleMs,
131
+ deadline_ms: this.options.deadlineMs,
132
+ include_self: this.options.includeSelf,
133
+ });
134
+ if (result.state === 'prepared') {
135
+ throw suspended({
136
+ kind: 'activation', subscriptionId: result.subscription_id,
137
+ eventTypes: Object.freeze([...result.event_types]),
138
+ ...(result.pattern === undefined ? {} : { pattern: Object.freeze({ ...result.pattern }) }),
139
+ stream: result.stream, settleMs: result.settle_ms, idleMs: result.idle_ms,
140
+ deadlineAtMs: result.deadline_at_ms, includeSelf: result.include_self,
141
+ }, this.runId);
142
+ }
143
+ }
144
+ }
145
+
146
+ function suspended(value: AuthoredFlowSuspension, runId: string): AuthoredFlowExecutionError {
147
+ return new AuthoredFlowExecutionError(
148
+ 'subscription_suspended',
149
+ `subscription ${value.subscriptionId} is durably suspended for ${value.kind}`,
150
+ undefined,
151
+ runId,
152
+ undefined,
153
+ value,
154
+ );
155
+ }
156
+
157
+ function receiptId(result: SubscriptionNextResult): string | undefined {
158
+ const receipt = (result as { acknowledge_wait_id?: unknown }).acknowledge_wait_id;
159
+ return typeof receipt === 'string' && receipt.length > 0 ? receipt : undefined;
160
+ }
161
+
162
+ interface NormalizedActivityOptions {
163
+ readonly settleMs: number;
164
+ readonly idleMs: number;
165
+ readonly deadlineMs: number;
166
+ readonly includeSelf: boolean;
167
+ }
168
+
169
+ function normalizeOptions(options: ActivityOptions): NormalizedActivityOptions {
170
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
171
+ throw unboundedSubscription('f.on() requires idle and deadline bounds');
172
+ }
173
+ const value = options as unknown as Record<string, unknown>;
174
+ const allowed = new Set(['settle', 'idle', 'deadline', 'includeSelf']);
175
+ for (const key of Object.keys(value)) {
176
+ if (!allowed.has(key)) throw new AuthoredFlowExecutionError('unbounded_subscription', `f.on() has unknown option ${JSON.stringify(key)}`);
177
+ }
178
+ if (!Object.hasOwn(value, 'idle') || !Object.hasOwn(value, 'deadline')) {
179
+ throw unboundedSubscription('f.on() requires both idle and deadline bounds');
180
+ }
181
+ if (value['includeSelf'] !== undefined && typeof value['includeSelf'] !== 'boolean') {
182
+ throw new AuthoredFlowExecutionError('unbounded_subscription', 'f.on() includeSelf must be a boolean');
183
+ }
184
+ return Object.freeze({
185
+ settleMs: parseDuration(value['settle'] as ActivityDuration | undefined, 'settle', true),
186
+ idleMs: parseDuration(value['idle'] as ActivityDuration, 'idle', false),
187
+ deadlineMs: parseDuration(value['deadline'] as ActivityDuration, 'deadline', false),
188
+ includeSelf: value['includeSelf'] === true,
189
+ });
190
+ }
191
+
192
+ function parseDuration(value: ActivityDuration | undefined, field: string, zeroAllowed: boolean): number {
193
+ if (value === undefined && zeroAllowed) return 0;
194
+ let milliseconds: number;
195
+ if (typeof value === 'number') milliseconds = value;
196
+ else if (typeof value === 'string') {
197
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value);
198
+ const units: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
199
+ const unit = match?.[2];
200
+ milliseconds = match === null || unit === undefined ? NaN : Math.round(Number(match[1]) * units[unit]!);
201
+ } else milliseconds = NaN;
202
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || (!zeroAllowed && milliseconds === 0)) {
203
+ throw new AuthoredFlowExecutionError(
204
+ 'unbounded_subscription',
205
+ `f.on() ${field} must be ${zeroAllowed ? 'a non-negative' : 'a positive'} whole number of milliseconds or a duration such as "72h"`,
206
+ );
207
+ }
208
+ return milliseconds;
209
+ }
210
+
211
+ function unboundedSubscription(message: string): AuthoredFlowExecutionError {
212
+ return new AuthoredFlowExecutionError('unbounded_subscription', message);
213
+ }
214
+
215
+ /** Decode the wire result at the journal boundary so malformed wakes fail closed. */
216
+ export function decodeWake(value: SubscriptionNextResult): Wake {
217
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidWake();
218
+ if (value.kind === 'idle') return Object.freeze({ kind: 'idle' });
219
+ if (value.kind === 'events') {
220
+ if (!Array.isArray(value.events) || !isOffset(value.offset) || !value.events.every(isEventFrame)) return invalidWake();
221
+ return Object.freeze({ kind: 'events', events: Object.freeze([...value.events]) as readonly EventFrame[], offset: value.offset });
222
+ }
223
+ if (value.kind === 'deadline') {
224
+ if (value.pending !== null && !isPending(value.pending)) return invalidWake();
225
+ return Object.freeze({ kind: 'deadline', pending: value.pending === null ? null : Object.freeze({ ...value.pending }) });
226
+ }
227
+ if (value.kind === 'overflow') {
228
+ if (!isOffset(value.retained) || !isOffset(value.bytes) || !isOffset(value.from)) return invalidWake();
229
+ return Object.freeze({ kind: 'overflow', retained: value.retained, bytes: value.bytes, from: value.from });
230
+ }
231
+ return invalidWake();
232
+ }
233
+
234
+ function isEventFrame(value: unknown): value is EventFrame {
235
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
236
+ && typeof (value as { type?: unknown }).type === 'string';
237
+ }
238
+
239
+ function isPending(value: unknown): value is { from: number; to: number } {
240
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
241
+ && isOffset((value as { from?: unknown }).from)
242
+ && isOffset((value as { to?: unknown }).to);
243
+ }
244
+
245
+ function isOffset(value: unknown): value is number {
246
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
247
+ }
248
+
249
+ function invalidWake(): never {
250
+ throw new AuthoredFlowExecutionError('journal_protocol_violation', 'subscription.next returned an invalid wake result');
251
+ }
@@ -0,0 +1,138 @@
1
+ import { delay, LEASE_SWEEP_GRACE_MS } from './cli/run.js';
2
+ import type { JournalClient } from './journal-client.js';
3
+ import type { RunGetResult, RunStatus } from './protocol.js';
4
+
5
+ /** One `journal.read` page; the kernel caps an unbounded read at 100 entries. */
6
+ const PAGE = 1_000;
7
+ const POLL_MS = 50;
8
+ // A run between two of its own actions can report `running` with nothing in
9
+ // flight for a moment (a wait just completed, the next attempt not yet
10
+ // journaled). The same bound `classifyOutcome` gives that window (#179).
11
+ const MAX_IDLE_POLLS = 40;
12
+
13
+ /**
14
+ * Every entry of a child run's journal, paged.
15
+ *
16
+ * A single unbounded read returns the first 100 entries only, and a reader
17
+ * that needs the LAST completion of a step cannot stop at a page boundary.
18
+ * A short page is the end of the journal.
19
+ */
20
+ export async function readChildJournal(journal: JournalClient, runId: string): Promise<unknown[]> {
21
+ const all: unknown[] = [];
22
+ let fromSeq = 1;
23
+ while (true) {
24
+ const { entries } = await journal.journalRead(runId, fromSeq, PAGE);
25
+ all.push(...entries);
26
+ if (entries.length < PAGE) return all;
27
+ const last = entries.at(-1) as { seq?: unknown } | undefined;
28
+ if (typeof last?.seq !== 'number' || !Number.isSafeInteger(last.seq) || last.seq < fromSeq) {
29
+ throw new Error(`invalid journal sequence reading child run "${runId}"`);
30
+ }
31
+ fromSeq = last.seq + 1;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Wait until an authored operation's child run is terminal, and say whether
37
+ * it got there.
38
+ *
39
+ * A child adopted through its admission key is not necessarily finished: on
40
+ * `flows resume` of a root whose daemon survived, `run.start` returns the
41
+ * existing child's CURRENT outcome without driving it, so the child may still
42
+ * be executing an attempt, or sleeping out a retry backoff, on the request
43
+ * that originally started it. Reading its journal then judges a history that
44
+ * is still being written.
45
+ *
46
+ * The wait is read-only on purpose: it never resumes the child. The kernel
47
+ * drives a deterministic attempt inline on the request that started it, and a
48
+ * second driver would presume that attempt dead and run the command again.
49
+ *
50
+ * Bounded by the child's own journaled deadlines, never a fixed one: a running
51
+ * attempt by its lease (renewed as the lease renews) plus the kernel's
52
+ * lease-sweep grace, a backoff by the `sleep.until` it journaled (once that
53
+ * wake has passed, by the settling window). With nothing in flight, only the
54
+ * short settling window above; a child parked on a worker
55
+ * returns at once. Either way the caller gets the status and decides.
56
+ */
57
+ export async function waitForTerminalChildRun(
58
+ journal: JournalClient,
59
+ runId: string,
60
+ signal?: AbortSignal,
61
+ ): Promise<RunStatus> {
62
+ let idlePolls = 0;
63
+ while (true) {
64
+ if (signal?.aborted === true) throw new Error(`waiting for child run "${runId}" was canceled`);
65
+ const snapshot = await journal.runGet(runId);
66
+ if (snapshot.status === 'completed' || snapshot.status === 'failed') return snapshot.status;
67
+ const inFlight = await inFlightDeadline(journal, runId, snapshot);
68
+ if (inFlight === 'parked') return snapshot.status;
69
+ const remainingMs = inFlight === undefined ? 0 : inFlight.deadlineMs + inFlight.graceMs - Date.now();
70
+ if (inFlight !== undefined && remainingMs > 0) {
71
+ idlePolls = 0;
72
+ await delay(Math.min(POLL_MS, remainingMs), signal);
73
+ continue;
74
+ }
75
+ // Only a lease past its sweep grace is proof nothing is running the
76
+ // attempt. An elapsed retry wake is the kernel's normal transition to the
77
+ // next attempt — its driver may be folding the timer right now — so it is
78
+ // re-polled inside the settling window like any other in-between state.
79
+ if (inFlight?.what === 'lease') {
80
+ throw new Error(`child run "${runId}" step "${inFlight.stepId}" passed its lease`
81
+ + ` (${inFlight.deadlineMs}) without reaching a terminal state`);
82
+ }
83
+ if (idlePolls >= MAX_IDLE_POLLS) return snapshot.status;
84
+ idlePolls += 1;
85
+ await delay(POLL_MS, signal);
86
+ }
87
+ }
88
+
89
+ interface InFlight {
90
+ stepId: string;
91
+ what: 'lease' | 'retry backoff';
92
+ deadlineMs: number;
93
+ graceMs: number;
94
+ }
95
+
96
+ /**
97
+ * What the child is waiting on, and until when; `parked` when it is waiting
98
+ * on nothing this process can wait out.
99
+ *
100
+ * A worker-typed (`agent`/`llm`) step that is runnable, or whose retry
101
+ * backoff has elapsed, with no attempt running, needs a worker — or the
102
+ * `run.resume` a caller like `classifyOutcome` issues — to move. That is the
103
+ * park `classifyOutcome` reports, and waiting on it would only delay the same
104
+ * answer. A deterministic step in that state is the kernel between two of its
105
+ * own actions, which the caller's settling window covers.
106
+ */
107
+ async function inFlightDeadline(
108
+ journal: JournalClient,
109
+ runId: string,
110
+ snapshot: RunGetResult,
111
+ ): Promise<InFlight | 'parked' | undefined> {
112
+ const steps = Object.entries(snapshot.steps);
113
+ const running = steps.find(([, step]) => step.state === 'running' && step.lease_deadline_ms !== undefined);
114
+ if (running !== undefined) {
115
+ return {
116
+ stepId: running[0], what: 'lease', deadlineMs: running[1].lease_deadline_ms!, graceMs: LEASE_SWEEP_GRACE_MS,
117
+ };
118
+ }
119
+ const worker = ([, step]: [string, { type: string }]) => step.type !== 'deterministic';
120
+ if (steps.some(entry => worker(entry) && entry[1].state === 'runnable')) return 'parked';
121
+ const backoff = steps.find(([, step]) => step.state === 'backoff');
122
+ if (backoff === undefined) return undefined;
123
+ const wakeAtMs = (await readChildJournal(journal, runId))
124
+ .map(entry => retryWake(entry, backoff[0]))
125
+ .filter((value): value is number => value !== undefined)
126
+ .at(-1);
127
+ if (wakeAtMs === undefined) return undefined;
128
+ if (wakeAtMs <= Date.now()) return worker(backoff) ? 'parked' : undefined;
129
+ return { stepId: backoff[0], what: 'retry backoff', deadlineMs: wakeAtMs, graceMs: 0 };
130
+ }
131
+
132
+ function retryWake(entry: unknown, stepId: string): number | undefined {
133
+ if (typeof entry !== 'object' || entry === null) return undefined;
134
+ const { entry_type: type, step_id: step, payload } = entry as Record<string, unknown>;
135
+ if (type !== 'sleep.until' || step !== stepId || typeof payload !== 'object' || payload === null) return undefined;
136
+ const wakeAtMs = (payload as Record<string, unknown>)['wake_at_ms'];
137
+ return typeof wakeAtMs === 'number' && Number.isFinite(wakeAtMs) ? wakeAtMs : undefined;
138
+ }
@@ -11,6 +11,10 @@ export type AuthoredFlowExecutionErrorCode =
11
11
  | 'helper_slack.credential_missing'
12
12
  | 'helper_slack.mount_required'
13
13
  | 'budget_syntax_invalid'
14
+ /** The step's CLI is too old for its model — an environment refusal, not a spec error. */
15
+ | 'cli_outdated'
16
+ /** The step's credential hit a provider usage/rate limit during preflight. */
17
+ | 'provider_usage_limited'
14
18
  | 'agent_cli_unresolved'
15
19
  | 'agent_parked'
16
20
  | 'llm_cli_unresolved'
@@ -39,6 +43,9 @@ export type AuthoredFlowExecutionErrorCode =
39
43
  | 'unsettled_derived_work'
40
44
  | 'unsupported_promise_lifecycle'
41
45
  | 'unsupported_workspace_permission'
46
+ | 'unbounded_subscription'
47
+ | 'activity_closed'
48
+ | 'subscription_suspended'
42
49
  | 'unawaited_step'
43
50
  | 'unsupported_verb';
44
51
 
@@ -76,12 +83,38 @@ export class AuthoredFlowExecutionError extends Error {
76
83
  * Node-child error frame — can forward it instead of dropping it.
77
84
  */
78
85
  readonly details?: StepFailedDetails,
86
+ readonly suspension?: AuthoredFlowSuspension,
79
87
  ) {
80
88
  super(`${code}: ${message}`);
81
89
  this.name = 'AuthoredFlowExecutionError';
82
90
  }
83
91
  }
84
92
 
93
+ /** Serialized into the CLI report so Cloud can atomically finish activation or wait for a wake. */
94
+ export type AuthoredFlowSuspension =
95
+ /**
96
+ * Exact durable `subscription.prepared` facts Cloud must persist before it
97
+ * fences provider ingress and invokes `subscription.activate`. Binding
98
+ * generation and ingress cursor are Cloud-assigned receipts, deliberately
99
+ * absent from the authored request.
100
+ */
101
+ | {
102
+ readonly kind: 'activation';
103
+ readonly subscriptionId: string;
104
+ readonly eventTypes: readonly string[];
105
+ readonly pattern?: Readonly<Record<string, unknown>>;
106
+ readonly stream: string;
107
+ readonly settleMs: number;
108
+ readonly idleMs: number;
109
+ readonly deadlineAtMs: number;
110
+ readonly includeSelf: boolean;
111
+ }
112
+ | {
113
+ readonly kind: 'event_wait';
114
+ readonly subscriptionId: string;
115
+ readonly stream: string;
116
+ readonly deadlineAtMs: number;
117
+ };
85
118
  /** The question an authored body parked on, as the kernel journals it. */
86
119
  export interface AuthoredHumanWait {
87
120
  readonly waitId: string;
@@ -43,6 +43,7 @@ import type { LoadedAuthoredFlowNode } from './authored-flow-loader.js';
43
43
  import type { RunLifecycleOptions } from './cli/run.js';
44
44
  import {
45
45
  AuthoredFlowExecutionError,
46
+ type AuthoredFlowSuspension,
46
47
  AuthoredHumanParked,
47
48
  type AuthoredFlowExecutionErrorCode,
48
49
  } from './authored-flow-error.js';
@@ -54,6 +55,7 @@ import {
54
55
  verifyAuthoredOperations,
55
56
  } from './authored-flow-operation.js';
56
57
  import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js';
58
+ import { AuthoredActivities } from './authored-activity.js';
57
59
  import { displayLabel, type AuthoredStepEdges } from './authored-step-index.js';
58
60
  import { JournalClient } from './journal-client.js';
59
61
  import { PluginError } from './plugin-manifest.js';
@@ -105,6 +107,7 @@ export interface AuthoredExecutionRuntime {
105
107
  }
106
108
 
107
109
  export interface AuthoredFlowExecutionResult {
110
+ readonly state?: undefined;
108
111
  readonly executionRuntime?: AuthoredExecutionRuntime;
109
112
  readonly rootRunId?: string;
110
113
  readonly name: string;
@@ -118,6 +121,14 @@ export interface AuthoredFlowExecutionResult {
118
121
  readonly journalSteps: readonly AuthoredFlowJournalStep[];
119
122
  }
120
123
 
124
+ /** A body reached a durable event boundary and released its worker lease. */
125
+ export interface AuthoredFlowSuspendedResult {
126
+ readonly state: 'suspended';
127
+ readonly name: string;
128
+ readonly suspension: AuthoredFlowSuspension;
129
+ readonly journalSteps: readonly AuthoredFlowJournalStep[];
130
+ }
131
+
121
132
  // The authoring SURFACE stays wide: `done()` accepts every
122
133
  // `FlowCompletionReason` and refuses the kernel-owned ones at runtime with a
123
134
  // diagnostic that names the alternative. The RESULT is narrow, because `done()`
@@ -273,6 +284,7 @@ export async function executeAuthoredFlow<Input = undefined>(
273
284
  const journalSteps: AuthoredFlowJournalStep[] = [];
274
285
  const authoredSteps: AuthoredFlowOperation<unknown>[] = [];
275
286
  const lifecycle = new AuthoredFlowLifecycle();
287
+ const activities = new AuthoredActivities(journal, options.rootRunId);
276
288
  const edgeOverrides = new Map<string, AuthoredStepEdges>();
277
289
  const stepEdges = (step: string): AuthoredStepEdges | undefined => {
278
290
  const overridden = edgeOverrides.get(step);
@@ -300,6 +312,7 @@ export async function executeAuthoredFlow<Input = undefined>(
300
312
  definition.name, journal, journalSteps, budget, {
301
313
  ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
302
314
  ...(options.dataDir === undefined ? {} : { dataDir: options.dataDir }),
315
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
303
316
  stepEdges,
304
317
  },
305
318
  );
@@ -567,6 +580,17 @@ export async function executeAuthoredFlow<Input = undefined>(
567
580
  );
568
581
  return trackStep(authoredSteps, agentOp);
569
582
  },
583
+ on(source, activityOptions) {
584
+ assertOperationAllowed('on', definition.name, requestedCompletion);
585
+ // Activity ids are root-scoped (`activity-N`); a child's own counter would collide.
586
+ if ((options.dispatchDepth ?? 0) > 0) {
587
+ throw new AuthoredFlowExecutionError(
588
+ 'dispatch_invalid',
589
+ `child flow "${definition.name}" cannot call f.on in this release; activities are root-scoped`,
590
+ );
591
+ }
592
+ return activities.open(source, activityOptions);
593
+ },
570
594
  /**
571
595
  * `f.human` (docs/SURFACE.md §1, §7). The question is not a child run: it
572
596
  * is the ROOT attempt parking on the kernel's `wait.human`. The body
@@ -794,6 +818,11 @@ export async function executeAuthoredFlow<Input = undefined>(
794
818
  try {
795
819
  worker.stop(bodyFailure);
796
820
  await stopAuthoredOperations(authoredSteps, bodyFailure);
821
+ // A durable wait hands execution back to the control plane. Its
822
+ // subscriptions must keep receiving events while no body is running.
823
+ const parked = bodyFailure instanceof AuthoredFlowExecutionError
824
+ && (bodyFailure.code === 'subscription_suspended' || bodyFailure.code === 'human_parked');
825
+ if (!parked) await activities.closeAll('canceled');
797
826
  } finally {
798
827
  lifecycle.close();
799
828
  }
@@ -821,6 +850,7 @@ export async function executeAuthoredFlow<Input = undefined>(
821
850
  try {
822
851
  worker.stop(missingCompletion);
823
852
  await stopAuthoredOperations(authoredSteps, missingCompletion);
853
+ await activities.closeAll('canceled');
824
854
  } finally {
825
855
  lifecycle.close();
826
856
  }
@@ -828,6 +858,16 @@ export async function executeAuthoredFlow<Input = undefined>(
828
858
  }
829
859
  try {
830
860
  await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle);
861
+ } catch (error) {
862
+ try {
863
+ await activities.closeAll('canceled');
864
+ } finally {
865
+ lifecycle.close();
866
+ }
867
+ throw error;
868
+ }
869
+ try {
870
+ await activities.closeAll('run_completed');
831
871
  } finally {
832
872
  lifecycle.close();
833
873
  }
@@ -140,13 +140,13 @@ export async function runMcpEffect(
140
140
  childRunId = outcome.run_id;
141
141
  if (outcome.status !== 'completed') await completed;
142
142
  if (diagnostic !== undefined) {
143
- try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
143
+ try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status); }
144
144
  catch (error) {
145
145
  if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
146
146
  throw new McpStepError(diagnostic, outcome.run_id);
147
147
  }
148
148
  }
149
- const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
149
+ const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status) as { output: unknown };
150
150
  return receipt.output;
151
151
  } finally {
152
152
  clearTimeout(timer);
@@ -94,7 +94,9 @@ try {
94
94
  // the only one that saw the child's classification.
95
95
  ...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
96
96
  completionReason: error.completionReason, runId: error.runId,
97
- details: error.details, parkCause: error.parkCause } : {}),
97
+ details: error.details,
98
+ parkCause: error.parkCause,
99
+ ...(error.suspension === undefined ? {} : { suspension: error.suspension }) } : {}),
98
100
  ...(error instanceof AuthoredHumanParked ? { wait: error.wait } : {}) });
99
101
  process.exitCode = 1;
100
102
  } finally {