conductor-remote 1.107.0 → 1.108.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.
@@ -0,0 +1,336 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ const DEFAULT_TIMEOUT_MS = 30_000;
5
+ const DEFAULT_KILL_GRACE_MS = 500;
6
+ const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
7
+ const GATE_PROTOCOL_VERSION = 1;
8
+ /**
9
+ * The wrapper receives no target command in argv or its environment. It waits for
10
+ * one newline-delimited descriptor on private fd 3, then and only then spawns the
11
+ * target inside its own process group. Pipe EOF before that descriptor is a no-op.
12
+ */
13
+ const GATED_WRAPPER_SOURCE = String.raw `
14
+ const fs = require('node:fs');
15
+ const { spawn } = require('node:child_process');
16
+ const gate = fs.createReadStream(null, { fd: 3, encoding: 'utf8', autoClose: true });
17
+ let input = '';
18
+ let launched = false;
19
+ const fail = code => { if (!launched) process.exit(code); };
20
+ gate.on('data', chunk => {
21
+ if (launched) return;
22
+ input += chunk;
23
+ if (Buffer.byteLength(input) > 4 * 1024 * 1024) return fail(76);
24
+ const newline = input.indexOf('\n');
25
+ if (newline < 0) return;
26
+ launched = true;
27
+ gate.destroy();
28
+ let message;
29
+ try { message = JSON.parse(input.slice(0, newline)); } catch { return process.exit(77); }
30
+ if (!message || message.version !== ${GATE_PROTOCOL_VERSION} || message.go !== true || typeof message.command !== 'string') {
31
+ return process.exit(78);
32
+ }
33
+ const child = spawn(message.command, message.args || [], {
34
+ cwd: message.cwd || undefined,
35
+ env: { ...process.env, ...(message.env || {}) },
36
+ shell: false,
37
+ stdio: ['ignore', 'inherit', 'inherit']
38
+ });
39
+ child.once('error', error => {
40
+ process.stderr.write(String(error && error.stack || error));
41
+ process.exit(79);
42
+ });
43
+ child.once('exit', (code, signal) => {
44
+ if (signal) {
45
+ process.stderr.write('\n[target terminated by ' + signal + ']');
46
+ process.exit(80);
47
+ }
48
+ process.exit(code == null ? 81 : code);
49
+ });
50
+ });
51
+ gate.once('end', () => fail(74));
52
+ gate.once('error', () => fail(75));
53
+ `;
54
+ export class WorkflowGatedCommandError extends Error {
55
+ code;
56
+ phase;
57
+ mayHaveExecuted;
58
+ externalProcess;
59
+ stdout;
60
+ stderr;
61
+ constructor(code, message, options) {
62
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
63
+ this.name = 'WorkflowGatedCommandError';
64
+ this.code = code;
65
+ this.phase = options.phase;
66
+ this.mayHaveExecuted = options.phase === 'after_gate';
67
+ this.externalProcess = options.externalProcess;
68
+ this.stdout = options.stdout ?? '';
69
+ this.stderr = options.stderr ?? '';
70
+ }
71
+ }
72
+ function validate(options) {
73
+ if (!options.command.trim() || options.command.includes('\0')) {
74
+ throw new WorkflowGatedCommandError('invalid_command', 'Workflow gated command must be non-empty.', {
75
+ phase: 'before_gate'
76
+ });
77
+ }
78
+ for (const argument of options.args ?? []) {
79
+ if (argument.includes('\0')) {
80
+ throw new WorkflowGatedCommandError('invalid_command', 'Workflow gated command arguments cannot contain NUL.', {
81
+ phase: 'before_gate'
82
+ });
83
+ }
84
+ }
85
+ if (options.cwd?.includes('\0')) {
86
+ throw new WorkflowGatedCommandError('invalid_command', 'Workflow gated command cwd cannot contain NUL.', {
87
+ phase: 'before_gate'
88
+ });
89
+ }
90
+ for (const [name, value] of Object.entries(options.env ?? {})) {
91
+ if (!name || name.includes('=') || name.includes('\0') || value.includes('\0')) {
92
+ throw new WorkflowGatedCommandError('invalid_command', 'Workflow gated command environment is invalid.', {
93
+ phase: 'before_gate'
94
+ });
95
+ }
96
+ }
97
+ }
98
+ async function processStartedAt(pid) {
99
+ const { stdout } = await execFileAsync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], {
100
+ timeout: 2_000,
101
+ maxBuffer: 16_384
102
+ });
103
+ const value = stdout.trim().replace(/\s+/g, ' ');
104
+ if (!value)
105
+ throw new Error(`ps returned no start identity for PID ${pid}`);
106
+ return value;
107
+ }
108
+ function terminateGroup(pid, signal) {
109
+ if (!Number.isSafeInteger(pid) || pid <= 1)
110
+ return;
111
+ try {
112
+ process.kill(-pid, signal);
113
+ }
114
+ catch (error) {
115
+ if (!error || typeof error !== 'object' || !('code' in error))
116
+ throw error;
117
+ if (error.code === 'ESRCH')
118
+ return;
119
+ // A just-spawned detached child can briefly exist before setsid has made its
120
+ // PID addressable as a process group. Killing that exact validated PID keeps
121
+ // the still-closed gate safe; after release the group path is required.
122
+ if (error.code === 'EPERM') {
123
+ try {
124
+ process.kill(pid, signal);
125
+ }
126
+ catch (fallback) {
127
+ if (!fallback || typeof fallback !== 'object' || !('code' in fallback) || fallback.code !== 'ESRCH')
128
+ throw fallback;
129
+ }
130
+ return;
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+ function closeGate(gate) {
136
+ if (!gate.destroyed)
137
+ gate.destroy();
138
+ }
139
+ /**
140
+ * Execute one external GUI helper with a crash-safe start gate.
141
+ *
142
+ * The durable callback observes the blocked wrapper first. Only after it resolves
143
+ * does GO plus the private command descriptor cross fd 3. The detached wrapper
144
+ * remains the process-group leader until the target exits, so recovery has one
145
+ * stable PID/start identity and timeout cleanup can address the whole group.
146
+ */
147
+ export async function runGatedWorkflowCommand(options) {
148
+ validate(options);
149
+ const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS));
150
+ const killGraceMs = Math.max(1, Math.floor(options.killGraceMs ?? DEFAULT_KILL_GRACE_MS));
151
+ const maxOutputBytes = Math.max(1, Math.floor(options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES));
152
+ let wrapper;
153
+ try {
154
+ wrapper = spawn(process.execPath, ['-e', GATED_WRAPPER_SOURCE], {
155
+ detached: true,
156
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe']
157
+ });
158
+ }
159
+ catch (cause) {
160
+ throw new WorkflowGatedCommandError('spawn_failed', 'Could not spawn the gated Workflow wrapper.', {
161
+ phase: 'before_gate',
162
+ cause
163
+ });
164
+ }
165
+ const pid = wrapper.pid;
166
+ const gate = wrapper.stdio[3];
167
+ if (!pid || pid <= 1 || !gate) {
168
+ wrapper.kill();
169
+ throw new WorkflowGatedCommandError('spawn_failed', 'The gated Workflow wrapper exposed no safe PID or pipe.', {
170
+ phase: 'before_gate'
171
+ });
172
+ }
173
+ let stdout = '';
174
+ let stderr = '';
175
+ let outputBytes = 0;
176
+ let gateReleased = false;
177
+ let timeout = false;
178
+ let aborted = options.signal?.aborted ?? false;
179
+ let outputLimit = false;
180
+ let settled = false;
181
+ let forceKillTimer;
182
+ let completionResolve;
183
+ const completion = new Promise(resolve => {
184
+ completionResolve = resolve;
185
+ });
186
+ const settle = (value) => {
187
+ if (settled)
188
+ return;
189
+ settled = true;
190
+ if (forceKillTimer)
191
+ clearTimeout(forceKillTimer);
192
+ completionResolve?.(value);
193
+ };
194
+ const armForceKill = () => {
195
+ if (forceKillTimer || settled)
196
+ return;
197
+ forceKillTimer = setTimeout(() => terminateGroup(pid, 'SIGKILL'), killGraceMs);
198
+ };
199
+ const collect = (stream, chunk) => {
200
+ outputBytes += chunk.byteLength;
201
+ if (outputBytes > maxOutputBytes) {
202
+ outputLimit = true;
203
+ terminateGroup(pid, 'SIGTERM');
204
+ armForceKill();
205
+ return;
206
+ }
207
+ if (stream === 'stdout')
208
+ stdout += chunk.toString('utf8');
209
+ else
210
+ stderr += chunk.toString('utf8');
211
+ };
212
+ wrapper.stdout?.on('data', chunk => collect('stdout', Buffer.from(chunk)));
213
+ wrapper.stderr?.on('data', chunk => collect('stderr', Buffer.from(chunk)));
214
+ wrapper.once('error', () => settle({ code: null, signal: null }));
215
+ wrapper.once('close', (code, signal) => settle({ code, signal }));
216
+ const abort = () => {
217
+ aborted = true;
218
+ closeGate(gate);
219
+ terminateGroup(pid, 'SIGTERM');
220
+ armForceKill();
221
+ };
222
+ options.signal?.addEventListener('abort', abort, { once: true });
223
+ const timer = setTimeout(() => {
224
+ timeout = true;
225
+ closeGate(gate);
226
+ terminateGroup(pid, 'SIGTERM');
227
+ armForceKill();
228
+ }, timeoutMs);
229
+ let externalProcess;
230
+ try {
231
+ let startedAt;
232
+ try {
233
+ startedAt = await (options.readProcessStartedAt ?? processStartedAt)(pid);
234
+ }
235
+ catch (cause) {
236
+ closeGate(gate);
237
+ terminateGroup(pid, 'SIGTERM');
238
+ throw new WorkflowGatedCommandError('identity_failed', 'Could not prove the gated wrapper process identity.', {
239
+ phase: 'before_gate',
240
+ cause
241
+ });
242
+ }
243
+ externalProcess = { pid, processStartedAt: startedAt, processGroup: pid };
244
+ if (aborted || timeout) {
245
+ throw new WorkflowGatedCommandError(aborted ? 'aborted' : 'timed_out', 'Workflow command stopped before gate registration.', {
246
+ phase: 'before_gate',
247
+ externalProcess
248
+ });
249
+ }
250
+ try {
251
+ await options.onSpawned(externalProcess);
252
+ }
253
+ catch (cause) {
254
+ closeGate(gate);
255
+ terminateGroup(pid, 'SIGTERM');
256
+ throw new WorkflowGatedCommandError('gate_rejected', 'Durable Workflow process registration failed before gate release.', { phase: 'before_gate', externalProcess, cause });
257
+ }
258
+ if (aborted || timeout) {
259
+ throw new WorkflowGatedCommandError(aborted ? 'aborted' : 'timed_out', 'Workflow command stopped before gate release.', {
260
+ phase: 'before_gate',
261
+ externalProcess
262
+ });
263
+ }
264
+ const descriptor = JSON.stringify({
265
+ version: GATE_PROTOCOL_VERSION,
266
+ go: true,
267
+ command: options.command,
268
+ args: [...(options.args ?? [])],
269
+ ...(options.cwd ? { cwd: options.cwd } : {}),
270
+ ...(options.env ? { env: options.env } : {})
271
+ });
272
+ await new Promise((resolve, reject) => {
273
+ const failed = (error) => reject(error);
274
+ gate.once('error', failed);
275
+ gate.end(`${descriptor}\n`, () => {
276
+ gate.off('error', failed);
277
+ resolve();
278
+ });
279
+ });
280
+ gateReleased = true;
281
+ const finished = await completion;
282
+ if (timeout) {
283
+ throw new WorkflowGatedCommandError('timed_out', 'Workflow command exceeded its deadline.', {
284
+ phase: 'after_gate',
285
+ externalProcess,
286
+ stdout,
287
+ stderr
288
+ });
289
+ }
290
+ if (aborted) {
291
+ throw new WorkflowGatedCommandError('aborted', 'Workflow command was aborted.', {
292
+ phase: 'after_gate',
293
+ externalProcess,
294
+ stdout,
295
+ stderr
296
+ });
297
+ }
298
+ if (outputLimit) {
299
+ throw new WorkflowGatedCommandError('output_limit', 'Workflow command exceeded its output limit.', {
300
+ phase: 'after_gate',
301
+ externalProcess,
302
+ stdout,
303
+ stderr
304
+ });
305
+ }
306
+ if (finished.code !== 0) {
307
+ throw new WorkflowGatedCommandError('command_failed', `Workflow command exited with ${finished.signal ?? finished.code ?? 'no status'}.`, { phase: 'after_gate', externalProcess, stdout, stderr });
308
+ }
309
+ return { code: 0, stdout, stderr, externalProcess };
310
+ }
311
+ catch (error) {
312
+ if (!settled) {
313
+ closeGate(gate);
314
+ terminateGroup(pid, 'SIGTERM');
315
+ await Promise.race([completion, new Promise(resolve => setTimeout(resolve, killGraceMs))]);
316
+ if (!settled)
317
+ terminateGroup(pid, 'SIGKILL');
318
+ await completion;
319
+ }
320
+ if (error instanceof WorkflowGatedCommandError)
321
+ throw error;
322
+ throw new WorkflowGatedCommandError('gate_rejected', 'The private Workflow execution gate failed.', {
323
+ phase: gateReleased ? 'after_gate' : 'before_gate',
324
+ externalProcess,
325
+ stdout,
326
+ stderr,
327
+ cause: error
328
+ });
329
+ }
330
+ finally {
331
+ clearTimeout(timer);
332
+ if (forceKillTimer)
333
+ clearTimeout(forceKillTimer);
334
+ options.signal?.removeEventListener('abort', abort);
335
+ }
336
+ }
@@ -0,0 +1,141 @@
1
+ export class WorkflowRequestError extends Error {
2
+ status;
3
+ code;
4
+ constructor(message, status = 400) {
5
+ super(message);
6
+ this.name = 'WorkflowRequestError';
7
+ this.status = status;
8
+ this.code = status === 403 ? 'workflow_authorization_failed' : 'invalid_request';
9
+ }
10
+ }
11
+ function record(value, name) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
13
+ throw new WorkflowRequestError(`${name} must be an object.`);
14
+ }
15
+ return value;
16
+ }
17
+ function exact(value, allowed, name) {
18
+ const fields = new Set(allowed);
19
+ const unknown = Object.keys(value).filter(field => !fields.has(field));
20
+ if (unknown.length)
21
+ throw new WorkflowRequestError(`${name} has unknown field ${unknown.join(', ')}.`);
22
+ }
23
+ function text(value, name, maximum = 256) {
24
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum) {
25
+ throw new WorkflowRequestError(`${name} is required.`);
26
+ }
27
+ return value.trim();
28
+ }
29
+ function clientId(value) {
30
+ return text(value, 'clientId', 256);
31
+ }
32
+ export function parseStartWorkflowRequest(raw) {
33
+ const body = record(raw, 'Workflow request');
34
+ exact(body, ['clientId', 'objective', 'target'], 'Workflow request');
35
+ const target = record(body.target, 'target');
36
+ const objective = text(body.objective, 'objective', 1_000_000);
37
+ const id = clientId(body.clientId);
38
+ if (target.kind === 'new_workspace') {
39
+ exact(target, ['kind', 'repo', 'sendImmediately'], 'new-workspace target');
40
+ if (typeof target.sendImmediately !== 'boolean') {
41
+ throw new WorkflowRequestError('target.sendImmediately must be a boolean.');
42
+ }
43
+ return {
44
+ clientId: id,
45
+ objective,
46
+ target: {
47
+ kind: 'new_workspace',
48
+ repo: text(target.repo, 'target.repo'),
49
+ sendImmediately: target.sendImmediately
50
+ }
51
+ };
52
+ }
53
+ if (target.kind === 'existing_session') {
54
+ exact(target, ['kind', 'workspaceId', 'sessionId'], 'existing-session target');
55
+ return {
56
+ clientId: id,
57
+ objective,
58
+ target: {
59
+ kind: 'existing_session',
60
+ workspaceId: text(target.workspaceId, 'target.workspaceId'),
61
+ sessionId: text(target.sessionId, 'target.sessionId')
62
+ }
63
+ };
64
+ }
65
+ throw new WorkflowRequestError('target.kind must be new_workspace or existing_session.');
66
+ }
67
+ export function parseWorkflowDelegateRequest(raw, workflowId) {
68
+ const body = record(raw, 'Workflow delegation');
69
+ exact(body, ['workflow_id', 'phase_capability', 'session_id', 'role', 'prompt'], 'Workflow delegation');
70
+ const bodyWorkflowId = text(body.workflow_id, 'workflow_id');
71
+ if (bodyWorkflowId !== workflowId) {
72
+ throw new WorkflowRequestError('The body workflow_id does not match the route.', 403);
73
+ }
74
+ if (body.role !== 'exploration' && body.role !== 'implementation') {
75
+ throw new WorkflowRequestError('role must be exploration or implementation.');
76
+ }
77
+ return {
78
+ workflow_id: bodyWorkflowId,
79
+ phase_capability: text(body.phase_capability, 'phase_capability', 512),
80
+ session_id: text(body.session_id, 'session_id'),
81
+ role: body.role,
82
+ prompt: text(body.prompt, 'prompt', 1_000_000)
83
+ };
84
+ }
85
+ export function parseWorkflowRetryRequest(raw) {
86
+ const body = record(raw, 'Workflow retry');
87
+ exact(body, ['clientId'], 'Workflow retry');
88
+ return { clientId: clientId(body.clientId) };
89
+ }
90
+ export function parseWorkflowCompleteRequest(raw) {
91
+ const body = record(raw, 'Workflow completion');
92
+ exact(body, ['clientId'], 'Workflow completion');
93
+ return { clientId: clientId(body.clientId) };
94
+ }
95
+ export function parseWorkflowAdoptRequest(raw) {
96
+ const body = record(raw, 'Workflow adoption');
97
+ exact(body, ['clientId', 'actionId', 'workspaceId', 'sessionId'], 'Workflow adoption');
98
+ const workspaceId = body.workspaceId === undefined ? undefined : text(body.workspaceId, 'workspaceId');
99
+ const sessionId = body.sessionId === undefined ? undefined : text(body.sessionId, 'sessionId');
100
+ const request = {
101
+ clientId: clientId(body.clientId),
102
+ actionId: text(body.actionId, 'actionId')
103
+ };
104
+ if (workspaceId && !sessionId)
105
+ return { ...request, workspaceId };
106
+ if (sessionId && !workspaceId)
107
+ return { ...request, sessionId };
108
+ throw new WorkflowRequestError('Adoption requires exactly one workspaceId or sessionId.');
109
+ }
110
+ export function parseWorkflowReplayRequest(raw) {
111
+ const body = record(raw, 'Workflow replay');
112
+ exact(body, ['clientId', 'actionId', 'confirmDuplicateRisk'], 'Workflow replay');
113
+ if (body.confirmDuplicateRisk !== true) {
114
+ throw new WorkflowRequestError('confirmDuplicateRisk must be true.');
115
+ }
116
+ return {
117
+ clientId: clientId(body.clientId),
118
+ actionId: text(body.actionId, 'actionId'),
119
+ confirmDuplicateRisk: true
120
+ };
121
+ }
122
+ export function parseConfirmUiStableRequest(raw) {
123
+ const body = record(raw, 'UI stability confirmation');
124
+ exact(body, ['clientId', 'confirmStable', 'createdAt', 'actionId', 'effectId'], 'UI stability confirmation');
125
+ if (body.confirmStable !== true) {
126
+ throw new WorkflowRequestError('confirmStable must be true.');
127
+ }
128
+ if (!Number.isSafeInteger(body.createdAt) || body.createdAt < 0) {
129
+ throw new WorkflowRequestError('createdAt must identify the displayed UI safety hold.');
130
+ }
131
+ return {
132
+ clientId: clientId(body.clientId),
133
+ confirmStable: true,
134
+ createdAt: body.createdAt,
135
+ ...(body.actionId === undefined ? {} : { actionId: text(body.actionId, 'actionId') }),
136
+ ...(body.effectId === undefined ? {} : { effectId: text(body.effectId, 'effectId') })
137
+ };
138
+ }
139
+ export function workflowClientIsMcp(headers) {
140
+ return headers['x-relay-client'] === 'mcp';
141
+ }
@@ -0,0 +1,112 @@
1
+ export class WorkflowGuardError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, message, status = 409) {
5
+ super(message);
6
+ this.name = 'WorkflowGuardError';
7
+ this.code = code;
8
+ this.status = status;
9
+ }
10
+ }
11
+ export function isTerminalWorkflowPhase(phase) {
12
+ return phase === 'completed' || phase === 'cancelled';
13
+ }
14
+ export function isTerminalWorkflowJobState(state) {
15
+ return state === 'returned' || state === 'failed' || state === 'cancelled';
16
+ }
17
+ /** Which managed child role the root may request at this evidence barrier. */
18
+ export function workflowAllowedRoles(phase) {
19
+ if (phase === 'exploring')
20
+ return ['exploration'];
21
+ if (phase === 'planning' || phase === 'reviewing')
22
+ return ['exploration', 'implementation'];
23
+ return [];
24
+ }
25
+ export function workflowOutstandingJobs(jobs, cycle) {
26
+ return jobs.filter(job => job.cycle === cycle && !isTerminalWorkflowJobState(job.status));
27
+ }
28
+ export function workflowExplorationBarrierSatisfied(jobs, cycle) {
29
+ const explorers = jobs.filter(job => job.cycle === cycle && job.role === 'exploration');
30
+ return explorers.length > 0 && explorers.every(job => job.status === 'returned' && job.batonReceiptKind === 'message');
31
+ }
32
+ export function workflowImplementationBarrierSatisfied(jobs, cycle) {
33
+ const implementers = jobs.filter(job => job.cycle === cycle && job.role === 'implementation');
34
+ return (implementers.length > 0 &&
35
+ implementers.every(job => job.status === 'returned' && job.batonReceiptKind === 'message'));
36
+ }
37
+ /**
38
+ * Validate a root delegation against the capability that was causally delivered
39
+ * to that exact chat. This is intentionally pure; the caller still consumes or
40
+ * rotates the matching database row in the same transaction that creates a job.
41
+ */
42
+ export function assertWorkflowDelegation(run, claims, request, jobs) {
43
+ if (!run) {
44
+ throw new WorkflowGuardError('workflow_required', 'delegate_task is available only inside a UI-authorized Workflow.');
45
+ }
46
+ if (run.phase === 'blocked') {
47
+ throw new WorkflowGuardError('workflow_blocked', 'This Workflow is blocked and needs recovery from the phone.');
48
+ }
49
+ if (isTerminalWorkflowPhase(run.phase)) {
50
+ throw new WorkflowGuardError('workflow_phase_invalid', `This Workflow is ${run.phase}.`);
51
+ }
52
+ if (!run.rootSessionId || request.sessionId !== run.rootSessionId) {
53
+ throw new WorkflowGuardError('workflow_authorization_failed', 'The phase capability does not authorize this root session.', 403);
54
+ }
55
+ if (!claims ||
56
+ claims.consumed ||
57
+ claims.revoked ||
58
+ claims.runId !== run.id ||
59
+ claims.rootSessionId !== run.rootSessionId) {
60
+ throw new WorkflowGuardError('workflow_authorization_failed', 'The phase capability is missing, invalid, consumed, or revoked.', 403);
61
+ }
62
+ if (claims.cycle !== run.cycle ||
63
+ claims.revision !== run.revision ||
64
+ claims.phase !== run.phase ||
65
+ !claims.allowedRoles.includes(request.role)) {
66
+ throw new WorkflowGuardError('workflow_phase_invalid', 'That phase capability is stale or does not permit the requested role.');
67
+ }
68
+ if (!workflowAllowedRoles(run.phase).includes(request.role)) {
69
+ throw new WorkflowGuardError('workflow_phase_invalid', `Role ${request.role} cannot start while the Workflow is ${run.phase}.`);
70
+ }
71
+ const outstanding = workflowOutstandingJobs(jobs, run.cycle);
72
+ const conflicting = run.phase === 'exploring' && request.role === 'exploration'
73
+ ? outstanding.some(job => job.role === 'implementation')
74
+ : outstanding.length > 0;
75
+ if (conflicting) {
76
+ throw new WorkflowGuardError('workflow_phase_invalid', 'The current Workflow job must return before another phase can start.');
77
+ }
78
+ if (request.role === 'implementation' && !workflowExplorationBarrierSatisfied(jobs, run.cycle)) {
79
+ throw new WorkflowGuardError('workflow_phase_invalid', 'Implementation remains closed until every current explorer Baton is delivered.');
80
+ }
81
+ }
82
+ /** The transition materialized atomically beside capability consumption and job creation. */
83
+ export function workflowDelegationTransition(run, role) {
84
+ if (role === 'implementation') {
85
+ return {
86
+ phase: 'implementing',
87
+ cycle: run.cycle,
88
+ revision: run.revision + 1,
89
+ logicalPrefix: 'implement'
90
+ };
91
+ }
92
+ if (run.phase === 'exploring') {
93
+ return {
94
+ phase: 'exploring',
95
+ cycle: run.cycle,
96
+ revision: run.revision + 1,
97
+ logicalPrefix: 'explore'
98
+ };
99
+ }
100
+ return {
101
+ phase: 'exploring',
102
+ cycle: run.cycle + 1,
103
+ revision: 0,
104
+ logicalPrefix: 'explore'
105
+ };
106
+ }
107
+ /** Delivered Batons, never merely accepted outbox rows, open the next root phase. */
108
+ export function phaseAfterDeliveredBaton(role, jobs, cycle) {
109
+ if (role === 'exploration')
110
+ return workflowExplorationBarrierSatisfied(jobs, cycle) ? 'planning' : null;
111
+ return workflowImplementationBarrierSatisfied(jobs, cycle) ? 'reviewing' : null;
112
+ }