@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.
- package/GUIDE-CLI.md +298 -0
- package/GUIDE-PROMPTS.md +132 -0
- package/README.md +139 -0
- package/bin/crosstalk.js +51 -80
- package/package.json +9 -5
- package/src/activation.ts +104 -0
- package/src/actor.ts +29 -4
- package/src/attach.ts +1 -1
- package/src/channel.ts +8 -21
- package/src/chat.ts +52 -115
- package/src/dispatch.ts +288 -660
- package/src/dlq.ts +89 -136
- package/src/init.ts +23 -42
- package/src/open.ts +55 -31
- package/src/replies.ts +59 -0
- package/src/send.ts +87 -72
- package/src/state.ts +173 -0
- package/src/status.ts +18 -57
- package/src/stop.ts +37 -0
- package/src/transport.ts +81 -198
- package/src/turnq.ts +64 -32
- package/src/upgrade.ts +9 -11
- package/src/wake.ts +5 -6
- package/template/CLAUDE.md +12 -2
- package/template/gitignore +4 -0
- package/template/upstream/CROSSTALK-VERSION +1 -1
- package/template/upstream/CROSSTALK.md +172 -463
- package/template/upstream/OPERATOR.md +9 -9
- package/template/upstream/PROTOCOL.md +64 -244
- package/template/upstream/actors/concierge.md +24 -118
- package/src/cursor.ts +0 -48
- package/template/.amazonq/rules/crosstalk.md +0 -2
- package/template/.continue/rules/crosstalk.md +0 -7
- package/template/.cursor/rules/crosstalk.mdc +0 -7
- package/template/.github/copilot-instructions.md +0 -2
- package/template/.windsurfrules +0 -2
- package/template/AGENTS.md +0 -2
- package/template/ANTIGRAVITY.md +0 -2
- package/template/GEMINI.md +0 -2
- package/template/OPENCODE.md +0 -2
- package/template/QWEN.md +0 -2
- package/template/README.md +0 -22
- package/template/local/CROSSTALK.md +0 -4
- package/template/upstream/JITTER.md +0 -24
- package/template/upstream/actors/cloud-architect.md +0 -83
- package/template/upstream/actors/devops-engineer.md +0 -83
- package/template/upstream/actors/documentation-engineer.md +0 -107
- package/template/upstream/actors/infrastructure-engineer.md +0 -83
- package/template/upstream/actors/junior-developer.md +0 -83
- package/template/upstream/actors/precise-generalist.md +0 -48
- package/template/upstream/actors/product-manager.md +0 -83
- package/template/upstream/actors/qa-engineer.md +0 -83
- package/template/upstream/actors/security-engineer.md +0 -92
- package/template/upstream/actors/senior-generalist-engineer.md +0 -111
- package/template/upstream/actors/senior-software-engineer.md +0 -94
- package/template/upstream/actors/skeptic.md +0 -89
- package/template/upstream/actors/technical-writer.md +0 -89
- 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
|
-
|
|
42
|
-
|
|
30
|
+
cursorBaseline,
|
|
31
|
+
newFilesSince,
|
|
32
|
+
hostFileCommit,
|
|
43
33
|
type ChannelMessage,
|
|
44
34
|
} from './transport.js';
|
|
45
|
-
import {
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
105
|
+
const protocolPrompt = (() => {
|
|
114
106
|
const p = join(transportRoot, 'upstream', 'PROTOCOL.md');
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
const child = spawn(parts[0]
|
|
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,
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
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',
|
|
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]
|
|
420
|
-
const
|
|
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
|
|
425
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
//
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
const
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
802
|
-
});
|
|
480
|
+
return { didWork, infraOk };
|
|
803
481
|
}
|
|
804
482
|
|
|
805
|
-
async function waitForWakeOrTimeout(ms: number): Promise<
|
|
806
|
-
const
|
|
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(
|
|
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
|
-
|
|
493
|
+
/* abort = timeout */
|
|
821
494
|
} finally {
|
|
822
495
|
clearTimeout(timer);
|
|
823
496
|
}
|
|
824
497
|
}
|
|
825
498
|
|
|
826
499
|
async function main(): Promise<void> {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
509
|
+
process.exit(0);
|
|
835
510
|
}
|
|
836
|
-
log('
|
|
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
|
-
|
|
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
|
-
|
|
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));
|