@cordfuse/crosstalk 6.0.0-alpha.9 → 7.0.0-alpha.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/README.md +26 -0
- package/bin/crosstalk.js +60 -74
- package/commands/channel.js +69 -0
- package/commands/chat.js +159 -0
- package/commands/down.js +37 -0
- package/commands/init.js +105 -0
- package/commands/logs.js +39 -0
- package/commands/pull.js +24 -0
- package/commands/replies.js +39 -0
- package/commands/restart.js +24 -0
- package/commands/run.js +121 -0
- package/commands/status.js +52 -0
- package/commands/up.js +129 -0
- package/commands/version.js +30 -0
- package/lib/api-client.js +80 -0
- package/lib/argv.js +28 -0
- package/lib/errors.js +19 -0
- package/lib/transport.js +51 -0
- package/package.json +5 -21
- package/src/activation.ts +0 -104
- package/src/actor.ts +0 -131
- package/src/attach.ts +0 -118
- package/src/channel.ts +0 -49
- package/src/chat.ts +0 -142
- package/src/dispatch.ts +0 -531
- package/src/dlq.ts +0 -216
- package/src/filenames.ts +0 -28
- package/src/frontmatter.ts +0 -26
- package/src/init.ts +0 -138
- package/src/open.ts +0 -207
- package/src/replies.ts +0 -59
- package/src/send.ts +0 -122
- package/src/state.ts +0 -173
- package/src/status.ts +0 -75
- package/src/stop.ts +0 -37
- package/src/transport.ts +0 -213
- package/src/turnq.ts +0 -91
- package/src/upgrade.ts +0 -211
- package/src/wake.ts +0 -7
- package/template/CLAUDE.md +0 -12
- package/template/gitignore +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -298
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -80
- package/template/upstream/actors/concierge.md +0 -36
package/src/dispatch.ts
DELETED
|
@@ -1,531 +0,0 @@
|
|
|
1
|
-
// crosstalk dispatch — the loop.
|
|
2
|
-
//
|
|
3
|
-
// Tick: pull → for each local actor, scan channels for messages past the
|
|
4
|
-
// cursor → decideWake (activation.ts, the one rule) → invoke the actor's
|
|
5
|
-
// CLI per batch → write replies (re: linked per sender) → commit+push.
|
|
6
|
-
//
|
|
7
|
-
// Only the commit+push is locked, and the lock is advisory (turnq.ts) —
|
|
8
|
-
// git arbitrates correctness. Cursors, DLQ, heartbeat and the error log
|
|
9
|
-
// live in the machine-local state dir (state.ts), so a tick's commit only
|
|
10
|
-
// ever contains data/ and there is no self-inflicted git deadlock to heal.
|
|
11
|
-
|
|
12
|
-
import { resolve, join, dirname } from 'path';
|
|
13
|
-
import { spawn } from 'child_process';
|
|
14
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync } from 'fs';
|
|
15
|
-
import { watch } from 'fs/promises';
|
|
16
|
-
import { fileURLToPath } from 'url';
|
|
17
|
-
import {
|
|
18
|
-
findHostFile,
|
|
19
|
-
loadActorProfile,
|
|
20
|
-
pickTier,
|
|
21
|
-
tokenizeCli,
|
|
22
|
-
type HostActorTiers,
|
|
23
|
-
type HostFile,
|
|
24
|
-
} from './actor.js';
|
|
25
|
-
import {
|
|
26
|
-
discoverChannels,
|
|
27
|
-
listChannelMessages,
|
|
28
|
-
gitPull,
|
|
29
|
-
gitCommitAndPush,
|
|
30
|
-
cursorBaseline,
|
|
31
|
-
newFilesSince,
|
|
32
|
-
hostFileCommit,
|
|
33
|
-
type ChannelMessage,
|
|
34
|
-
} from './transport.js';
|
|
35
|
-
import {
|
|
36
|
-
stateDir,
|
|
37
|
-
readCursor,
|
|
38
|
-
writeCursor,
|
|
39
|
-
writeHeartbeat,
|
|
40
|
-
writePidfile,
|
|
41
|
-
removePidfile,
|
|
42
|
-
logError,
|
|
43
|
-
} from './state.js';
|
|
44
|
-
import { recipients, reList, decideWake, splitForConcurrency } from './activation.js';
|
|
45
|
-
import { now, messageFilename } from './filenames.js';
|
|
46
|
-
import { serializeFrontmatter } from './frontmatter.js';
|
|
47
|
-
import { withLock } from './turnq.js';
|
|
48
|
-
import { writeDlqEntry, isQuarantined } from './dlq.js';
|
|
49
|
-
|
|
50
|
-
const RUNTIME_VERSION: string = (() => {
|
|
51
|
-
try {
|
|
52
|
-
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
53
|
-
return (JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }).version ?? 'unknown';
|
|
54
|
-
} catch {
|
|
55
|
-
return 'unknown';
|
|
56
|
-
}
|
|
57
|
-
})();
|
|
58
|
-
|
|
59
|
-
const transportRoot = resolve(process.cwd());
|
|
60
|
-
const argv = process.argv.slice(2);
|
|
61
|
-
|
|
62
|
-
function flag(name: string): string | undefined {
|
|
63
|
-
const i = argv.indexOf(name);
|
|
64
|
-
if (i === -1 || i === argv.length - 1) return undefined;
|
|
65
|
-
return argv[i + 1];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const onceMode = argv.includes('--once');
|
|
69
|
-
const jsonMode = argv.includes('--json');
|
|
70
|
-
const hostOverride = flag('--host');
|
|
71
|
-
const pollSeconds = Number(flag('--poll')) || 30;
|
|
72
|
-
const logFile = flag('--log-file');
|
|
73
|
-
|
|
74
|
-
const CLI_TIMEOUT_MS = 5 * 60_000;
|
|
75
|
-
const MAX_BACKOFF_MULTIPLIER = 10;
|
|
76
|
-
const BACKOFF_GRACE = 2;
|
|
77
|
-
|
|
78
|
-
function log(event: string, fields: Record<string, unknown> = {}): void {
|
|
79
|
-
let line: string;
|
|
80
|
-
if (jsonMode) {
|
|
81
|
-
line = JSON.stringify({ ts: new Date().toISOString(), event, ...fields });
|
|
82
|
-
} else {
|
|
83
|
-
const tail = Object.entries(fields)
|
|
84
|
-
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
85
|
-
.join(' ');
|
|
86
|
-
line = `[${new Date().toISOString()}] ${event}${tail ? ' ' + tail : ''}`;
|
|
87
|
-
}
|
|
88
|
-
console.log(line);
|
|
89
|
-
if (logFile) {
|
|
90
|
-
try { appendFileSync(logFile, line + '\n'); } catch { /* best-effort */ }
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Config errors (bad host file, bad actor profile) repeat every tick until
|
|
95
|
-
// fixed — log each distinct one once per process run, not once per tick.
|
|
96
|
-
const loggedConfigErrors = new Set<string>();
|
|
97
|
-
function logConfigError(scope: string, message: string): void {
|
|
98
|
-
const key = `${scope}::${message}`;
|
|
99
|
-
if (loggedConfigErrors.has(key)) return;
|
|
100
|
-
loggedConfigErrors.add(key);
|
|
101
|
-
logError(transportRoot, 'parse', `${scope}: ${message}`);
|
|
102
|
-
log('config_error', { scope, message: message.slice(0, 200) });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const protocolPrompt = (() => {
|
|
106
|
-
const p = join(transportRoot, 'upstream', 'PROTOCOL.md');
|
|
107
|
-
return existsSync(p) ? readFileSync(p, 'utf-8').trim() : '';
|
|
108
|
-
})();
|
|
109
|
-
|
|
110
|
-
function composeSystemPrompt(actorPrompt: string): string {
|
|
111
|
-
return [protocolPrompt, actorPrompt].filter((p) => p.length > 0).join('\n\n---\n\n');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function actorConcurrency(tiers: HostActorTiers): number {
|
|
115
|
-
for (const value of Object.values(tiers)) {
|
|
116
|
-
if (typeof value === 'object' && typeof value.count === 'number' && value.count > 0) {
|
|
117
|
-
return value.count;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return 1;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function messageSender(msg: ChannelMessage): string {
|
|
124
|
-
return typeof msg.data['from'] === 'string' ? (msg.data['from'] as string) : 'unknown';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
interface CliResult {
|
|
128
|
-
status: number;
|
|
129
|
-
stdout: string;
|
|
130
|
-
stderr: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function invokeCli(
|
|
134
|
-
cli: string,
|
|
135
|
-
systemPrompt: string,
|
|
136
|
-
userMessage: string,
|
|
137
|
-
env: Record<string, string>,
|
|
138
|
-
): Promise<CliResult> {
|
|
139
|
-
return new Promise((res) => {
|
|
140
|
-
const fullPrompt = `${systemPrompt}\n\n---\n\n${userMessage}`;
|
|
141
|
-
const parts = tokenizeCli(cli);
|
|
142
|
-
if (parts.length === 0) {
|
|
143
|
-
res({ status: 1, stdout: '', stderr: 'tokenized cli is empty' });
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
// detached: new process group, so the timeout SIGKILL takes the actor's
|
|
147
|
-
// children with it — orphans writing to the transport after a timeout
|
|
148
|
-
// was an observed v5 hazard.
|
|
149
|
-
const child = spawn(parts[0]!, parts.slice(1), {
|
|
150
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
151
|
-
detached: true,
|
|
152
|
-
env: { ...process.env, ...env },
|
|
153
|
-
});
|
|
154
|
-
let stdout = '';
|
|
155
|
-
let stderr = '';
|
|
156
|
-
let resolved = false;
|
|
157
|
-
const timeout = setTimeout(() => {
|
|
158
|
-
if (resolved) return;
|
|
159
|
-
resolved = true;
|
|
160
|
-
try {
|
|
161
|
-
if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGKILL');
|
|
162
|
-
else child.kill('SIGKILL');
|
|
163
|
-
} catch {
|
|
164
|
-
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
165
|
-
}
|
|
166
|
-
res({ status: 124, stdout, stderr: stderr + '\n[timeout]' });
|
|
167
|
-
}, CLI_TIMEOUT_MS);
|
|
168
|
-
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
169
|
-
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
170
|
-
child.on('close', (code) => {
|
|
171
|
-
if (resolved) return;
|
|
172
|
-
resolved = true;
|
|
173
|
-
clearTimeout(timeout);
|
|
174
|
-
res({ status: code ?? 1, stdout, stderr });
|
|
175
|
-
});
|
|
176
|
-
child.on('error', (err) => {
|
|
177
|
-
if (resolved) return;
|
|
178
|
-
resolved = true;
|
|
179
|
-
clearTimeout(timeout);
|
|
180
|
-
res({ status: 1, stdout, stderr: stderr + '\n' + err.message });
|
|
181
|
-
});
|
|
182
|
-
child.stdin.on('error', () => { /* child closed stdin */ });
|
|
183
|
-
try { child.stdin.write(fullPrompt); } catch { /* same */ }
|
|
184
|
-
try { child.stdin.end(); } catch { /* ignore */ }
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function writeReply(
|
|
189
|
-
channelUuid: string,
|
|
190
|
-
fromActor: string,
|
|
191
|
-
toActor: string,
|
|
192
|
-
re: string | string[],
|
|
193
|
-
body: string,
|
|
194
|
-
): void {
|
|
195
|
-
const ts = now();
|
|
196
|
-
const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
|
|
197
|
-
mkdirSync(dir, { recursive: true });
|
|
198
|
-
const content = serializeFrontmatter(
|
|
199
|
-
{ from: fromActor, to: toActor, type: 'text', timestamp: ts.iso, re },
|
|
200
|
-
body,
|
|
201
|
-
);
|
|
202
|
-
writeFileSync(join(dir, messageFilename(ts)), content);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function formatBatchedUserMessage(msgs: ChannelMessage[]): string {
|
|
206
|
-
if (msgs.length === 1) return msgs[0]!.body;
|
|
207
|
-
const parts = [`You have ${msgs.length} new messages in this channel. Process them collectively and reply once.`];
|
|
208
|
-
for (let i = 0; i < msgs.length; i++) {
|
|
209
|
-
const m = msgs[i]!;
|
|
210
|
-
const ts = typeof m.data['timestamp'] === 'string' ? `, ts: ${m.data['timestamp']}` : '';
|
|
211
|
-
parts.push(`--- Message ${i + 1} of ${msgs.length} (from: ${messageSender(m)}, ref: ${m.relPath}${ts}) ---`);
|
|
212
|
-
parts.push(m.body);
|
|
213
|
-
}
|
|
214
|
-
return parts.join('\n\n');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
interface PendingDispatch {
|
|
218
|
-
actorName: string;
|
|
219
|
-
channelUuid: string;
|
|
220
|
-
msgs: ChannelMessage[];
|
|
221
|
-
tiers: HostActorTiers;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function dispatchOne(p: PendingDispatch): Promise<boolean> {
|
|
225
|
-
const firstMsg = p.msgs[0]!;
|
|
226
|
-
const lastMsg = p.msgs[p.msgs.length - 1]!;
|
|
227
|
-
|
|
228
|
-
if (isQuarantined(transportRoot, p.actorName, p.channelUuid, lastMsg.relPath)) {
|
|
229
|
-
log('dispatch_skipped_quarantined', {
|
|
230
|
-
actor: p.actorName,
|
|
231
|
-
channel: p.channelUuid.slice(0, 8),
|
|
232
|
-
msg: lastMsg.relPath,
|
|
233
|
-
});
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const preferredTier = typeof firstMsg.data['tier'] === 'string' ? (firstMsg.data['tier'] as string) : undefined;
|
|
238
|
-
let cli: string;
|
|
239
|
-
let profile;
|
|
240
|
-
try {
|
|
241
|
-
cli = pickTier(p.tiers, preferredTier).cli;
|
|
242
|
-
profile = loadActorProfile(transportRoot, p.actorName);
|
|
243
|
-
} catch (err) {
|
|
244
|
-
logConfigError(`actor:${p.actorName}`, (err as Error).message);
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
log('dispatch', {
|
|
249
|
-
actor: p.actorName,
|
|
250
|
-
channel: p.channelUuid.slice(0, 8),
|
|
251
|
-
batch_size: p.msgs.length,
|
|
252
|
-
first_msg: firstMsg.relPath,
|
|
253
|
-
last_msg: lastMsg.relPath,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
const result = await invokeCli(
|
|
257
|
-
cli,
|
|
258
|
-
composeSystemPrompt(profile.systemPrompt),
|
|
259
|
-
formatBatchedUserMessage(p.msgs),
|
|
260
|
-
{
|
|
261
|
-
CROSSTALK_DISPATCH_ACTOR: p.actorName,
|
|
262
|
-
CROSSTALK_DISPATCH_CHANNEL: p.channelUuid,
|
|
263
|
-
// Every relPath in the batch — `crosstalk send` records them all as
|
|
264
|
-
// the reply's re: list, so batching never loses an answered message.
|
|
265
|
-
CROSSTALK_DISPATCH_RE: p.msgs.map((m) => m.relPath).join(','),
|
|
266
|
-
},
|
|
267
|
-
);
|
|
268
|
-
|
|
269
|
-
if (result.status !== 0) {
|
|
270
|
-
const r = writeDlqEntry(
|
|
271
|
-
transportRoot,
|
|
272
|
-
p.actorName,
|
|
273
|
-
p.channelUuid,
|
|
274
|
-
lastMsg.relPath,
|
|
275
|
-
`cli exit=${result.status}\n${result.stderr.slice(0, 1000)}`,
|
|
276
|
-
);
|
|
277
|
-
log('dispatch_failed', {
|
|
278
|
-
actor: p.actorName,
|
|
279
|
-
channel: p.channelUuid.slice(0, 8),
|
|
280
|
-
batch_size: p.msgs.length,
|
|
281
|
-
dlq_id: r.id,
|
|
282
|
-
attempts: r.attempts,
|
|
283
|
-
quarantined: r.quarantined,
|
|
284
|
-
exit: result.status,
|
|
285
|
-
});
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const reply = result.stdout.trim();
|
|
290
|
-
if (reply.length === 0) {
|
|
291
|
-
// Legitimate: the actor routed its answer via `crosstalk send` (which
|
|
292
|
-
// auto-links re:). If it truly did nothing, the asker's `crosstalk
|
|
293
|
-
// replies` stays PENDING — visible, not silently lost.
|
|
294
|
-
log('dispatch_silent', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length });
|
|
295
|
-
log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: false });
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// One reply per distinct sender, re:-linked to EVERY message that sender
|
|
300
|
-
// had in the batch — the asker's activation rule fires, and `crosstalk
|
|
301
|
-
// replies` sees each individual message as answered.
|
|
302
|
-
const bySender = new Map<string, string[]>();
|
|
303
|
-
for (const m of p.msgs) {
|
|
304
|
-
const sender = messageSender(m);
|
|
305
|
-
bySender.set(sender, [...(bySender.get(sender) ?? []), m.relPath]);
|
|
306
|
-
}
|
|
307
|
-
bySender.delete('unknown');
|
|
308
|
-
if (bySender.size === 0) bySender.set(messageSender(firstMsg), [firstMsg.relPath]);
|
|
309
|
-
for (const [sender, relPaths] of bySender) {
|
|
310
|
-
writeReply(p.channelUuid, p.actorName, sender, relPaths.length === 1 ? relPaths[0]! : relPaths, reply);
|
|
311
|
-
}
|
|
312
|
-
log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: true });
|
|
313
|
-
return true;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
interface TickResult {
|
|
317
|
-
didWork: boolean;
|
|
318
|
-
infraOk: boolean;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
async function dispatchTick(): Promise<TickResult> {
|
|
322
|
-
writeHeartbeat(transportRoot, RUNTIME_VERSION);
|
|
323
|
-
let infraOk = true;
|
|
324
|
-
|
|
325
|
-
const pullResult = gitPull(transportRoot);
|
|
326
|
-
if (!pullResult.ok) {
|
|
327
|
-
// Skip the whole tick: a failed pull can leave origin/HEAD (the cursor
|
|
328
|
-
// baseline) ahead of the working tree, and scanning against that would
|
|
329
|
-
// advance cursors past messages that never materialized.
|
|
330
|
-
logError(transportRoot, 'git_pull', pullResult.error ?? 'unknown');
|
|
331
|
-
log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
|
|
332
|
-
return { didWork: false, infraOk: false };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
let host: HostFile;
|
|
336
|
-
try {
|
|
337
|
-
host = findHostFile(transportRoot, hostOverride);
|
|
338
|
-
} catch (err) {
|
|
339
|
-
logConfigError('host', (err as Error).message);
|
|
340
|
-
return { didWork: false, infraOk };
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Cursors are commit hashes, not relPaths: filenames order by sender
|
|
344
|
-
// timestamp but arrive in push order, so a relPath cursor can advance
|
|
345
|
-
// past a slower writer's earlier-stamped message and lose it forever.
|
|
346
|
-
// "New since cursor" is asked of git, which records arrival truthfully.
|
|
347
|
-
const head = cursorBaseline(transportRoot);
|
|
348
|
-
if (!head) {
|
|
349
|
-
logError(transportRoot, 'other', 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
|
|
350
|
-
return { didWork: false, infraOk: false };
|
|
351
|
-
}
|
|
352
|
-
// diff results keyed by cursor commit (shared across actors on the same
|
|
353
|
-
// cursor); null = commit unknown to this clone -> full re-scan.
|
|
354
|
-
const addedSince = new Map<string, Set<string> | null>();
|
|
355
|
-
|
|
356
|
-
let didWork = false;
|
|
357
|
-
const channels = discoverChannels(transportRoot);
|
|
358
|
-
|
|
359
|
-
for (const actorName of Object.keys(host.actors)) {
|
|
360
|
-
const tiers = host.actors[actorName]!;
|
|
361
|
-
const concurrency = actorConcurrency(tiers);
|
|
362
|
-
const pending: PendingDispatch[] = [];
|
|
363
|
-
|
|
364
|
-
for (const channelUuid of channels) {
|
|
365
|
-
const persistedCursor = readCursor(transportRoot, actorName, channelUuid);
|
|
366
|
-
if (persistedCursor === head) continue;
|
|
367
|
-
|
|
368
|
-
// First encounter: seed to the commit that introduced this actor's host
|
|
369
|
-
// file. Messages sent after the host joined are delivered (store-and-
|
|
370
|
-
// forward); pre-join history is ignored. Seeding to HEAD would silently
|
|
371
|
-
// drop messages sent while the dispatcher was offline — the wrong trade.
|
|
372
|
-
// Fall through after seeding so this tick processes the post-join backlog
|
|
373
|
-
// (otherwise `--once` users hit a seed-then-dispatch two-tick gotcha).
|
|
374
|
-
let cursor: string;
|
|
375
|
-
if (persistedCursor === null) {
|
|
376
|
-
const joinCommit = hostFileCommit(transportRoot, host.alias);
|
|
377
|
-
cursor = joinCommit ?? head;
|
|
378
|
-
writeCursor(transportRoot, actorName, channelUuid, cursor);
|
|
379
|
-
if (cursor === head) continue;
|
|
380
|
-
} else {
|
|
381
|
-
cursor = persistedCursor;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const messages = listChannelMessages(transportRoot, channelUuid);
|
|
385
|
-
const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
|
|
386
|
-
const senderOf = (relPath: string) => senderByRelPath.get(relPath);
|
|
387
|
-
|
|
388
|
-
let added = addedSince.get(cursor);
|
|
389
|
-
if (added === undefined) {
|
|
390
|
-
const files = newFilesSince(transportRoot, cursor);
|
|
391
|
-
added = files === null ? null : new Set(files);
|
|
392
|
-
addedSince.set(cursor, added);
|
|
393
|
-
if (added === null) {
|
|
394
|
-
logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
let post = messages;
|
|
398
|
-
if (added !== null) {
|
|
399
|
-
const prefix = `data/channels/${channelUuid}/`;
|
|
400
|
-
post = messages.filter((m) => added.has(prefix + m.relPath));
|
|
401
|
-
}
|
|
402
|
-
if (post.length === 0) {
|
|
403
|
-
writeCursor(transportRoot, actorName, channelUuid, head);
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const batch: ChannelMessage[] = [];
|
|
408
|
-
for (const msg of post) {
|
|
409
|
-
if (msg.data['type'] !== 'text') continue;
|
|
410
|
-
const decision = decideWake(
|
|
411
|
-
{
|
|
412
|
-
from: messageSender(msg),
|
|
413
|
-
to: recipients(msg.data['to']),
|
|
414
|
-
re: reList(msg.data['re']),
|
|
415
|
-
},
|
|
416
|
-
actorName,
|
|
417
|
-
host.alias,
|
|
418
|
-
senderOf,
|
|
419
|
-
);
|
|
420
|
-
if (decision === 'wake') {
|
|
421
|
-
batch.push(msg);
|
|
422
|
-
} else if (decision === 'wrong-host') {
|
|
423
|
-
log('host_routing_mismatch', {
|
|
424
|
-
actor: actorName,
|
|
425
|
-
this_host: host.alias,
|
|
426
|
-
channel: channelUuid.slice(0, 8),
|
|
427
|
-
msg: msg.relPath,
|
|
428
|
-
to: recipients(msg.data['to']),
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
if (batch.length === 0) {
|
|
434
|
-
writeCursor(transportRoot, actorName, channelUuid, head);
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
for (const g of splitForConcurrency(batch, concurrency)) {
|
|
438
|
-
pending.push({ actorName, channelUuid, msgs: g, tiers });
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Waves of `concurrency` parallel CLI invocations. The cursor advances
|
|
443
|
-
// to the scanned commit whether each batch succeeded or DLQ'd —
|
|
444
|
-
// at-least-once was attempted; `crosstalk dlq --retry` rewinds the
|
|
445
|
-
// cursor explicitly. A crash mid-wave leaves the cursor behind, so the
|
|
446
|
-
// whole span replays next tick (at-least-once, never lost).
|
|
447
|
-
for (let i = 0; i < pending.length; i += concurrency) {
|
|
448
|
-
const wave = pending.slice(i, i + concurrency);
|
|
449
|
-
const results = await Promise.all(wave.map((p) => dispatchOne(p)));
|
|
450
|
-
if (results.some(Boolean)) didWork = true;
|
|
451
|
-
}
|
|
452
|
-
for (const p of pending) {
|
|
453
|
-
writeCursor(transportRoot, p.actorName, p.channelUuid, head);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
if (didWork) {
|
|
458
|
-
const pushResult = await withLock(transportRoot, 'git', async () =>
|
|
459
|
-
gitCommitAndPush(transportRoot, `dispatch: replies ${new Date().toISOString()}`),
|
|
460
|
-
);
|
|
461
|
-
if (!pushResult.ok && pushResult.error) {
|
|
462
|
-
logError(transportRoot, pushResult.committed ? 'git_push' : 'git_commit', pushResult.error);
|
|
463
|
-
log('git_push_failed', { committed_locally: pushResult.committed, error: pushResult.error.slice(0, 200) });
|
|
464
|
-
infraOk = false;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return { didWork, infraOk };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
async function waitForWakeOrTimeout(ms: number): Promise<void> {
|
|
472
|
-
const dir = stateDir(transportRoot);
|
|
473
|
-
const ac = new AbortController();
|
|
474
|
-
const timer = setTimeout(() => ac.abort(), ms);
|
|
475
|
-
try {
|
|
476
|
-
const watcher = watch(dir, { signal: ac.signal });
|
|
477
|
-
for await (const ev of watcher) {
|
|
478
|
-
if (ev.filename === 'wake.signal') return;
|
|
479
|
-
}
|
|
480
|
-
} catch {
|
|
481
|
-
/* abort = timeout */
|
|
482
|
-
} finally {
|
|
483
|
-
clearTimeout(timer);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
async function main(): Promise<void> {
|
|
488
|
-
writePidfile(transportRoot);
|
|
489
|
-
const cleanup = () => removePidfile(transportRoot);
|
|
490
|
-
process.on('exit', cleanup);
|
|
491
|
-
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
492
|
-
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
493
|
-
|
|
494
|
-
log('dispatch_start', { transport: transportRoot, version: RUNTIME_VERSION, state_dir: stateDir(transportRoot) });
|
|
495
|
-
if (onceMode) {
|
|
496
|
-
await dispatchTick();
|
|
497
|
-
process.exit(0);
|
|
498
|
-
}
|
|
499
|
-
log('dispatch_running', { quiet_poll_s: pollSeconds });
|
|
500
|
-
|
|
501
|
-
let consecutiveInfraFailures = 0;
|
|
502
|
-
while (true) {
|
|
503
|
-
try {
|
|
504
|
-
const r = await dispatchTick();
|
|
505
|
-
if (r.infraOk) {
|
|
506
|
-
if (consecutiveInfraFailures > 0) log('backoff_cleared', { previous_failures: consecutiveInfraFailures });
|
|
507
|
-
consecutiveInfraFailures = 0;
|
|
508
|
-
} else {
|
|
509
|
-
consecutiveInfraFailures++;
|
|
510
|
-
}
|
|
511
|
-
const beyondGrace = Math.max(0, consecutiveInfraFailures - BACKOFF_GRACE);
|
|
512
|
-
const backoffFactor = Math.min(MAX_BACKOFF_MULTIPLIER, 2 ** beyondGrace);
|
|
513
|
-
if (backoffFactor > 1) {
|
|
514
|
-
log('backoff_active', { consecutive_failures: consecutiveInfraFailures, factor: backoffFactor });
|
|
515
|
-
}
|
|
516
|
-
if (r.didWork) {
|
|
517
|
-
await new Promise((res) => setTimeout(res, 1_000 * backoffFactor));
|
|
518
|
-
} else {
|
|
519
|
-
await waitForWakeOrTimeout(pollSeconds * 1_000 * backoffFactor);
|
|
520
|
-
}
|
|
521
|
-
} catch (err) {
|
|
522
|
-
const msg = (err as Error).message;
|
|
523
|
-
logError(transportRoot, 'other', `tick error: ${msg}`);
|
|
524
|
-
log('tick_error', { message: msg });
|
|
525
|
-
consecutiveInfraFailures++;
|
|
526
|
-
await new Promise((res) => setTimeout(res, pollSeconds * 1_000));
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
main();
|