@cordfuse/crosstalk 7.0.0-alpha.8 → 7.0.0-beta.1
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/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -122
- package/commands/up.js +0 -135
package/src/workflow.ts
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// workflow.ts — runtime workflow executor.
|
|
2
|
+
//
|
|
3
|
+
// A workflow marker is a `type: workflow` message addressed to the reserved
|
|
4
|
+
// recipient `workflow`. The runtime processes it directly: no model wakes
|
|
5
|
+
// for it via the activation loop. The persona-orchestrator pattern from
|
|
6
|
+
// pre-alpha v7 is gone — workflows are now compile-pass + deterministic
|
|
7
|
+
// runtime execution, so every supported agent CLI works regardless of
|
|
8
|
+
// model's instruction-following discipline.
|
|
9
|
+
//
|
|
10
|
+
// One phase advances per dispatcher tick. State is derived from files
|
|
11
|
+
// each tick — no in-memory workflow registry, no recovery logic needed
|
|
12
|
+
// across dispatcher restarts.
|
|
13
|
+
//
|
|
14
|
+
// 1. compile — invoke the first claimed model with COMPILE_PROMPT
|
|
15
|
+
// and the workflow body. Parse JSON plan. Save to
|
|
16
|
+
// data/channels/<child>/PLAN.json, or fail the workflow.
|
|
17
|
+
// 2. fanout — write `plan.fanout.count` sub-primitives into the
|
|
18
|
+
// child channel addressed to `plan.fanout.to`.
|
|
19
|
+
// 3. synthesize — once all fanout replies are in, write one synthesis
|
|
20
|
+
// sub-primitive into the child channel with body =
|
|
21
|
+
// plan.synthesize.body + the concatenated reply bodies.
|
|
22
|
+
// 4. route — once the synthesis reply lands, write a final reply
|
|
23
|
+
// in the PARENT channel addressed to the workflow
|
|
24
|
+
// marker's `from:`, re:-linked to the marker. Drop a
|
|
25
|
+
// COMPLETE side file so future ticks skip this marker.
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
28
|
+
import { join } from 'path';
|
|
29
|
+
import { now, messageFilename } from './filenames.js';
|
|
30
|
+
import { serializeFrontmatter } from './frontmatter.js';
|
|
31
|
+
import { invokeModelCli } from './invoke.js';
|
|
32
|
+
import { listChannelMessages, type ChannelMessage } from './transport.js';
|
|
33
|
+
import { logError } from './state.js';
|
|
34
|
+
import { dispatchersForModel } from './dispatchers.js';
|
|
35
|
+
import type { ModelEntry, ModelsRegistry } from './models.js';
|
|
36
|
+
import { resolveModelRef, formatResolutionError } from './resolve.js';
|
|
37
|
+
|
|
38
|
+
export const WORKFLOW_RECIPIENT = 'workflow';
|
|
39
|
+
|
|
40
|
+
export interface WorkflowPlan {
|
|
41
|
+
fanout: { to: string; count: number; body: string };
|
|
42
|
+
synthesize: { to: string; body: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const COMPILE_PROMPT = [
|
|
46
|
+
'Compile the workflow markdown document below into a single JSON object',
|
|
47
|
+
'with this exact shape:',
|
|
48
|
+
'',
|
|
49
|
+
'{"fanout":{"to":"<provider>/<model>","count":<integer 1-10>,"body":"<task>"},',
|
|
50
|
+
' "synthesize":{"to":"<provider>/<model>","body":"<instruction>"}}',
|
|
51
|
+
'',
|
|
52
|
+
'Field meaning:',
|
|
53
|
+
' fanout.to the model identity in qualified `<provider>/<model>` form',
|
|
54
|
+
' (from data/crosstalk.yaml). A bare `<model>` is accepted',
|
|
55
|
+
' but resolved as ambiguous when multiple providers offer',
|
|
56
|
+
' it; prefer the qualified form for stability.',
|
|
57
|
+
' fanout.count how many parallel workers, integer 1-10',
|
|
58
|
+
' fanout.body the task each fanout worker performs',
|
|
59
|
+
' synthesize.to the model identity that picks/merges the fanout replies',
|
|
60
|
+
' (same `<provider>/<model>` form)',
|
|
61
|
+
' synthesize.body the instruction the synthesizer follows',
|
|
62
|
+
'',
|
|
63
|
+
'Output ONLY the JSON object. No prose, no markdown fences, no explanation.',
|
|
64
|
+
'The workflow document follows:',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n');
|
|
67
|
+
|
|
68
|
+
function channelDir(transportRoot: string, channelUuid: string): string {
|
|
69
|
+
return join(transportRoot, 'data', 'channels', channelUuid);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function planPath(transportRoot: string, childUuid: string): string {
|
|
73
|
+
return join(channelDir(transportRoot, childUuid), 'PLAN.json');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function completePath(transportRoot: string, childUuid: string): string {
|
|
77
|
+
return join(channelDir(transportRoot, childUuid), 'COMPLETE');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function validatePlan(p: unknown): p is WorkflowPlan {
|
|
81
|
+
if (typeof p !== 'object' || p == null) return false;
|
|
82
|
+
const x = p as Record<string, unknown>;
|
|
83
|
+
const f = x['fanout'] as Record<string, unknown> | undefined;
|
|
84
|
+
const s = x['synthesize'] as Record<string, unknown> | undefined;
|
|
85
|
+
if (!f || !s) return false;
|
|
86
|
+
if (typeof f['to'] !== 'string' || f['to'].length === 0) return false;
|
|
87
|
+
const c = f['count'];
|
|
88
|
+
if (typeof c !== 'number' || !Number.isInteger(c) || c < 1 || c > 10) return false;
|
|
89
|
+
if (typeof f['body'] !== 'string') return false;
|
|
90
|
+
if (typeof s['to'] !== 'string' || s['to'].length === 0) return false;
|
|
91
|
+
if (typeof s['body'] !== 'string') return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadPlan(transportRoot: string, childUuid: string): WorkflowPlan | null {
|
|
96
|
+
const p = planPath(transportRoot, childUuid);
|
|
97
|
+
if (!existsSync(p)) return null;
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(readFileSync(p, 'utf-8')) as unknown;
|
|
100
|
+
return validatePlan(parsed) ? parsed : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Models occasionally wrap JSON in markdown fences or add a sentence before
|
|
107
|
+
// the object despite the prompt's "JSON only" instruction. Extract the first
|
|
108
|
+
// balanced {...} block as a fallback.
|
|
109
|
+
export function extractPlanFromOutput(stdout: string): WorkflowPlan | null {
|
|
110
|
+
const trimmed = stdout.trim();
|
|
111
|
+
const tryParse = (s: string): WorkflowPlan | null => {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(s) as unknown;
|
|
114
|
+
return validatePlan(parsed) ? parsed : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const whole = tryParse(trimmed);
|
|
120
|
+
if (whole) return whole;
|
|
121
|
+
const start = trimmed.indexOf('{');
|
|
122
|
+
const end = trimmed.lastIndexOf('}');
|
|
123
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
124
|
+
return tryParse(trimmed.slice(start, end + 1));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface WorkflowMarker {
|
|
128
|
+
parentChannelUuid: string;
|
|
129
|
+
childChannelUuid: string;
|
|
130
|
+
markerRelPath: string;
|
|
131
|
+
markerFrom: string;
|
|
132
|
+
body: string;
|
|
133
|
+
// When set, only the dispatcher whose alias matches progresses this
|
|
134
|
+
// workflow's phases. Markers without dispatch_host fall back to
|
|
135
|
+
// race-based progression (acceptable for single-host transports).
|
|
136
|
+
dispatchHost?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function findOpenWorkflows(
|
|
140
|
+
transportRoot: string,
|
|
141
|
+
channels: string[],
|
|
142
|
+
alias?: string,
|
|
143
|
+
): WorkflowMarker[] {
|
|
144
|
+
const out: WorkflowMarker[] = [];
|
|
145
|
+
for (const parentUuid of channels) {
|
|
146
|
+
const messages = listChannelMessages(transportRoot, parentUuid);
|
|
147
|
+
for (const m of messages) {
|
|
148
|
+
if (m.data['type'] !== 'workflow') continue;
|
|
149
|
+
const childUuid = m.data['child_channel'];
|
|
150
|
+
if (typeof childUuid !== 'string') continue;
|
|
151
|
+
if (existsSync(completePath(transportRoot, childUuid))) continue;
|
|
152
|
+
const dispatchHost = typeof m.data['dispatch_host'] === 'string'
|
|
153
|
+
? (m.data['dispatch_host'] as string)
|
|
154
|
+
: undefined;
|
|
155
|
+
// Ownership filter: when the marker pins a dispatch_host, only that
|
|
156
|
+
// dispatcher progresses it. Other dispatchers see the marker (it's a
|
|
157
|
+
// normal message) but skip it here, leaving the workflow's runtime
|
|
158
|
+
// work to its owner.
|
|
159
|
+
if (dispatchHost && alias && dispatchHost !== alias) continue;
|
|
160
|
+
const from = typeof m.data['from'] === 'string' ? (m.data['from'] as string) : 'unknown';
|
|
161
|
+
out.push({
|
|
162
|
+
parentChannelUuid: parentUuid,
|
|
163
|
+
childChannelUuid: childUuid,
|
|
164
|
+
markerRelPath: m.relPath,
|
|
165
|
+
markerFrom: from,
|
|
166
|
+
body: m.body,
|
|
167
|
+
dispatchHost,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function senderOf(msg: ChannelMessage): string {
|
|
175
|
+
return typeof msg.data['from'] === 'string' ? (msg.data['from'] as string) : 'unknown';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function workflowDispatches(
|
|
179
|
+
transportRoot: string,
|
|
180
|
+
childUuid: string,
|
|
181
|
+
alias: string,
|
|
182
|
+
phase: 'fanout' | 'synthesize',
|
|
183
|
+
): ChannelMessage[] {
|
|
184
|
+
const fromName = `workflow@${alias}`;
|
|
185
|
+
return listChannelMessages(transportRoot, childUuid).filter(
|
|
186
|
+
(m) => senderOf(m) === fromName && m.data['workflow_phase'] === phase,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function repliesTo(
|
|
191
|
+
transportRoot: string,
|
|
192
|
+
childUuid: string,
|
|
193
|
+
targetRelPaths: string[],
|
|
194
|
+
): ChannelMessage[] {
|
|
195
|
+
const targetSet = new Set(targetRelPaths);
|
|
196
|
+
return listChannelMessages(transportRoot, childUuid).filter((m) => {
|
|
197
|
+
const re = m.data['re'];
|
|
198
|
+
if (typeof re === 'string') return targetSet.has(re);
|
|
199
|
+
if (Array.isArray(re)) return re.some((r) => typeof r === 'string' && targetSet.has(r));
|
|
200
|
+
return false;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface WriteOpts {
|
|
205
|
+
transportRoot: string;
|
|
206
|
+
channelUuid: string;
|
|
207
|
+
from: string;
|
|
208
|
+
to: string;
|
|
209
|
+
body: string;
|
|
210
|
+
workflowPhase?: 'fanout' | 'synthesize';
|
|
211
|
+
re?: string;
|
|
212
|
+
failed?: { error: string };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function writeRuntimeMessage(opts: WriteOpts): string {
|
|
216
|
+
const ts = now();
|
|
217
|
+
const dir = join(channelDir(opts.transportRoot, opts.channelUuid), ts.pathDate);
|
|
218
|
+
mkdirSync(dir, { recursive: true });
|
|
219
|
+
const fm: Record<string, unknown> = {
|
|
220
|
+
from: opts.from,
|
|
221
|
+
to: opts.to,
|
|
222
|
+
timestamp: ts.iso,
|
|
223
|
+
};
|
|
224
|
+
if (opts.re) fm['re'] = opts.re;
|
|
225
|
+
if (opts.workflowPhase) fm['workflow_phase'] = opts.workflowPhase;
|
|
226
|
+
if (opts.failed) {
|
|
227
|
+
fm['failed'] = true;
|
|
228
|
+
fm['error'] = opts.failed.error.slice(0, 2000);
|
|
229
|
+
}
|
|
230
|
+
const filename = messageFilename(ts);
|
|
231
|
+
writeFileSync(join(dir, filename), serializeFrontmatter(fm, opts.body));
|
|
232
|
+
return join(ts.pathDate, filename);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function markComplete(transportRoot: string, childUuid: string): void {
|
|
236
|
+
writeFileSync(completePath(transportRoot, childUuid), new Date().toISOString() + '\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function failWorkflow(
|
|
240
|
+
transportRoot: string,
|
|
241
|
+
marker: WorkflowMarker,
|
|
242
|
+
alias: string,
|
|
243
|
+
error: string,
|
|
244
|
+
): void {
|
|
245
|
+
writeRuntimeMessage({
|
|
246
|
+
transportRoot,
|
|
247
|
+
channelUuid: marker.parentChannelUuid,
|
|
248
|
+
from: `workflow@${alias}`,
|
|
249
|
+
to: marker.markerFrom,
|
|
250
|
+
body: error,
|
|
251
|
+
re: marker.markerRelPath,
|
|
252
|
+
failed: { error },
|
|
253
|
+
});
|
|
254
|
+
markComplete(transportRoot, marker.childChannelUuid);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface WorkflowTickContext {
|
|
258
|
+
transportRoot: string;
|
|
259
|
+
alias: string;
|
|
260
|
+
registry: ModelsRegistry; // full registry (all + byBareName + claimed)
|
|
261
|
+
claimed: Map<string, ModelEntry>; // shorthand for registry.claimed
|
|
262
|
+
log: (event: string, fields?: Record<string, unknown>) => void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function workflowTick(
|
|
266
|
+
ctx: WorkflowTickContext,
|
|
267
|
+
channels: string[],
|
|
268
|
+
): Promise<boolean> {
|
|
269
|
+
const open = findOpenWorkflows(ctx.transportRoot, channels, ctx.alias);
|
|
270
|
+
if (open.length === 0) return false;
|
|
271
|
+
let progressed = false;
|
|
272
|
+
for (const marker of open) {
|
|
273
|
+
try {
|
|
274
|
+
const did = await advanceOne(ctx, marker);
|
|
275
|
+
if (did) progressed = true;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
const msg = (err as Error).message;
|
|
278
|
+
logError(ctx.transportRoot, `workflow ${marker.markerRelPath} crashed: ${msg}`);
|
|
279
|
+
ctx.log('workflow_crash', { marker: marker.markerRelPath, error: msg.slice(0, 200) });
|
|
280
|
+
failWorkflow(ctx.transportRoot, marker, ctx.alias, `runtime error: ${msg.slice(0, 500)}`);
|
|
281
|
+
progressed = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return progressed;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function advanceOne(ctx: WorkflowTickContext, marker: WorkflowMarker): Promise<boolean> {
|
|
288
|
+
const { transportRoot, alias, claimed } = ctx;
|
|
289
|
+
const fromIdentity = `workflow@${alias}`;
|
|
290
|
+
|
|
291
|
+
// Phase 1: compile.
|
|
292
|
+
let plan = loadPlan(transportRoot, marker.childChannelUuid);
|
|
293
|
+
if (!plan) {
|
|
294
|
+
const firstClaimed = claimed.values().next().value as ModelEntry | undefined;
|
|
295
|
+
if (!firstClaimed) {
|
|
296
|
+
failWorkflow(transportRoot, marker, alias, 'no claimed model available to compile the workflow');
|
|
297
|
+
ctx.log('workflow_compile_no_model', { marker: marker.markerRelPath });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
ctx.log('workflow_compile_start', {
|
|
301
|
+
marker: marker.markerRelPath,
|
|
302
|
+
compile_model: firstClaimed.name,
|
|
303
|
+
});
|
|
304
|
+
const result = await invokeModelCli(firstClaimed, COMPILE_PROMPT, marker.body, {});
|
|
305
|
+
if (result.status !== 0) {
|
|
306
|
+
failWorkflow(transportRoot, marker, alias,
|
|
307
|
+
`compile model exit=${result.status}: ${result.stderr.slice(0, 500)}`);
|
|
308
|
+
ctx.log('workflow_compile_failed', { marker: marker.markerRelPath, exit: result.status });
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
const parsed = extractPlanFromOutput(result.stdout);
|
|
312
|
+
if (!parsed) {
|
|
313
|
+
failWorkflow(transportRoot, marker, alias,
|
|
314
|
+
`could not parse workflow prose. Compiler returned:\n${result.stdout.slice(0, 800)}`);
|
|
315
|
+
ctx.log('workflow_compile_invalid', { marker: marker.markerRelPath });
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
// Resolve fanout + synthesize targets via the addressing layer.
|
|
319
|
+
// Compile model may emit bare names; resolver normalizes to qualified
|
|
320
|
+
// (single unambiguous match) or fails the workflow with a pick-list.
|
|
321
|
+
const fanoutRes = resolveModelRef(parsed.fanout.to, ctx.registry);
|
|
322
|
+
if (fanoutRes.kind !== 'ok') {
|
|
323
|
+
failWorkflow(transportRoot, marker, alias,
|
|
324
|
+
`compiled plan fanout: ${formatResolutionError(fanoutRes)}`);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
const synthRes = resolveModelRef(parsed.synthesize.to, ctx.registry);
|
|
328
|
+
if (synthRes.kind !== 'ok') {
|
|
329
|
+
failWorkflow(transportRoot, marker, alias,
|
|
330
|
+
`compiled plan synthesize: ${formatResolutionError(synthRes)}`);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
// Normalize plan to qualified form before persisting so every
|
|
334
|
+
// subsequent tick sees the same addressing.
|
|
335
|
+
parsed.fanout.to = fanoutRes.model.qualified;
|
|
336
|
+
parsed.synthesize.to = synthRes.model.qualified;
|
|
337
|
+
writeFileSync(planPath(transportRoot, marker.childChannelUuid), JSON.stringify(parsed, null, 2) + '\n');
|
|
338
|
+
plan = parsed;
|
|
339
|
+
ctx.log('workflow_compiled', {
|
|
340
|
+
marker: marker.markerRelPath,
|
|
341
|
+
fanout: `${plan.fanout.to}x${plan.fanout.count}`,
|
|
342
|
+
synthesize: plan.synthesize.to,
|
|
343
|
+
});
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Phase 2: fanout dispatch.
|
|
348
|
+
//
|
|
349
|
+
// Distribution: read the dispatcher registry to find every dispatcher
|
|
350
|
+
// claiming the fanout model. Round-robin the N sub-primitives across
|
|
351
|
+
// them, scoping each as `to: <model>@<alias>`. Without this, bare
|
|
352
|
+
// `to: <model>` dispatches get claimed by EVERY dispatcher (at-least-
|
|
353
|
+
// once activation), turning a fan-out into N× duplicated work for 1×
|
|
354
|
+
// throughput — defeating the multi-host value prop entirely.
|
|
355
|
+
//
|
|
356
|
+
// Fallback: if the registry is empty (single-host case with no
|
|
357
|
+
// dispatcher publishing, or a transient race between dispatcher start
|
|
358
|
+
// and workflow dispatch), use the bare recipient. Single-host: only
|
|
359
|
+
// one dispatcher claims anyway, so bare is safe. Empty registry on
|
|
360
|
+
// multi-host: degrade to current duplicate-work behavior with a logged
|
|
361
|
+
// warning rather than failing the workflow.
|
|
362
|
+
const fanouts = workflowDispatches(transportRoot, marker.childChannelUuid, alias, 'fanout');
|
|
363
|
+
if (fanouts.length === 0) {
|
|
364
|
+
const candidates = dispatchersForModel(transportRoot, plan.fanout.to);
|
|
365
|
+
const distribution = candidates.length === 0 ? null : candidates;
|
|
366
|
+
if (distribution === null) {
|
|
367
|
+
ctx.log('workflow_fanout_no_registry_fallback', {
|
|
368
|
+
marker: marker.markerRelPath,
|
|
369
|
+
model: plan.fanout.to,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
for (let i = 0; i < plan.fanout.count; i++) {
|
|
373
|
+
const to = distribution === null
|
|
374
|
+
? plan.fanout.to
|
|
375
|
+
: `${plan.fanout.to}@${distribution[i % distribution.length]}`;
|
|
376
|
+
writeRuntimeMessage({
|
|
377
|
+
transportRoot,
|
|
378
|
+
channelUuid: marker.childChannelUuid,
|
|
379
|
+
from: fromIdentity,
|
|
380
|
+
to,
|
|
381
|
+
body: plan.fanout.body,
|
|
382
|
+
workflowPhase: 'fanout',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
ctx.log('workflow_fanout_dispatched', {
|
|
386
|
+
marker: marker.markerRelPath,
|
|
387
|
+
count: plan.fanout.count,
|
|
388
|
+
to: plan.fanout.to,
|
|
389
|
+
dispatchers: distribution ?? 'bare',
|
|
390
|
+
});
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Phase 3: wait for fanout replies. Failed replies count toward the total
|
|
395
|
+
// so we don't stall on individual worker failures — the synthesizer sees
|
|
396
|
+
// them as FAILED candidates and decides.
|
|
397
|
+
const fanoutRelPaths = fanouts.map((m) => m.relPath);
|
|
398
|
+
const fanoutReplies = repliesTo(transportRoot, marker.childChannelUuid, fanoutRelPaths);
|
|
399
|
+
if (fanoutReplies.length < plan.fanout.count) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Phase 4: synthesize dispatch.
|
|
404
|
+
//
|
|
405
|
+
// Pin to one specific dispatcher so we don't duplicate the synthesis
|
|
406
|
+
// (which costs another model call and emits another routed reply).
|
|
407
|
+
// Pick deterministically — first alias in sorted-claim order — so the
|
|
408
|
+
// assignment is reproducible. Falls back to bare on empty registry,
|
|
409
|
+
// accepting the duplication risk (already documented for fanout above).
|
|
410
|
+
const synthesizes = workflowDispatches(transportRoot, marker.childChannelUuid, alias, 'synthesize');
|
|
411
|
+
if (synthesizes.length === 0) {
|
|
412
|
+
const synthCandidates = dispatchersForModel(transportRoot, plan.synthesize.to);
|
|
413
|
+
const synthTo = synthCandidates.length === 0
|
|
414
|
+
? plan.synthesize.to
|
|
415
|
+
: `${plan.synthesize.to}@${synthCandidates[0]}`;
|
|
416
|
+
const candidatesText = fanoutReplies
|
|
417
|
+
.slice()
|
|
418
|
+
.sort((a, b) => a.relPath.localeCompare(b.relPath))
|
|
419
|
+
.map((m, i) => {
|
|
420
|
+
const failed = m.data['failed'] === true ? ' (FAILED)' : '';
|
|
421
|
+
return `--- candidate ${i + 1}${failed} ---\n${m.body}`;
|
|
422
|
+
})
|
|
423
|
+
.join('\n\n');
|
|
424
|
+
const synthBody = `${plan.synthesize.body}\n\n${candidatesText}`;
|
|
425
|
+
writeRuntimeMessage({
|
|
426
|
+
transportRoot,
|
|
427
|
+
channelUuid: marker.childChannelUuid,
|
|
428
|
+
from: fromIdentity,
|
|
429
|
+
to: synthTo,
|
|
430
|
+
body: synthBody,
|
|
431
|
+
workflowPhase: 'synthesize',
|
|
432
|
+
});
|
|
433
|
+
ctx.log('workflow_synthesize_dispatched', {
|
|
434
|
+
marker: marker.markerRelPath,
|
|
435
|
+
to: synthTo,
|
|
436
|
+
candidates: fanoutReplies.length,
|
|
437
|
+
});
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Phase 5: wait for synthesis reply.
|
|
442
|
+
const synthRelPaths = synthesizes.map((m) => m.relPath);
|
|
443
|
+
const synthReplies = repliesTo(transportRoot, marker.childChannelUuid, synthRelPaths);
|
|
444
|
+
if (synthReplies.length === 0) return false;
|
|
445
|
+
|
|
446
|
+
// Phase 6: route final reply back to the operator who launched the workflow.
|
|
447
|
+
const finalSource = synthReplies
|
|
448
|
+
.slice()
|
|
449
|
+
.sort((a, b) => a.relPath.localeCompare(b.relPath))[0]!;
|
|
450
|
+
const finalFailed = finalSource.data['failed'] === true;
|
|
451
|
+
writeRuntimeMessage({
|
|
452
|
+
transportRoot,
|
|
453
|
+
channelUuid: marker.parentChannelUuid,
|
|
454
|
+
from: fromIdentity,
|
|
455
|
+
to: marker.markerFrom,
|
|
456
|
+
body: finalSource.body,
|
|
457
|
+
re: marker.markerRelPath,
|
|
458
|
+
failed: finalFailed
|
|
459
|
+
? { error: typeof finalSource.data['error'] === 'string'
|
|
460
|
+
? (finalSource.data['error'] as string)
|
|
461
|
+
: 'synthesize failed' }
|
|
462
|
+
: undefined,
|
|
463
|
+
});
|
|
464
|
+
markComplete(transportRoot, marker.childChannelUuid);
|
|
465
|
+
ctx.log('workflow_complete', {
|
|
466
|
+
marker: marker.markerRelPath,
|
|
467
|
+
final_failed: finalFailed,
|
|
468
|
+
});
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Crosstalk transport
|
|
2
|
+
|
|
3
|
+
You are inside a Crosstalk transport — a git repo that carries async messages between AI agents and humans across machines.
|
|
4
|
+
|
|
5
|
+
Read these before doing anything here:
|
|
6
|
+
|
|
7
|
+
1. `PROTOCOL.md` — how to behave when dispatched. Start here.
|
|
8
|
+
2. `CROSSTALK.md` — the full protocol spec, if you need details.
|
|
9
|
+
|
|
10
|
+
Do not edit files under `data/channels/` by hand — messages are written only by `crosstalk message send` (and `crosstalk workflow run` for workflows) and the dispatcher.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
7
|