@cordfuse/crosstalk 5.0.0-alpha.7 → 6.0.0-alpha.10

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 (58) hide show
  1. package/GUIDE-CLI.md +298 -0
  2. package/GUIDE-PROMPTS.md +132 -0
  3. package/README.md +139 -0
  4. package/bin/crosstalk.js +51 -80
  5. package/package.json +9 -5
  6. package/src/activation.ts +104 -0
  7. package/src/actor.ts +29 -4
  8. package/src/attach.ts +1 -1
  9. package/src/channel.ts +8 -21
  10. package/src/chat.ts +52 -115
  11. package/src/dispatch.ts +288 -660
  12. package/src/dlq.ts +89 -136
  13. package/src/init.ts +23 -42
  14. package/src/open.ts +55 -31
  15. package/src/replies.ts +59 -0
  16. package/src/send.ts +87 -72
  17. package/src/state.ts +173 -0
  18. package/src/status.ts +18 -57
  19. package/src/stop.ts +37 -0
  20. package/src/transport.ts +81 -198
  21. package/src/turnq.ts +64 -32
  22. package/src/upgrade.ts +9 -11
  23. package/src/wake.ts +5 -6
  24. package/template/CLAUDE.md +12 -2
  25. package/template/gitignore +4 -0
  26. package/template/upstream/CROSSTALK-VERSION +1 -1
  27. package/template/upstream/CROSSTALK.md +172 -463
  28. package/template/upstream/OPERATOR.md +9 -9
  29. package/template/upstream/PROTOCOL.md +64 -244
  30. package/template/upstream/actors/concierge.md +24 -118
  31. package/src/cursor.ts +0 -48
  32. package/template/.amazonq/rules/crosstalk.md +0 -2
  33. package/template/.continue/rules/crosstalk.md +0 -7
  34. package/template/.cursor/rules/crosstalk.mdc +0 -7
  35. package/template/.github/copilot-instructions.md +0 -2
  36. package/template/.windsurfrules +0 -2
  37. package/template/AGENTS.md +0 -2
  38. package/template/ANTIGRAVITY.md +0 -2
  39. package/template/GEMINI.md +0 -2
  40. package/template/OPENCODE.md +0 -2
  41. package/template/QWEN.md +0 -2
  42. package/template/README.md +0 -22
  43. package/template/local/CROSSTALK.md +0 -4
  44. package/template/upstream/JITTER.md +0 -24
  45. package/template/upstream/actors/cloud-architect.md +0 -83
  46. package/template/upstream/actors/devops-engineer.md +0 -83
  47. package/template/upstream/actors/documentation-engineer.md +0 -107
  48. package/template/upstream/actors/infrastructure-engineer.md +0 -83
  49. package/template/upstream/actors/junior-developer.md +0 -83
  50. package/template/upstream/actors/precise-generalist.md +0 -48
  51. package/template/upstream/actors/product-manager.md +0 -83
  52. package/template/upstream/actors/qa-engineer.md +0 -83
  53. package/template/upstream/actors/security-engineer.md +0 -92
  54. package/template/upstream/actors/senior-generalist-engineer.md +0 -111
  55. package/template/upstream/actors/senior-software-engineer.md +0 -94
  56. package/template/upstream/actors/skeptic.md +0 -89
  57. package/template/upstream/actors/technical-writer.md +0 -89
  58. package/template/upstream/actors/ux-designer.md +0 -83
package/src/dispatch.ts CHANGED
@@ -1,30 +1,19 @@
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
+
1
12
  import { resolve, join, dirname } from 'path';
2
13
  import { spawn } from 'child_process';
3
- import {
4
- mkdirSync,
5
- writeFileSync,
6
- readFileSync,
7
- existsSync,
8
- appendFileSync,
9
- openSync,
10
- closeSync,
11
- } from 'fs';
14
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync } from 'fs';
12
15
  import { watch } from 'fs/promises';
13
16
  import { fileURLToPath } from 'url';
14
-
15
- // Read runtime version from the installed package's package.json at startup
16
- // so dispatch_start logs and heartbeat content always match the actual
17
- // installed @cordfuse/crosstalk version. Avoids hand-editing on every release.
18
- const RUNTIME_VERSION: string = (() => {
19
- try {
20
- const thisFileDir = dirname(fileURLToPath(import.meta.url));
21
- const pkgPath = join(thisFileDir, '..', 'package.json');
22
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
23
- return pkg.version ?? 'unknown';
24
- } catch {
25
- return 'unknown';
26
- }
27
- })();
28
17
  import {
29
18
  findHostFile,
30
19
  loadActorProfile,
@@ -38,15 +27,34 @@ import {
38
27
  listChannelMessages,
39
28
  gitPull,
40
29
  gitCommitAndPush,
41
- writeErrorLog,
42
- sweepStaleReadReceipts,
30
+ cursorBaseline,
31
+ newFilesSince,
32
+ hostFileCommit,
43
33
  type ChannelMessage,
44
34
  } from './transport.js';
45
- import { readCursor, writeCursor } from './cursor.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';
46
45
  import { now, messageFilename } from './filenames.js';
47
46
  import { serializeFrontmatter } from './frontmatter.js';
48
47
  import { withLock } from './turnq.js';
49
- import { writeDlqEntry, isQuarantined, isActorQuarantined } from './dlq.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
+ })();
50
58
 
51
59
  const transportRoot = resolve(process.cwd());
52
60
  const argv = process.argv.slice(2);
@@ -63,27 +71,9 @@ const hostOverride = flag('--host');
63
71
  const pollSeconds = Number(flag('--poll')) || 30;
64
72
  const logFile = flag('--log-file');
65
73
 
66
- // Backoff config persistent infra failures (git pull/push) trigger
67
- // exponential delay. Reset on any successful pull+push cycle.
68
- const MAX_BACKOFF_MULTIPLIER = 10; // cap: pollSeconds * 10
69
- const BACKOFF_GRACE = 2; // first N failures don't trigger backoff
70
-
71
- // Per-tick heal: when N consecutive infra failures pile up, the dispatch
72
- // loop is stuck in a deadlock that entrypoint's boot-time auto-recovery
73
- // can't break (because dispatch is already running). At HEAL_THRESHOLD
74
- // consecutive failures, attempt a `git fetch && reset --hard origin/<branch>
75
- // && clean -fd` from inside the tick loop. Mirrors the entrypoint logic.
76
- // Throttled — won't reattempt until fully BACKOFF_GRACE+HEAL_THRESHOLD more
77
- // failures pile up after a heal, to avoid heal-loop-storms.
78
- const HEAL_THRESHOLD = 5;
79
- let lastHealAtFailureCount = 0;
80
-
81
- // Stale-read-receipt sweep config — runs at most every SWEEP_INTERVAL_MS
82
- // of wall-clock to surface read receipts that never produced a reply
83
- // (indicates dispatch crashed mid-tick or CLI hung silently).
84
- const SWEEP_INTERVAL_MS = 5 * 60_000;
85
- const STALE_RECEIPT_THRESHOLD_MS = 5 * 60_000;
86
- let lastSweepAt = 0;
74
+ const CLI_TIMEOUT_MS = 5 * 60_000;
75
+ const MAX_BACKOFF_MULTIPLIER = 10;
76
+ const BACKOFF_GRACE = 2;
87
77
 
88
78
  function log(event: string, fields: Record<string, unknown> = {}): void {
89
79
  let line: string;
@@ -101,185 +91,24 @@ function log(event: string, fields: Record<string, unknown> = {}): void {
101
91
  }
102
92
  }
103
93
 
104
- function writeHeartbeat(): void {
105
- try {
106
- const dir = join(transportRoot, '.turnq');
107
- mkdirSync(dir, { recursive: true });
108
- const data = { ts: new Date().toISOString(), pid: process.pid, version: RUNTIME_VERSION };
109
- writeFileSync(join(dir, 'heartbeat'), JSON.stringify(data) + '\n');
110
- } catch { /* best-effort */ }
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) });
111
103
  }
112
104
 
113
- function loadProtocolPrompt(): string {
105
+ const protocolPrompt = (() => {
114
106
  const p = join(transportRoot, 'upstream', 'PROTOCOL.md');
115
- if (!existsSync(p)) return '';
116
- return readFileSync(p, 'utf-8').trim();
117
- }
118
-
119
- const protocolPrompt = loadProtocolPrompt();
120
-
121
- function recipients(toField: unknown): string[] {
122
- if (Array.isArray(toField)) return toField.map(String);
123
- if (typeof toField === 'string') return [toField];
124
- return [];
125
- }
126
-
127
- // A `to:` recipient is either a bare actor name (`junior-developer`) or
128
- // an actor@host pair (`junior-developer@cachy`). Bare names broadcast to
129
- // every host that declares the actor; @host narrows to one host.
130
- //
131
- // Documented in concierge.md "Host-aware routing"; honored by the runtime
132
- // as of alpha.7 step 1. Prior to this, the recipient string was matched
133
- // verbatim against the actor name, so `junior-developer@cachy` never
134
- // matched the cachy dispatcher's `junior-developer` actor declaration —
135
- // the harness's first cross-host bug.
136
- function extractActor(recipient: string): string {
137
- const at = recipient.indexOf('@');
138
- return at === -1 ? recipient : recipient.slice(0, at);
139
- }
140
-
141
- function targetHost(recipient: string): string | null {
142
- const at = recipient.indexOf('@');
143
- return at === -1 ? null : recipient.slice(at + 1);
144
- }
145
-
146
- // Does `recipientList` address `actorName` on `thisHost`? Returns the match
147
- // outcome plus a flag for "actor was named but every instance targeted a
148
- // different host" — useful as a diagnostic so silent wrong-host routes are
149
- // logged rather than dropped without trace.
150
- function matchHostRouting(
151
- recipientList: string[],
152
- actorName: string,
153
- thisHost: string,
154
- ): { addressed: boolean; wrongHost: boolean } {
155
- let addressed = false;
156
- let actorNamedAtAll = false;
157
- for (const r of recipientList) {
158
- if (extractActor(r) !== actorName) continue;
159
- actorNamedAtAll = true;
160
- const host = targetHost(r);
161
- if (host === null || host === thisHost) {
162
- addressed = true;
163
- break;
164
- }
165
- }
166
- return { addressed, wrongHost: !addressed && actorNamedAtAll };
167
- }
168
-
169
- // Host-agnostic actor name check, used by causality scans (isCausalReply,
170
- // hasPriorWork) where the question is "does this recipient list name actor
171
- // X at all?" — host doesn't matter because the `from` field of replies
172
- // doesn't carry a host suffix either.
173
- function namesActor(recipientList: string[], actorName: string): boolean {
174
- for (const r of recipientList) {
175
- if (extractActor(r) === actorName) return true;
176
- }
177
- return false;
178
- }
179
-
180
- // Declared lifecycle kind for a message. `work` (default for legacy messages
181
- // without the field) is the as-tagged intent. The runtime does NOT trust this
182
- // value directly for the activation decision — see effectiveKind() below.
183
- // Kept for use as the seed of the effective-kind computation.
184
- function messageKind(msg: ChannelMessage): 'work' | 'result' {
185
- const raw = msg.data['kind'];
186
- return raw === 'result' ? 'result' : 'work';
187
- }
188
-
189
- // Is `msg` causally a reply to a prior ask? True iff some message strictly
190
- // before `msg` was sent FROM one of `msg`'s recipients TO `msg`'s sender with
191
- // declared kind `work`. If so, `msg` is that recipient's answer coming back —
192
- // regardless of how its sender (a fallible LLM actor, or `crosstalk send`'s
193
- // `work` default) labelled it.
194
- //
195
- // Conservative on multi-recipient `to:` lists: if ANY recipient previously
196
- // tasked the sender, the message is treated as causally a reply for all
197
- // recipients. The per-addressee asymmetry in hasPriorWork (below) compensates
198
- // — only the recipient that actually asked wakes on it. Known v1 limitation:
199
- // genuine multi-recipient fan-out where one recipient happens to have prior
200
- // unrelated work to the sender will be demoted to result and suppress wakes
201
- // for the other recipients. Not observed in Monte Carlo; revisit if it
202
- // surfaces.
203
- function isCausalReply(channelMessages: ChannelMessage[], msg: ChannelMessage): boolean {
204
- const sender = typeof msg.data['from'] === 'string' ? msg.data['from'] : '';
205
- if (!sender) return false;
206
- const toList = recipients(msg.data['to']);
207
- for (const m of channelMessages) {
208
- if (m.relPath >= msg.relPath) break;
209
- // Read receipts are bookkeeping, never causal evidence. The activation
210
- // scan already filters them out before considering a message for
211
- // dispatch — this filter is the same guard at the causality-helper
212
- // level, so a receipt from one of msg's recipients to msg's sender
213
- // can't forge a false causal-reply edge (which would then demote a
214
- // legitimate `work` to `result` and silently skip it). This was the
215
- // alpha.7 step 2 finding from the cross-host harness — receipts
216
- // pre-existing in the channel from cachy's first dispatch burst
217
- // misclassified mac's subsequent fan-out msgs as replies.
218
- if (m.data['type'] === 'read') continue;
219
- const mFrom = typeof m.data['from'] === 'string' ? m.data['from'] : '';
220
- // Host-agnostic actor name match: `from` fields are bare actor names,
221
- // but `to` fields may include `@host` suffixes that don't change
222
- // causal semantics.
223
- if (!namesActor(toList, mFrom)) continue;
224
- if ((m.data['kind'] ?? 'work') === 'result') continue;
225
- if (namesActor(recipients(m.data['to']), sender)) return true;
226
- }
227
- return false;
228
- }
229
-
230
- // Effective lifecycle kind. The runtime INFERS kind from the causality graph
231
- // rather than trusting the declared field: a message that is causally a reply
232
- // is a `result` even if it was labelled `work` (actors routinely report
233
- // results via `crosstalk send`, which defaults to `work`, and that mislabel
234
- // forges false reply-causality edges → wake-up loops). Genuine unsolicited
235
- // tasks (kickoffs, fresh dispatches) have no prior opposite-direction work
236
- // and keep their `work` kind. See PROTOCOL.md "Message kinds".
237
- //
238
- // This is the load-bearing principle the rest of the activation rule rides
239
- // on: the dispatcher derives semantics from the interaction graph; it never
240
- // trusts an actor's declaration.
241
- function effectiveKind(channelMessages: ChannelMessage[], msg: ChannelMessage): 'work' | 'result' {
242
- if (messageKind(msg) === 'result') return 'result';
243
- return isCausalReply(channelMessages, msg) ? 'result' : 'work';
244
- }
245
-
246
- // Reply causality — does `addressee` have a prior `kind: work` outbound to
247
- // `sender` somewhere in the channel's history strictly before `before`? If
248
- // yes, an inbound `kind: result` from `sender` to `addressee` is the answer
249
- // to that ask, and the addressee should wake on it. If no, the result is
250
- // unsolicited from addressee's POV and is informational only.
251
- //
252
- // Uses effectiveKind (not messageKind) when checking prior messages — a
253
- // mislabeled "work" reply from a prior peer would otherwise forge a false
254
- // causality edge here, which was the ping-pong root.
255
- //
256
- // The channel is already sorted by relPath ascending in
257
- // listChannelMessages(), so the scan walks chronologically.
258
- function hasPriorWork(
259
- channelMessages: ChannelMessage[],
260
- addressee: string,
261
- sender: string,
262
- before: string,
263
- ): boolean {
264
- for (const m of channelMessages) {
265
- if (m.relPath >= before) break;
266
- // Same receipt filter as isCausalReply — a receipt from `addressee`
267
- // to `sender` would otherwise look like a prior work outbound and
268
- // forge a false causal edge here too. Defense against the same
269
- // bug class at every causality-walking helper.
270
- if (m.data['type'] === 'read') continue;
271
- if (typeof m.data['from'] !== 'string' || m.data['from'] !== addressee) continue;
272
- if (effectiveKind(channelMessages, m) !== 'work') continue;
273
- const toList = recipients(m.data['to']);
274
- if (namesActor(toList, sender)) return true;
275
- }
276
- return false;
277
- }
107
+ return existsSync(p) ? readFileSync(p, 'utf-8').trim() : '';
108
+ })();
278
109
 
279
110
  function composeSystemPrompt(actorPrompt: string): string {
280
- return [protocolPrompt, actorPrompt]
281
- .filter((p) => p.length > 0)
282
- .join('\n\n---\n\n');
111
+ return [protocolPrompt, actorPrompt].filter((p) => p.length > 0).join('\n\n---\n\n');
283
112
  }
284
113
 
285
114
  function actorConcurrency(tiers: HostActorTiers): number {
@@ -291,17 +120,29 @@ function actorConcurrency(tiers: HostActorTiers): number {
291
120
  return 1;
292
121
  }
293
122
 
123
+ function messageSender(msg: ChannelMessage): string {
124
+ return typeof msg.data['from'] === 'string' ? (msg.data['from'] as string) : 'unknown';
125
+ }
126
+
294
127
  interface CliResult {
295
128
  status: number;
296
129
  stdout: string;
297
130
  stderr: string;
298
131
  }
299
132
 
133
+ // Pass the prompt as the CLI's last argv entry. Every modern agent CLI
134
+ // (Claude --print, codex exec, gemini -p, qwen -p, opencode run, agy -p)
135
+ // reads its prompt from the trailing positional or flag-value, so appending
136
+ // it works universally — no per-agent `bash -c '... "$(cat)"'` wrapper.
137
+ // Fallback to stdin when the prompt would exceed a safe argv size, since
138
+ // ARG_MAX is ~128KB on Linux and ~256KB on macOS.
139
+ const ARGV_PROMPT_LIMIT = 64 * 1024;
140
+
300
141
  function invokeCli(
301
142
  cli: string,
302
143
  systemPrompt: string,
303
144
  userMessage: string,
304
- actorName: string,
145
+ env: Record<string, string>,
305
146
  ): Promise<CliResult> {
306
147
  return new Promise((res) => {
307
148
  const fullPrompt = `${systemPrompt}\n\n---\n\n${userMessage}`;
@@ -310,15 +151,15 @@ function invokeCli(
310
151
  res({ status: 1, stdout: '', stderr: 'tokenized cli is empty' });
311
152
  return;
312
153
  }
313
- // detached: true creates a new process group so we can SIGKILL the
314
- // group (not just the parent) on timeout — orphan children writing
315
- // to the transport after parent SIGKILL was an observed alpha.5 hazard.
316
- // Env: CROSSTALK_DISPATCH_ACTOR tells send.ts what to use as --from when
317
- // the dispatched actor invokes `crosstalk send` without explicit --from.
318
- const child = spawn(parts[0], parts.slice(1), {
154
+ const useStdin = Buffer.byteLength(fullPrompt, 'utf-8') > ARGV_PROMPT_LIMIT;
155
+ const argv = useStdin ? parts.slice(1) : [...parts.slice(1), fullPrompt];
156
+ // detached: new process group, so the timeout SIGKILL takes the actor's
157
+ // children with it orphans writing to the transport after a timeout
158
+ // was an observed v5 hazard.
159
+ const child = spawn(parts[0]!, argv, {
319
160
  stdio: ['pipe', 'pipe', 'pipe'],
320
161
  detached: true,
321
- env: { ...process.env, CROSSTALK_DISPATCH_ACTOR: actorName },
162
+ env: { ...process.env, ...env },
322
163
  });
323
164
  let stdout = '';
324
165
  let stderr = '';
@@ -326,20 +167,14 @@ function invokeCli(
326
167
  const timeout = setTimeout(() => {
327
168
  if (resolved) return;
328
169
  resolved = true;
329
- // SIGKILL the process group (negative pid) so any children the actor
330
- // spawned (e.g. crosstalk send subprocesses) die with the parent.
331
- // Fallback to single-pid kill if the group signal fails (some envs).
332
170
  try {
333
- if (typeof child.pid === 'number') {
334
- process.kill(-child.pid, 'SIGKILL');
335
- } else {
336
- child.kill('SIGKILL');
337
- }
171
+ if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGKILL');
172
+ else child.kill('SIGKILL');
338
173
  } catch {
339
174
  try { child.kill('SIGKILL'); } catch { /* already dead */ }
340
175
  }
341
176
  res({ status: 124, stdout, stderr: stderr + '\n[timeout]' });
342
- }, 5 * 60_000);
177
+ }, CLI_TIMEOUT_MS);
343
178
  child.stdout.on('data', (d) => { stdout += d.toString(); });
344
179
  child.stderr.on('data', (d) => { stderr += d.toString(); });
345
180
  child.on('close', (code) => {
@@ -354,158 +189,55 @@ function invokeCli(
354
189
  clearTimeout(timeout);
355
190
  res({ status: 1, stdout, stderr: stderr + '\n' + err.message });
356
191
  });
357
- // The child may exit before reading stdin (e.g. cli=`false`). Attach
358
- // an error handler so EPIPE is swallowed instead of crashing dispatch,
359
- // and guard the write itself.
360
- child.stdin.on('error', () => { /* EPIPE/etc. — child closed stdin */ });
361
- try {
362
- child.stdin.write(fullPrompt);
363
- } catch { /* same: child closed stdin before we could write */ }
364
- try {
365
- child.stdin.end();
366
- } catch { /* ignore */ }
192
+ child.stdin.on('error', () => { /* child closed stdin */ });
193
+ if (useStdin) {
194
+ try { child.stdin.write(fullPrompt); } catch { /* same */ }
195
+ }
196
+ try { child.stdin.end(); } catch { /* ignore */ }
367
197
  });
368
198
  }
369
199
 
370
200
  function writeReply(
371
201
  channelUuid: string,
372
202
  fromActor: string,
373
- toActor: string | string[],
203
+ toActor: string,
204
+ re: string | string[],
374
205
  body: string,
375
206
  ): void {
376
207
  const ts = now();
377
208
  const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
378
209
  mkdirSync(dir, { recursive: true });
379
- // Auto-replies emitted via stdout are `kind: result` by default — the actor
380
- // is answering, not initiating new work. Recipients only wake on a result if
381
- // they previously asked the sender for work in this channel (reply
382
- // causality, see activation rule below). Actors that want to dispatch new
383
- // work do so explicitly via `crosstalk send --kind work`.
384
210
  const content = serializeFrontmatter(
385
- { from: fromActor, to: toActor, type: 'text', kind: 'result', timestamp: ts.iso },
211
+ { from: fromActor, to: toActor, type: 'text', timestamp: ts.iso, re },
386
212
  body,
387
213
  );
388
214
  writeFileSync(join(dir, messageFilename(ts)), content);
389
215
  }
390
216
 
391
- function writeReadReceipt(
392
- channelUuid: string,
393
- fromActor: string,
394
- toActor: string,
395
- ref: string,
396
- ): void {
397
- const ts = now();
398
- const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
399
- mkdirSync(dir, { recursive: true });
400
- const content = serializeFrontmatter(
401
- { from: fromActor, to: toActor, type: 'read', ref, timestamp: ts.iso },
402
- '',
403
- );
404
- writeFileSync(join(dir, messageFilename(ts)), content);
405
- }
406
-
407
- interface PendingDispatch {
408
- actorName: string;
409
- channelUuid: string;
410
- msgs: ChannelMessage[]; // all unread messages addressed to this actor in this channel
411
- tiers: HostActorTiers;
412
- }
413
-
414
- function messageSender(msg: ChannelMessage): string {
415
- return typeof msg.data['from'] === 'string' ? msg.data['from'] : 'unknown';
416
- }
417
-
418
217
  function formatBatchedUserMessage(msgs: ChannelMessage[]): string {
419
- if (msgs.length === 1) return msgs[0].body;
420
- const header = `You have ${msgs.length} new messages in this channel. Process them collectively and reply once.`;
421
- const parts: string[] = [header];
218
+ if (msgs.length === 1) return msgs[0]!.body;
219
+ const parts = [`You have ${msgs.length} new messages in this channel. Process them collectively and reply once.`];
422
220
  for (let i = 0; i < msgs.length; i++) {
423
- const m = msgs[i];
424
- const from = messageSender(m);
425
- const ts = typeof m.data['timestamp'] === 'string' ? (m.data['timestamp'] as string) : '';
426
- parts.push(`--- Message ${i + 1} of ${msgs.length} (from: ${from}, ref: ${m.relPath}${ts ? `, ts: ${ts}` : ''}) ---`);
221
+ const m = msgs[i]!;
222
+ const ts = typeof m.data['timestamp'] === 'string' ? `, ts: ${m.data['timestamp']}` : '';
223
+ parts.push(`--- Message ${i + 1} of ${msgs.length} (from: ${messageSender(m)}, ref: ${m.relPath}${ts}) ---`);
427
224
  parts.push(m.body);
428
225
  }
429
226
  return parts.join('\n\n');
430
227
  }
431
228
 
432
- // Split a channel's pending messages (already sorted by relPath) into
433
- // contiguous batches sized for the actor's concurrency. Contiguous (not
434
- // round-robin) so each batch's highest relPath is monotone across batches —
435
- // the cursor advances safely after the dispatch loop's per-batch writes
436
- // without leaving a gap that would re-dispatch on the next tick.
437
- //
438
- // When pending fits within concurrency, every batch is a single message
439
- // (preserves parallel fan-out — junior-developer with count: 10 and 10
440
- // pending fan-out messages dispatches 10 parallel CLI invocations of 1
441
- // message each). When pending exceeds concurrency, batches collapse pending
442
- // into ~concurrency parallel invocations, each handling ceil(N/concurrency)
443
- // messages (preserves the fan-in collapse — concierge with count: 1 and 10
444
- // pending replies dispatches 1 invocation of 10 messages).
445
- function splitForConcurrency(
446
- msgs: ChannelMessage[],
447
- concurrency: number,
448
- ): ChannelMessage[][] {
449
- if (concurrency <= 1 || msgs.length <= 1) return [msgs];
450
- const chunkSize = Math.max(1, Math.ceil(msgs.length / concurrency));
451
- const out: ChannelMessage[][] = [];
452
- for (let i = 0; i < msgs.length; i += chunkSize) {
453
- out.push(msgs.slice(i, i + chunkSize));
454
- }
455
- return out;
456
- }
457
-
458
- function distinctSenders(msgs: ChannelMessage[]): string[] {
459
- const seen = new Set<string>();
460
- const out: string[] = [];
461
- for (const m of msgs) {
462
- const s = messageSender(m);
463
- if (s !== 'unknown' && !seen.has(s)) {
464
- seen.add(s);
465
- out.push(s);
466
- }
467
- }
468
- return out;
229
+ interface PendingDispatch {
230
+ actorName: string;
231
+ channelUuid: string;
232
+ msgs: ChannelMessage[];
233
+ tiers: HostActorTiers;
469
234
  }
470
235
 
471
236
  async function dispatchOne(p: PendingDispatch): Promise<boolean> {
472
- // Tier resolution uses the first message's `tier:` hint (if any). Batched
473
- // dispatches assume homogeneous tier preference within an (actor, channel)
474
- // pairing — true for fan-in (all peer replies omit tier) and for explicit
475
- // single-message dispatches alike.
476
- const firstMsg = p.msgs[0];
477
- const lastMsg = p.msgs[p.msgs.length - 1];
478
- const preferredTier = typeof firstMsg.data['tier'] === 'string'
479
- ? (firstMsg.data['tier'] as string)
480
- : undefined;
481
- let resolved;
482
- try {
483
- resolved = pickTier(p.tiers, preferredTier);
484
- } catch (err) {
485
- const r = writeDlqEntry(
486
- transportRoot,
487
- 'config',
488
- p.actorName,
489
- '(config)',
490
- '(config)',
491
- `tier selection failed: ${(err as Error).message}`,
492
- );
493
- log('actor_config_error', {
494
- actor: p.actorName,
495
- dlq_id: r.id,
496
- attempts: r.attempts,
497
- quarantined: r.quarantined,
498
- });
499
- return false;
500
- }
501
- const cli = resolved.cli;
502
-
503
- // Quarantine check uses the LAST message's relPath as the batch's identity.
504
- // Per-message quarantine semantics are preserved because batch boundaries
505
- // align with cursor checkpoints; if a single message in a batch keeps
506
- // failing, the cursor never advances past it and it surfaces as a singleton
507
- // batch on the next tick.
508
- if (isQuarantined(transportRoot, 'dispatch', p.actorName, p.channelUuid, lastMsg.relPath)) {
237
+ const firstMsg = p.msgs[0]!;
238
+ const lastMsg = p.msgs[p.msgs.length - 1]!;
239
+
240
+ if (isQuarantined(transportRoot, p.actorName, p.channelUuid, lastMsg.relPath)) {
509
241
  log('dispatch_skipped_quarantined', {
510
242
  actor: p.actorName,
511
243
  channel: p.channelUuid.slice(0, 8),
@@ -514,6 +246,17 @@ async function dispatchOne(p: PendingDispatch): Promise<boolean> {
514
246
  return false;
515
247
  }
516
248
 
249
+ const preferredTier = typeof firstMsg.data['tier'] === 'string' ? (firstMsg.data['tier'] as string) : undefined;
250
+ let cli: string;
251
+ let profile;
252
+ try {
253
+ cli = pickTier(p.tiers, preferredTier).cli;
254
+ profile = loadActorProfile(transportRoot, p.actorName);
255
+ } catch (err) {
256
+ logConfigError(`actor:${p.actorName}`, (err as Error).message);
257
+ return false;
258
+ }
259
+
517
260
  log('dispatch', {
518
261
  actor: p.actorName,
519
262
  channel: p.channelUuid.slice(0, 8),
@@ -522,42 +265,22 @@ async function dispatchOne(p: PendingDispatch): Promise<boolean> {
522
265
  last_msg: lastMsg.relPath,
523
266
  });
524
267
 
525
- // Read receipt per message — preserves the audit trail (each original
526
- // message gets exactly one receipt) and keeps the stale-receipt sweep
527
- // correct.
528
- for (const m of p.msgs) {
529
- writeReadReceipt(p.channelUuid, p.actorName, messageSender(m), m.relPath);
530
- }
531
-
532
- let profile;
533
- try {
534
- profile = loadActorProfile(transportRoot, p.actorName);
535
- } catch (err) {
536
- const r = writeDlqEntry(
537
- transportRoot,
538
- 'config',
539
- p.actorName,
540
- '(config)',
541
- '(config)',
542
- `actor profile load failed: ${(err as Error).message}`,
543
- );
544
- log('dispatch_config_error', {
545
- actor: p.actorName,
546
- dlq_id: r.id,
547
- attempts: r.attempts,
548
- quarantined: r.quarantined,
549
- });
550
- return false;
551
- }
552
-
553
- const systemPrompt = composeSystemPrompt(profile.systemPrompt);
554
- const userMessage = formatBatchedUserMessage(p.msgs);
555
- const result = await invokeCli(cli, systemPrompt, userMessage, p.actorName);
268
+ const result = await invokeCli(
269
+ cli,
270
+ composeSystemPrompt(profile.systemPrompt),
271
+ formatBatchedUserMessage(p.msgs),
272
+ {
273
+ CROSSTALK_DISPATCH_ACTOR: p.actorName,
274
+ CROSSTALK_DISPATCH_CHANNEL: p.channelUuid,
275
+ // Every relPath in the batch — `crosstalk send` records them all as
276
+ // the reply's re: list, so batching never loses an answered message.
277
+ CROSSTALK_DISPATCH_RE: p.msgs.map((m) => m.relPath).join(','),
278
+ },
279
+ );
556
280
 
557
281
  if (result.status !== 0) {
558
282
  const r = writeDlqEntry(
559
283
  transportRoot,
560
- 'dispatch',
561
284
  p.actorName,
562
285
  p.channelUuid,
563
286
  lastMsg.relPath,
@@ -577,45 +300,28 @@ async function dispatchOne(p: PendingDispatch): Promise<boolean> {
577
300
 
578
301
  const reply = result.stdout.trim();
579
302
  if (reply.length === 0) {
580
- // Empty stdout on a multi-message batch is treated as success — the
581
- // actor likely routed via `crosstalk send` and has nothing to add as
582
- // an auto-reply. For a single-message batch we keep the prior DLQ
583
- // semantics: a single dispatched message that produces no reply is a
584
- // protocol violation.
585
- if (p.msgs.length > 1) {
586
- log('dispatch_batch_silent_ok', {
587
- actor: p.actorName,
588
- channel: p.channelUuid.slice(0, 8),
589
- batch_size: p.msgs.length,
590
- });
591
- return true;
592
- }
593
- const r = writeDlqEntry(
594
- transportRoot,
595
- 'dispatch',
596
- p.actorName,
597
- p.channelUuid,
598
- lastMsg.relPath,
599
- 'cli returned empty reply',
600
- );
601
- log('dispatch_empty_reply', {
602
- actor: p.actorName,
603
- channel: p.channelUuid.slice(0, 8),
604
- dlq_id: r.id,
605
- attempts: r.attempts,
606
- quarantined: r.quarantined,
607
- });
608
- return false;
303
+ // Legitimate: the actor routed its answer via `crosstalk send` (which
304
+ // auto-links re:). If it truly did nothing, the asker's `crosstalk
305
+ // replies` stays PENDING visible, not silently lost.
306
+ log('dispatch_silent', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length });
307
+ log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: false });
308
+ return true;
609
309
  }
610
310
 
611
- // Auto-reply addressing: single-sender batches reply to that sender
612
- // (preserves prior behavior). Multi-sender batches address all distinct
613
- // senders so each peer sees the response.
614
- const senders = distinctSenders(p.msgs);
615
- const replyTo: string | string[] = senders.length <= 1
616
- ? (senders[0] ?? messageSender(firstMsg))
617
- : senders;
618
- writeReply(p.channelUuid, p.actorName, replyTo, reply);
311
+ // One reply per distinct sender, re:-linked to EVERY message that sender
312
+ // had in the batch the asker's activation rule fires, and `crosstalk
313
+ // replies` sees each individual message as answered.
314
+ const bySender = new Map<string, string[]>();
315
+ for (const m of p.msgs) {
316
+ const sender = messageSender(m);
317
+ bySender.set(sender, [...(bySender.get(sender) ?? []), m.relPath]);
318
+ }
319
+ bySender.delete('unknown');
320
+ if (bySender.size === 0) bySender.set(messageSender(firstMsg), [firstMsg.relPath]);
321
+ for (const [sender, relPaths] of bySender) {
322
+ writeReply(p.channelUuid, p.actorName, sender, relPaths.length === 1 ? relPaths[0]! : relPaths, reply);
323
+ }
324
+ log('dispatch_done', { actor: p.actorName, channel: p.channelUuid.slice(0, 8), batch_size: p.msgs.length, replied: true });
619
325
  return true;
620
326
  }
621
327
 
@@ -625,278 +331,200 @@ interface TickResult {
625
331
  }
626
332
 
627
333
  async function dispatchTick(): Promise<TickResult> {
628
- writeHeartbeat();
629
-
630
- return withLock('dispatch', async () => {
631
- let infraOk = true;
632
-
633
- const pullResult = gitPull(transportRoot);
634
- if (!pullResult.ok && pullResult.error) {
635
- // Note: deliberately NOT calling writeErrorLog here. Repeated pull
636
- // failures (deadlock loop) would otherwise write a new errors/*.md
637
- // every tick, which dispatch then has to commit, which the next
638
- // pull then chokes on a positive feedback that contributed to
639
- // the alpha.3/alpha.4 Mac UAT wedge. The structured log line below
640
- // gives operators full diagnostic info via stdout/json logs.
641
- log('git_pull_failed', { error: pullResult.error.slice(0, 200) });
642
- infraOk = false;
643
- }
334
+ writeHeartbeat(transportRoot, RUNTIME_VERSION);
335
+ let infraOk = true;
336
+
337
+ const pullResult = gitPull(transportRoot);
338
+ if (!pullResult.ok) {
339
+ // Skip the whole tick: a failed pull can leave origin/HEAD (the cursor
340
+ // baseline) ahead of the working tree, and scanning against that would
341
+ // advance cursors past messages that never materialized.
342
+ logError(transportRoot, 'git_pull', pullResult.error ?? 'unknown');
343
+ log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
344
+ return { didWork: false, infraOk: false };
345
+ }
644
346
 
645
- let host: HostFile;
646
- try {
647
- host = findHostFile(transportRoot, hostOverride);
648
- } catch (err) {
649
- const r = writeDlqEntry(
650
- transportRoot,
651
- 'config',
652
- '(host)',
653
- '(config)',
654
- '(config)',
655
- `host file load failed: ${(err as Error).message}`,
656
- );
657
- log('tick_config_error', {
658
- scope: 'host',
659
- dlq_id: r.id,
660
- attempts: r.attempts,
661
- quarantined: r.quarantined,
662
- });
663
- return { didWork: false, infraOk };
664
- }
347
+ let host: HostFile;
348
+ try {
349
+ host = findHostFile(transportRoot, hostOverride);
350
+ } catch (err) {
351
+ logConfigError('host', (err as Error).message);
352
+ return { didWork: false, infraOk };
353
+ }
665
354
 
666
- let didWork = false;
355
+ // Cursors are commit hashes, not relPaths: filenames order by sender
356
+ // timestamp but arrive in push order, so a relPath cursor can advance
357
+ // past a slower writer's earlier-stamped message and lose it forever.
358
+ // "New since cursor" is asked of git, which records arrival truthfully.
359
+ const head = cursorBaseline(transportRoot);
360
+ if (!head) {
361
+ logError(transportRoot, 'other', 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
362
+ return { didWork: false, infraOk: false };
363
+ }
364
+ // diff results keyed by cursor commit (shared across actors on the same
365
+ // cursor); null = commit unknown to this clone -> full re-scan.
366
+ const addedSince = new Map<string, Set<string> | null>();
367
+
368
+ let didWork = false;
369
+ const channels = discoverChannels(transportRoot);
370
+
371
+ for (const actorName of Object.keys(host.actors)) {
372
+ const tiers = host.actors[actorName]!;
373
+ const concurrency = actorConcurrency(tiers);
374
+ const pending: PendingDispatch[] = [];
375
+
376
+ for (const channelUuid of channels) {
377
+ const persistedCursor = readCursor(transportRoot, actorName, channelUuid);
378
+ if (persistedCursor === head) continue;
379
+
380
+ // First encounter: seed to the commit that introduced this actor's host
381
+ // file. Messages sent after the host joined are delivered (store-and-
382
+ // forward); pre-join history is ignored. Seeding to HEAD would silently
383
+ // drop messages sent while the dispatcher was offline — the wrong trade.
384
+ // Fall through after seeding so this tick processes the post-join backlog
385
+ // (otherwise `--once` users hit a seed-then-dispatch two-tick gotcha).
386
+ let cursor: string;
387
+ if (persistedCursor === null) {
388
+ const joinCommit = hostFileCommit(transportRoot, host.alias);
389
+ cursor = joinCommit ?? head;
390
+ writeCursor(transportRoot, actorName, channelUuid, cursor);
391
+ if (cursor === head) continue;
392
+ } else {
393
+ cursor = persistedCursor;
394
+ }
667
395
 
668
- for (const actorName of Object.keys(host.actors)) {
669
- if (isActorQuarantined(transportRoot, actorName)) {
670
- log('actor_skipped_quarantined', { actor: actorName });
396
+ const messages = listChannelMessages(transportRoot, channelUuid);
397
+ const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
398
+ const senderOf = (relPath: string) => senderByRelPath.get(relPath);
399
+
400
+ let added = addedSince.get(cursor);
401
+ if (added === undefined) {
402
+ const files = newFilesSince(transportRoot, cursor);
403
+ added = files === null ? null : new Set(files);
404
+ addedSince.set(cursor, added);
405
+ if (added === null) {
406
+ logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
407
+ }
408
+ }
409
+ let post = messages;
410
+ if (added !== null) {
411
+ const prefix = `data/channels/${channelUuid}/`;
412
+ post = messages.filter((m) => added.has(prefix + m.relPath));
413
+ }
414
+ if (post.length === 0) {
415
+ writeCursor(transportRoot, actorName, channelUuid, head);
671
416
  continue;
672
417
  }
673
418
 
674
- const tiers = host.actors[actorName];
675
- const concurrency = actorConcurrency(tiers);
676
-
677
- // Mailbox batch-drain: for each channel, collect ALL unread messages
678
- // addressed to this actor into a single PendingDispatch. This collapses
679
- // fan-in O(N) into O(1) CLI invocations and prevents one actor's deep
680
- // backlog from starving its peers in the (actor, channel) scan order.
681
- // Read receipts and self-sent messages are filtered here — receipts
682
- // are bookkeeping the actor already produced, and self-messages would
683
- // create a wake-up loop.
684
- const pending: PendingDispatch[] = [];
685
- const channels = discoverChannels(transportRoot);
686
- for (const channelUuid of channels) {
687
- const cursor = readCursor(transportRoot, actorName, channelUuid);
688
- const messages = listChannelMessages(transportRoot, channelUuid);
689
- const post = cursor ? messages.filter((m) => m.relPath > cursor) : messages;
690
-
691
- log('tick_scan', {
692
- actor: actorName,
693
- channel: channelUuid.slice(0, 8),
694
- cursor: cursor ?? '(none)',
695
- total_msgs: messages.length,
696
- post_cursor_msgs: post.length,
697
- });
698
-
699
- const channelBatch: ChannelMessage[] = [];
700
- for (const msg of post) {
701
- const to = recipients(msg.data['to']);
702
- const from = typeof msg.data['from'] === 'string' ? msg.data['from'] : 'unknown';
703
- const msgType = typeof msg.data['type'] === 'string' ? msg.data['type'] : 'text';
704
- // Host-aware routing match. A recipient may target this actor
705
- // either by bare name (`junior-developer` — broadcast to every
706
- // host that declares the actor) or by `actor@host` (narrowed to
707
- // a specific host). Bare-name match always succeeds when the
708
- // actor name matches; @host match succeeds only when the host
709
- // alias also matches this dispatcher's host. A recipient that
710
- // names this actor but targets a different host is flagged as
711
- // `host_routing_mismatch` so silent wrong-host routes are
712
- // surfaced rather than dropped without trace. See concierge.md
713
- // "Host-aware routing" + PROTOCOL.md.
714
- const routing = matchHostRouting(to, actorName, host.alias);
715
- if (!routing.addressed || from === actorName || msgType === 'read') {
716
- if (routing.wrongHost) {
717
- log('host_routing_mismatch', {
718
- actor: actorName,
719
- this_host: host.alias,
720
- channel: channelUuid.slice(0, 8),
721
- msg: msg.relPath,
722
- to,
723
- });
724
- }
725
- writeCursor(transportRoot, actorName, channelUuid, msg.relPath);
726
- continue;
727
- }
728
- // Lifecycle activation rule. `work` always wakes. `result` wakes
729
- // only if reply-causal — actor previously sent the sender a `work`
730
- // in this channel. The kind used here is the runtime's INFERRED
731
- // effective kind, not the actor's declared kind: a message that's
732
- // causally a reply is treated as `result` even when an actor (or
733
- // `crosstalk send`'s default) labelled it `work`, so a fan-in peer
734
- // mislabeling its reply can't forge a wake-up loop. See PROTOCOL.md
735
- // "Message kinds".
736
- const kind = effectiveKind(messages, msg);
737
- if (kind === 'result' && !hasPriorWork(messages, actorName, from, msg.relPath)) {
738
- writeCursor(transportRoot, actorName, channelUuid, msg.relPath);
739
- continue;
740
- }
741
- channelBatch.push(msg);
742
- }
743
- if (channelBatch.length > 0) {
744
- const groups = splitForConcurrency(channelBatch, concurrency);
745
- for (const g of groups) {
746
- pending.push({ actorName, channelUuid, msgs: g, tiers });
747
- }
419
+ const batch: ChannelMessage[] = [];
420
+ for (const msg of post) {
421
+ if (msg.data['type'] !== 'text') continue;
422
+ const decision = decideWake(
423
+ {
424
+ from: messageSender(msg),
425
+ to: recipients(msg.data['to']),
426
+ re: reList(msg.data['re']),
427
+ },
428
+ actorName,
429
+ host.alias,
430
+ senderOf,
431
+ );
432
+ if (decision === 'wake') {
433
+ batch.push(msg);
434
+ } else if (decision === 'wrong-host') {
435
+ log('host_routing_mismatch', {
436
+ actor: actorName,
437
+ this_host: host.alias,
438
+ channel: channelUuid.slice(0, 8),
439
+ msg: msg.relPath,
440
+ to: recipients(msg.data['to']),
441
+ });
748
442
  }
749
443
  }
750
444
 
751
- // Concurrency now applies across (channel) batches, not individual
752
- // messages. Each batch is one CLI invocation regardless of how many
753
- // messages it carries. Cursor advances to the last message in the batch
754
- // on success or skip — failure (DLQ) leaves the cursor behind so the
755
- // tail of the batch retries.
756
- for (let i = 0; i < pending.length; i += concurrency) {
757
- const batch = pending.slice(i, i + concurrency);
758
- const results = await Promise.all(batch.map((p) => dispatchOne(p)));
759
- for (let j = 0; j < batch.length; j++) {
760
- const p = batch[j];
761
- const lastRelPath = p.msgs[p.msgs.length - 1].relPath;
762
- writeCursor(transportRoot, p.actorName, p.channelUuid, lastRelPath);
763
- if (results[j]) didWork = true;
764
- }
445
+ if (batch.length === 0) {
446
+ writeCursor(transportRoot, actorName, channelUuid, head);
447
+ continue;
448
+ }
449
+ for (const g of splitForConcurrency(batch, concurrency)) {
450
+ pending.push({ actorName, channelUuid, msgs: g, tiers });
765
451
  }
766
452
  }
767
453
 
768
- // Always attempt commit+push at end of tick gitCommitAndPush
769
- // short-circuits if the working tree is clean. This is required
770
- // even when no replies were produced, because cursors advance for
771
- // messages addressed to other actors (the actor's own replies and
772
- // read receipts appear on the next pull and need to be skipped past).
773
- // Without this commit, the orphan cursor change blocks the next
774
- // git pull --rebase and dispatch dead-ends in backoff.
775
- const commitMsg = didWork
776
- ? `dispatch: replies + cursor advance ${new Date().toISOString()}`
777
- : `dispatch: cursor advance ${new Date().toISOString()}`;
778
- const pushResult = gitCommitAndPush(transportRoot, commitMsg);
779
- if (!pushResult.ok && pushResult.error) {
780
- // Same rationale as the pull case above: no writeErrorLog.
781
- // Repeated push failures shouldn't flood errors/ since that
782
- // contributes to the same git-deadlock-feedback that pull does.
783
- const kind = pushResult.committed ? 'git_push' : 'git_commit';
784
- log('git_push_failed', {
785
- kind,
786
- committed_locally: pushResult.committed,
787
- error: pushResult.error.slice(0, 200),
788
- });
789
- infraOk = false;
454
+ // Waves of `concurrency` parallel CLI invocations. The cursor advances
455
+ // to the scanned commit whether each batch succeeded or DLQ'd —
456
+ // at-least-once was attempted; `crosstalk dlq --retry` rewinds the
457
+ // cursor explicitly. A crash mid-wave leaves the cursor behind, so the
458
+ // whole span replays next tick (at-least-once, never lost).
459
+ for (let i = 0; i < pending.length; i += concurrency) {
460
+ const wave = pending.slice(i, i + concurrency);
461
+ const results = await Promise.all(wave.map((p) => dispatchOne(p)));
462
+ if (results.some(Boolean)) didWork = true;
463
+ }
464
+ for (const p of pending) {
465
+ writeCursor(transportRoot, p.actorName, p.channelUuid, head);
790
466
  }
467
+ }
791
468
 
792
- // Periodic stale-read-receipt sweep
793
- if (Date.now() - lastSweepAt > SWEEP_INTERVAL_MS) {
794
- const surfaced = sweepStaleReadReceipts(transportRoot, STALE_RECEIPT_THRESHOLD_MS);
795
- lastSweepAt = Date.now();
796
- if (surfaced > 0) {
797
- log('stale_receipts_surfaced', { count: surfaced });
798
- }
469
+ if (didWork) {
470
+ const pushResult = await withLock(transportRoot, 'git', async () =>
471
+ gitCommitAndPush(transportRoot, `dispatch: replies ${new Date().toISOString()}`),
472
+ );
473
+ if (!pushResult.ok && pushResult.error) {
474
+ logError(transportRoot, pushResult.committed ? 'git_push' : 'git_commit', pushResult.error);
475
+ log('git_push_failed', { committed_locally: pushResult.committed, error: pushResult.error.slice(0, 200) });
476
+ infraOk = false;
799
477
  }
478
+ }
800
479
 
801
- return { didWork, infraOk };
802
- });
480
+ return { didWork, infraOk };
803
481
  }
804
482
 
805
- async function waitForWakeOrTimeout(ms: number): Promise<'wake' | 'timeout'> {
806
- const wakeDir = join(transportRoot, '.turnq');
807
- mkdirSync(wakeDir, { recursive: true });
483
+ async function waitForWakeOrTimeout(ms: number): Promise<void> {
484
+ const dir = stateDir(transportRoot);
808
485
  const ac = new AbortController();
809
486
  const timer = setTimeout(() => ac.abort(), ms);
810
487
  try {
811
- const watcher = watch(wakeDir, { signal: ac.signal });
488
+ const watcher = watch(dir, { signal: ac.signal });
812
489
  for await (const ev of watcher) {
813
- if (ev.filename === 'wake.signal') {
814
- clearTimeout(timer);
815
- return 'wake';
816
- }
490
+ if (ev.filename === 'wake.signal') return;
817
491
  }
818
- return 'timeout';
819
492
  } catch {
820
- return 'timeout';
493
+ /* abort = timeout */
821
494
  } finally {
822
495
  clearTimeout(timer);
823
496
  }
824
497
  }
825
498
 
826
499
  async function main(): Promise<void> {
827
- log('dispatch_start', {
828
- transport: transportRoot,
829
- version: RUNTIME_VERSION,
830
- log_file: logFile ?? null,
831
- });
500
+ writePidfile(transportRoot);
501
+ const cleanup = () => removePidfile(transportRoot);
502
+ process.on('exit', cleanup);
503
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
504
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
505
+
506
+ log('dispatch_start', { transport: transportRoot, version: RUNTIME_VERSION, state_dir: stateDir(transportRoot) });
832
507
  if (onceMode) {
833
508
  await dispatchTick();
834
- return;
509
+ process.exit(0);
835
510
  }
836
- log('coordinator_running', { quiet_poll_s: pollSeconds, active_poll_s: 1 });
511
+ log('dispatch_running', { quiet_poll_s: pollSeconds });
837
512
 
838
513
  let consecutiveInfraFailures = 0;
839
-
840
514
  while (true) {
841
515
  try {
842
516
  const r = await dispatchTick();
843
517
  if (r.infraOk) {
844
- if (consecutiveInfraFailures > 0) {
845
- log('backoff_cleared', { previous_consecutive_failures: consecutiveInfraFailures });
846
- }
518
+ if (consecutiveInfraFailures > 0) log('backoff_cleared', { previous_failures: consecutiveInfraFailures });
847
519
  consecutiveInfraFailures = 0;
848
520
  } else {
849
521
  consecutiveInfraFailures++;
850
522
  }
851
-
852
- // Backoff kicks in only after a grace period of failures.
853
523
  const beyondGrace = Math.max(0, consecutiveInfraFailures - BACKOFF_GRACE);
854
524
  const backoffFactor = Math.min(MAX_BACKOFF_MULTIPLIER, 2 ** beyondGrace);
855
-
856
- if (consecutiveInfraFailures > BACKOFF_GRACE) {
857
- log('backoff_active', {
858
- consecutive_failures: consecutiveInfraFailures,
859
- factor: backoffFactor,
860
- });
861
- }
862
-
863
- // Per-tick heal: deadlock-break when the dispatch loop has been
864
- // failing for HEAL_THRESHOLD consecutive ticks AND we haven't healed
865
- // recently. Hard-resets the working tree to origin/<current branch>.
866
- // Trades any uncommitted local state for forward progress — acceptable
867
- // because messages/cursors/dlq are pulled back from origin and
868
- // .turnq/errors are regenerated.
869
- if (
870
- consecutiveInfraFailures >= HEAL_THRESHOLD &&
871
- consecutiveInfraFailures - lastHealAtFailureCount >= HEAL_THRESHOLD
872
- ) {
873
- try {
874
- const branchProc = spawn('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
875
- cwd: transportRoot,
876
- stdio: ['ignore', 'pipe', 'ignore'],
877
- });
878
- let branchName = '';
879
- branchProc.stdout.on('data', (d) => { branchName += d.toString(); });
880
- await new Promise<void>((res) => branchProc.on('close', () => res()));
881
- const branch = branchName.trim() || 'main';
882
- log('per_tick_heal_start', {
883
- consecutive_failures: consecutiveInfraFailures,
884
- target: `origin/${branch}`,
885
- });
886
- await new Promise<void>((res) => {
887
- const p = spawn('sh', [
888
- '-c',
889
- `git rebase --abort 2>/dev/null; git fetch --quiet origin '${branch}' && git reset --hard --quiet 'origin/${branch}' && git clean -fdq`,
890
- ], { cwd: transportRoot, stdio: 'inherit' });
891
- p.on('close', () => res());
892
- });
893
- log('per_tick_heal_done', { target: `origin/${branch}` });
894
- lastHealAtFailureCount = consecutiveInfraFailures;
895
- } catch (err) {
896
- log('per_tick_heal_failed', { error: (err as Error).message });
897
- }
525
+ if (backoffFactor > 1) {
526
+ log('backoff_active', { consecutive_failures: consecutiveInfraFailures, factor: backoffFactor });
898
527
  }
899
-
900
528
  if (r.didWork) {
901
529
  await new Promise((res) => setTimeout(res, 1_000 * backoffFactor));
902
530
  } else {
@@ -904,7 +532,7 @@ async function main(): Promise<void> {
904
532
  }
905
533
  } catch (err) {
906
534
  const msg = (err as Error).message;
907
- writeErrorLog(transportRoot, 'other', `tick error: ${msg}`);
535
+ logError(transportRoot, 'other', `tick error: ${msg}`);
908
536
  log('tick_error', { message: msg });
909
537
  consecutiveInfraFailures++;
910
538
  await new Promise((res) => setTimeout(res, pollSeconds * 1_000));