@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/transport.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
// Git transport layer. The dispatcher's commits contain ONLY data/ —
|
|
2
|
+
// machine-local state lives in the state dir (state.ts), so there is
|
|
3
|
+
// nothing to exclude, untrack, or heal. Push rejection means another
|
|
4
|
+
// machine won the race: pull --rebase and retry at the call site.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
2
7
|
import { join } from 'path';
|
|
3
8
|
import { spawnSync } from 'child_process';
|
|
4
|
-
import { parseFrontmatter
|
|
5
|
-
import {
|
|
9
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
10
|
+
import { logError } from './state.js';
|
|
6
11
|
|
|
7
12
|
export interface ChannelMessage {
|
|
8
13
|
relPath: string;
|
|
@@ -28,9 +33,8 @@ function captureGit(cwd: string, args: string[]): { status: number; stdout: stri
|
|
|
28
33
|
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
// Detect and
|
|
32
|
-
// Returns true if recovery was performed
|
|
33
|
-
// infrastructure event). The error log surfaces the recovery in errors/.
|
|
36
|
+
// Detect and abort an interrupted rebase/merge left by a killed process.
|
|
37
|
+
// Returns true if recovery was performed.
|
|
34
38
|
export function recoverInterruptedGit(transportRoot: string): boolean {
|
|
35
39
|
const halfStates: { dir: string; abortArgs: string[] }[] = [
|
|
36
40
|
{ dir: '.git/rebase-merge', abortArgs: ['rebase', '--abort'] },
|
|
@@ -41,7 +45,7 @@ export function recoverInterruptedGit(transportRoot: string): boolean {
|
|
|
41
45
|
for (const { dir, abortArgs } of halfStates) {
|
|
42
46
|
if (existsSync(join(transportRoot, dir))) {
|
|
43
47
|
const r = captureGit(transportRoot, abortArgs);
|
|
44
|
-
|
|
48
|
+
logError(
|
|
45
49
|
transportRoot,
|
|
46
50
|
'git_pull',
|
|
47
51
|
`recovered from interrupted git state at ${dir} via 'git ${abortArgs.join(' ')}' (exit=${r.status})`,
|
|
@@ -52,17 +56,60 @@ export function recoverInterruptedGit(transportRoot: string): boolean {
|
|
|
52
56
|
return false;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
// The commit cursors anchor to. Prefer the origin tip: origin history is
|
|
60
|
+
// append-only, so a cursor pointing there can never be orphaned by a local
|
|
61
|
+
// `pull --rebase` rewriting unpushed commits. HEAD is the fallback for
|
|
62
|
+
// transports without a remote.
|
|
63
|
+
export function cursorBaseline(transportRoot: string): string | null {
|
|
64
|
+
for (const ref of ['origin/HEAD', 'origin/main', 'HEAD']) {
|
|
65
|
+
const r = captureGit(transportRoot, ['rev-parse', ref]);
|
|
66
|
+
if (r.status === 0) return r.stdout.trim();
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Find the commit that introduced this actor's host file. Used to seed
|
|
72
|
+
// the cursor on first boot: messages sent after the host file landed are
|
|
73
|
+
// deliverable (store-and-forward); pre-join history is ignored. Falls back
|
|
74
|
+
// to HEAD if git log fails (conservative: no history replay in that case).
|
|
75
|
+
export function hostFileCommit(transportRoot: string, hostname: string): string | null {
|
|
76
|
+
const hostPath = `hosts/${hostname}.md`;
|
|
77
|
+
const r = captureGit(transportRoot, ['log', '--format=%H', '--diff-filter=A', '--', hostPath]);
|
|
78
|
+
if (r.status !== 0 || !r.stdout.trim()) return null;
|
|
79
|
+
// `git log` lists newest first; the last line is the introducing commit.
|
|
80
|
+
const lines = r.stdout.trim().split('\n').filter(Boolean);
|
|
81
|
+
return lines[lines.length - 1] ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Repo-relative paths of message files added between `sinceCommit` and
|
|
85
|
+
// HEAD. Returns null when the commit is unknown to this clone (state dir
|
|
86
|
+
// copied across transports, history rewritten) — caller falls back to a
|
|
87
|
+
// full channel scan.
|
|
88
|
+
export function newFilesSince(transportRoot: string, sinceCommit: string): string[] | null {
|
|
89
|
+
const r = captureGit(transportRoot, [
|
|
90
|
+
'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..HEAD`, '--', 'data/channels/',
|
|
91
|
+
]);
|
|
92
|
+
if (r.status !== 0) return null;
|
|
93
|
+
return r.stdout.split('\n').filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
export function gitPull(transportRoot: string): GitResult {
|
|
56
97
|
recoverInterruptedGit(transportRoot);
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
return { ok: false, error: (
|
|
98
|
+
const fetch = captureGit(transportRoot, ['fetch', 'origin', '--quiet']);
|
|
99
|
+
if (fetch.status !== 0) {
|
|
100
|
+
return { ok: false, error: (fetch.stderr || fetch.stdout).trim().slice(0, 500) };
|
|
101
|
+
}
|
|
102
|
+
const rebase = captureGit(transportRoot, ['rebase', 'origin/main']);
|
|
103
|
+
if (rebase.status !== 0) {
|
|
104
|
+
return { ok: false, error: (rebase.stderr || rebase.stdout).trim().slice(0, 500) };
|
|
60
105
|
}
|
|
61
106
|
return { ok: true };
|
|
62
107
|
}
|
|
63
108
|
|
|
109
|
+
// Stage data/ only, commit, push. On push rejection, one pull --rebase +
|
|
110
|
+
// re-push — collision-free filenames make the rebase trivially clean.
|
|
64
111
|
export function gitCommitAndPush(transportRoot: string, message: string): GitPushResult {
|
|
65
|
-
const status = captureGit(transportRoot, ['status', '--porcelain']);
|
|
112
|
+
const status = captureGit(transportRoot, ['status', '--porcelain', '--', 'data/']);
|
|
66
113
|
if (status.status !== 0) {
|
|
67
114
|
return { ok: false, committed: false, pushed: false, error: status.stderr.trim().slice(0, 500) };
|
|
68
115
|
}
|
|
@@ -70,44 +117,30 @@ export function gitCommitAndPush(transportRoot: string, message: string): GitPus
|
|
|
70
117
|
return { ok: true, committed: false, pushed: false };
|
|
71
118
|
}
|
|
72
119
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// untracks it via gitignore). Pathspec exclusion is independent of the
|
|
76
|
-
// transport's .gitignore — defensive against gitignore drift.
|
|
77
|
-
//
|
|
78
|
-
// Edge: `git add -A . :(exclude).turnq` exits non-zero with "The following
|
|
79
|
-
// paths are ignored by one of your .gitignore files: .turnq" because the
|
|
80
|
-
// exclude pathspec itself matches a gitignored path. The add still stages
|
|
81
|
-
// every other change correctly — only the exit code is misleading. So we
|
|
82
|
-
// treat that specific failure-pattern as benign and let the subsequent
|
|
83
|
-
// commit step decide whether anything actually got staged.
|
|
84
|
-
const add = captureGit(transportRoot, ['add', '-A', '.', ':(exclude).turnq']);
|
|
85
|
-
const addBenignIgnoredPath = add.status !== 0 &&
|
|
86
|
-
/paths are ignored/.test(add.stderr);
|
|
87
|
-
if (add.status !== 0 && !addBenignIgnoredPath) {
|
|
120
|
+
const add = captureGit(transportRoot, ['add', '--', 'data/']);
|
|
121
|
+
if (add.status !== 0) {
|
|
88
122
|
return { ok: false, committed: false, pushed: false, error: add.stderr.trim().slice(0, 500) };
|
|
89
123
|
}
|
|
90
124
|
|
|
91
|
-
|
|
92
|
-
// may still hold tracked .turnq/* entries. Untrack them here so subsequent
|
|
93
|
-
// pulls don't fight with operator clones that have untracked .turnq/. This
|
|
94
|
-
// is a one-time-per-transport heal; on a clean transport it's a no-op.
|
|
95
|
-
const indexedTurnq = captureGit(transportRoot, ['ls-files', '.turnq']);
|
|
96
|
-
if (indexedTurnq.status === 0 && indexedTurnq.stdout.trim().length > 0) {
|
|
97
|
-
captureGit(transportRoot, ['rm', '-r', '--cached', '--quiet', '.turnq']);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const commit = captureGit(transportRoot, ['commit', '-m', message]);
|
|
125
|
+
const commit = captureGit(transportRoot, ['commit', '-m', message, '--', 'data/']);
|
|
101
126
|
if (commit.status !== 0) {
|
|
102
|
-
// Empty commit ("nothing to commit") is fine — the exclusion may have
|
|
103
|
-
// dropped the only change. Treat exit-1 with no error text as no-op.
|
|
104
127
|
const noop = commit.stdout.includes('nothing to commit') ||
|
|
105
128
|
commit.stderr.includes('nothing to commit');
|
|
106
129
|
if (noop) return { ok: true, committed: false, pushed: false };
|
|
107
130
|
return { ok: false, committed: false, pushed: false, error: commit.stderr.trim().slice(0, 500) };
|
|
108
131
|
}
|
|
109
132
|
|
|
110
|
-
|
|
133
|
+
// Push rejection is NORMAL under concurrent writers — git is the
|
|
134
|
+
// arbiter and collision-free filenames make every rebase clean. Retry
|
|
135
|
+
// with jitter; many writers racing one origin converge within a few
|
|
136
|
+
// rounds (verified by the Monte Carlo harness).
|
|
137
|
+
let push = captureGit(transportRoot, ['push', '--quiet']);
|
|
138
|
+
for (let attempt = 0; push.status !== 0 && attempt < 5; attempt++) {
|
|
139
|
+
spawnSync('sleep', [(0.05 + Math.random() * 0.3 * (attempt + 1)).toFixed(2)]);
|
|
140
|
+
const pull = gitPull(transportRoot);
|
|
141
|
+
if (!pull.ok) continue;
|
|
142
|
+
push = captureGit(transportRoot, ['push', '--quiet']);
|
|
143
|
+
}
|
|
111
144
|
if (push.status !== 0) {
|
|
112
145
|
return {
|
|
113
146
|
ok: false,
|
|
@@ -126,34 +159,23 @@ export function discoverChannels(transportRoot: string): string[] {
|
|
|
126
159
|
try {
|
|
127
160
|
entries = readdirSync(channelsDir);
|
|
128
161
|
} catch (err) {
|
|
129
|
-
|
|
130
|
-
transportRoot,
|
|
131
|
-
'fs',
|
|
132
|
-
`discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`,
|
|
133
|
-
);
|
|
162
|
+
logError(transportRoot, 'fs', `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`);
|
|
134
163
|
return [];
|
|
135
164
|
}
|
|
136
165
|
return entries.filter((name) => {
|
|
137
|
-
const dir = join(channelsDir, name);
|
|
138
166
|
try {
|
|
139
|
-
return statSync(
|
|
140
|
-
} catch
|
|
141
|
-
writeErrorLog(
|
|
142
|
-
transportRoot,
|
|
143
|
-
'fs',
|
|
144
|
-
`discoverChannels stat failed on ${dir}: ${(err as Error).message}`,
|
|
145
|
-
);
|
|
167
|
+
return statSync(join(channelsDir, name)).isDirectory();
|
|
168
|
+
} catch {
|
|
146
169
|
return false;
|
|
147
170
|
}
|
|
148
171
|
});
|
|
149
172
|
}
|
|
150
173
|
|
|
151
174
|
function isValidMessageFrontmatter(data: Record<string, unknown>): boolean {
|
|
152
|
-
|
|
153
|
-
if (typeof data
|
|
154
|
-
if (typeof data
|
|
155
|
-
if (typeof data
|
|
156
|
-
if (typeof data.timestamp !== 'string') return false;
|
|
175
|
+
if (typeof data['from'] !== 'string') return false;
|
|
176
|
+
if (typeof data['to'] !== 'string' && !Array.isArray(data['to'])) return false;
|
|
177
|
+
if (typeof data['type'] !== 'string') return false;
|
|
178
|
+
if (typeof data['timestamp'] !== 'string') return false;
|
|
157
179
|
return true;
|
|
158
180
|
}
|
|
159
181
|
|
|
@@ -175,19 +197,11 @@ export function listChannelMessages(transportRoot: string, channelUuid: string):
|
|
|
175
197
|
try {
|
|
176
198
|
parsed = parseFrontmatter(raw);
|
|
177
199
|
} catch (err) {
|
|
178
|
-
|
|
179
|
-
transportRoot,
|
|
180
|
-
'parse',
|
|
181
|
-
`frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`,
|
|
182
|
-
);
|
|
200
|
+
logError(transportRoot, 'parse', `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`);
|
|
183
201
|
continue;
|
|
184
202
|
}
|
|
185
203
|
if (!isValidMessageFrontmatter(parsed.data)) {
|
|
186
|
-
|
|
187
|
-
transportRoot,
|
|
188
|
-
'parse',
|
|
189
|
-
`invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, type, timestamp)`,
|
|
190
|
-
);
|
|
204
|
+
logError(transportRoot, 'parse', `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, type, timestamp)`);
|
|
191
205
|
continue;
|
|
192
206
|
}
|
|
193
207
|
results.push({ relPath: rel, fullPath: full, data: parsed.data, body: parsed.body });
|
|
@@ -197,134 +211,3 @@ export function listChannelMessages(transportRoot: string, channelUuid: string):
|
|
|
197
211
|
walk(channelDir, '');
|
|
198
212
|
return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
199
213
|
}
|
|
200
|
-
|
|
201
|
-
// ── errors/ log — infrastructure failures (git, fs, etc.) ──
|
|
202
|
-
// Deduped by (kind + signature). If a matching entry exists, increment count
|
|
203
|
-
// + update lastAt. Otherwise create a new entry.
|
|
204
|
-
|
|
205
|
-
export type ErrorKind = 'git_pull' | 'git_push' | 'git_commit' | 'fs' | 'parse' | 'other';
|
|
206
|
-
|
|
207
|
-
interface ErrorEntry {
|
|
208
|
-
id: string;
|
|
209
|
-
kind: ErrorKind;
|
|
210
|
-
signature: string;
|
|
211
|
-
count: number;
|
|
212
|
-
firstAt: string;
|
|
213
|
-
lastAt: string;
|
|
214
|
-
error: string;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function errorSignature(kind: ErrorKind, error: string): string {
|
|
218
|
-
const firstLine = error.split('\n')[0] ?? '';
|
|
219
|
-
return `${kind}::${firstLine.trim().slice(0, 120)}`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
export function writeErrorLog(transportRoot: string, kind: ErrorKind, error: string): string {
|
|
223
|
-
const dir = join(transportRoot, 'errors');
|
|
224
|
-
mkdirSync(dir, { recursive: true });
|
|
225
|
-
const sig = errorSignature(kind, error);
|
|
226
|
-
|
|
227
|
-
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
|
|
228
|
-
for (const f of files) {
|
|
229
|
-
const path = join(dir, f);
|
|
230
|
-
try {
|
|
231
|
-
const { data } = parseFrontmatter<ErrorEntry>(readFileSync(path, 'utf-8'));
|
|
232
|
-
if (data.signature === sig) {
|
|
233
|
-
const updated: ErrorEntry = {
|
|
234
|
-
...data,
|
|
235
|
-
count: (data.count ?? 1) + 1,
|
|
236
|
-
lastAt: new Date().toISOString(),
|
|
237
|
-
error: error.slice(0, 500),
|
|
238
|
-
};
|
|
239
|
-
writeFileSync(path, serializeFrontmatter(updated as unknown as Record<string, unknown>, error));
|
|
240
|
-
return data.id;
|
|
241
|
-
}
|
|
242
|
-
} catch { /* skip unparseable */ }
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const ts = now();
|
|
246
|
-
const id = `${ts.fileTime}-${ts.hex}`;
|
|
247
|
-
const entry: ErrorEntry = {
|
|
248
|
-
id, kind, signature: sig,
|
|
249
|
-
count: 1,
|
|
250
|
-
firstAt: ts.iso, lastAt: ts.iso,
|
|
251
|
-
error: error.slice(0, 500),
|
|
252
|
-
};
|
|
253
|
-
writeFileSync(
|
|
254
|
-
join(dir, `${id}.md`),
|
|
255
|
-
serializeFrontmatter(entry as unknown as Record<string, unknown>, error),
|
|
256
|
-
);
|
|
257
|
-
return id;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export function countErrorEntries(transportRoot: string): number {
|
|
261
|
-
const dir = join(transportRoot, 'errors');
|
|
262
|
-
if (!existsSync(dir)) return 0;
|
|
263
|
-
return readdirSync(dir).filter((f) => f.endsWith('.md')).length;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Sweep channels for read receipts older than `thresholdMs` that lack a
|
|
267
|
-
// corresponding `type: text` reply from the same actor referencing the same
|
|
268
|
-
// original message. Each stale receipt is logged once to errors/ kind=parse
|
|
269
|
-
// (we reuse 'parse' rather than introduce another kind for one weak signal).
|
|
270
|
-
// Returns the number of stale receipts surfaced.
|
|
271
|
-
export function sweepStaleReadReceipts(
|
|
272
|
-
transportRoot: string,
|
|
273
|
-
thresholdMs: number,
|
|
274
|
-
): number {
|
|
275
|
-
const channelsDir = join(transportRoot, 'data', 'channels');
|
|
276
|
-
if (!existsSync(channelsDir)) return 0;
|
|
277
|
-
let channels: string[];
|
|
278
|
-
try {
|
|
279
|
-
channels = readdirSync(channelsDir).filter((name) => {
|
|
280
|
-
try { return statSync(join(channelsDir, name)).isDirectory(); }
|
|
281
|
-
catch { return false; }
|
|
282
|
-
});
|
|
283
|
-
} catch { return 0; }
|
|
284
|
-
|
|
285
|
-
const now = Date.now();
|
|
286
|
-
let surfaced = 0;
|
|
287
|
-
|
|
288
|
-
for (const channelUuid of channels) {
|
|
289
|
-
const messages = listChannelMessages(transportRoot, channelUuid);
|
|
290
|
-
|
|
291
|
-
// Build: for each (fromActor, refRelPath), did a text reply follow the read receipt?
|
|
292
|
-
const readReceipts: { actor: string; ref: string; ts: number; relPath: string }[] = [];
|
|
293
|
-
const replyKeys = new Set<string>();
|
|
294
|
-
|
|
295
|
-
for (const msg of messages) {
|
|
296
|
-
const from = typeof msg.data['from'] === 'string' ? msg.data['from'] : null;
|
|
297
|
-
const type = typeof msg.data['type'] === 'string' ? msg.data['type'] : null;
|
|
298
|
-
const timestamp = typeof msg.data['timestamp'] === 'string' ? msg.data['timestamp'] : null;
|
|
299
|
-
const ref = typeof msg.data['ref'] === 'string' ? msg.data['ref'] : null;
|
|
300
|
-
if (!from || !type || !timestamp) continue;
|
|
301
|
-
|
|
302
|
-
if (type === 'read' && ref) {
|
|
303
|
-
readReceipts.push({
|
|
304
|
-
actor: from,
|
|
305
|
-
ref,
|
|
306
|
-
ts: new Date(timestamp).getTime(),
|
|
307
|
-
relPath: msg.relPath,
|
|
308
|
-
});
|
|
309
|
-
} else if (type === 'text') {
|
|
310
|
-
// A text reply doesn't carry a ref field. We approximate "did this
|
|
311
|
-
// actor reply after the read receipt" by keying on actor only; if
|
|
312
|
-
// any later text exists from the same actor in this channel, treat
|
|
313
|
-
// earlier read receipts as resolved. Imperfect but cheap.
|
|
314
|
-
replyKeys.add(from);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
for (const rr of readReceipts) {
|
|
319
|
-
if (replyKeys.has(rr.actor)) continue;
|
|
320
|
-
if (now - rr.ts <= thresholdMs) continue;
|
|
321
|
-
writeErrorLog(
|
|
322
|
-
transportRoot,
|
|
323
|
-
'parse',
|
|
324
|
-
`stale read receipt ${channelUuid}/${rr.relPath}: actor=${rr.actor} claimed ref=${rr.ref} ${Math.floor((now - rr.ts) / 60000)}min ago, no reply yet`,
|
|
325
|
-
);
|
|
326
|
-
surfaced++;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return surfaced;
|
|
330
|
-
}
|
package/src/turnq.ts
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Advisory turn coordination via @cordfuse/turnq.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// Distributed (env var TURNQ_URL set): HTTP coordinator running on a
|
|
10
|
-
// reachable turnq server. Serializes turns across multiple hosts. If
|
|
11
|
-
// the server is unreachable, falls back to local silently (default
|
|
12
|
-
// `fallback: true`).
|
|
3
|
+
// The lock is an OPTIMIZATION, never a correctness requirement — git
|
|
4
|
+
// arbitrates (collision-free filenames + push rejection/rebase retry).
|
|
5
|
+
// So: wait a bounded time for the turn, then proceed without it. A turnq
|
|
6
|
+
// bug or outage can never wedge a dispatcher; it just costs an occasional
|
|
7
|
+
// rebase. Failures are loud (errors.log), never silently fallen back.
|
|
13
8
|
//
|
|
14
9
|
// Env vars:
|
|
15
|
-
// TURNQ_URL — optional, e.g. http://turnq:3003 (unset → local
|
|
10
|
+
// TURNQ_URL — optional, e.g. http://turnq:3003 (unset → local flock)
|
|
16
11
|
// TURNQ_API_KEY — required when TURNQ_URL is set
|
|
17
|
-
// TURNQ_CHANNEL — optional namespace prefix (default: "crosstalk")
|
|
18
|
-
// Concatenated with the lock name to form the actual
|
|
19
|
-
// channel — e.g. "crosstalk/dispatch". Lets multiple
|
|
20
|
-
// transports share the same turnq server without
|
|
21
|
-
// colliding.
|
|
12
|
+
// TURNQ_CHANNEL — optional namespace prefix (default: "crosstalk")
|
|
22
13
|
|
|
23
14
|
import { createCoordinator } from '@cordfuse/turnq/coordinator';
|
|
15
|
+
import { logError } from './state.js';
|
|
16
|
+
|
|
17
|
+
const TURN_WAIT_MS = 15_000;
|
|
24
18
|
|
|
25
19
|
interface Coordinator {
|
|
26
20
|
withTurn<T>(channel: string, fn: () => Promise<T>): Promise<T>;
|
|
@@ -31,29 +25,67 @@ let coordinatorPromise: Promise<Coordinator> | null = null;
|
|
|
31
25
|
|
|
32
26
|
function getCoordinator(): Promise<Coordinator> {
|
|
33
27
|
if (!coordinatorPromise) {
|
|
34
|
-
const url = process.env
|
|
35
|
-
const apiKey = process.env
|
|
28
|
+
const url = process.env['TURNQ_URL'];
|
|
29
|
+
const apiKey = process.env['TURNQ_API_KEY'];
|
|
36
30
|
coordinatorPromise = createCoordinator(
|
|
37
|
-
url ? { url, apiKey, fallback:
|
|
31
|
+
url ? { url, apiKey, fallback: false } : {},
|
|
38
32
|
);
|
|
39
33
|
}
|
|
40
34
|
return coordinatorPromise;
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
function channelFor(name: string): string {
|
|
44
|
-
const prefix = process.env
|
|
38
|
+
const prefix = process.env['TURNQ_CHANNEL'] ?? 'crosstalk';
|
|
45
39
|
return `${prefix}/${name}`;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
42
|
+
// Run `fn` while holding the named turn if it can be acquired within
|
|
43
|
+
// TURN_WAIT_MS; otherwise (timeout, coordinator error, server down) log
|
|
44
|
+
// and run `fn` anyway. `fn` runs exactly once — the `ran` guard is safe
|
|
45
|
+
// because the event loop is single-threaded.
|
|
46
|
+
export async function withLock<T>(transportRoot: string, name: string, fn: () => Promise<T>): Promise<T> {
|
|
47
|
+
let ran = false;
|
|
48
|
+
const runOnce = (): Promise<T> => {
|
|
49
|
+
ran = true;
|
|
50
|
+
return fn();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let coordinator: Coordinator;
|
|
54
|
+
try {
|
|
55
|
+
coordinator = await getCoordinator();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
coordinatorPromise = null;
|
|
58
|
+
logError(transportRoot, 'turnq', `coordinator init failed — proceeding without lock: ${(err as Error).message}`);
|
|
59
|
+
return runOnce();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise<T>((resolve, reject) => {
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
if (ran) return;
|
|
65
|
+
logError(transportRoot, 'turnq', `turn '${name}' not granted within ${TURN_WAIT_MS}ms — proceeding without lock`);
|
|
66
|
+
runOnce().then(resolve, reject);
|
|
67
|
+
}, TURN_WAIT_MS);
|
|
68
|
+
timer.unref?.();
|
|
69
|
+
|
|
70
|
+
coordinator
|
|
71
|
+
.withTurn(channelFor(name), async () => {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
if (ran) return; // timeout path already ran fn; release the turn immediately
|
|
74
|
+
await runOnce().then(resolve, reject);
|
|
75
|
+
})
|
|
76
|
+
.catch((err) => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
if (ran) return;
|
|
79
|
+
logError(transportRoot, 'turnq', `turn '${name}' failed — proceeding without lock: ${(err as Error).message}`);
|
|
80
|
+
runOnce().then(resolve, reject);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function closeCoordinator(): Promise<void> {
|
|
86
|
+
if (!coordinatorPromise) return;
|
|
87
|
+
try {
|
|
88
|
+
(await coordinatorPromise).close();
|
|
89
|
+
} catch { /* best-effort */ }
|
|
90
|
+
coordinatorPromise = null;
|
|
59
91
|
}
|
package/src/upgrade.ts
CHANGED
|
@@ -8,26 +8,27 @@
|
|
|
8
8
|
// files into the operator's transport so they catch up.
|
|
9
9
|
//
|
|
10
10
|
// What this command DOES touch:
|
|
11
|
-
// - upstream/CROSSTALK.md, PROTOCOL.md, OPERATOR.md
|
|
11
|
+
// - upstream/CROSSTALK.md, PROTOCOL.md, OPERATOR.md
|
|
12
12
|
// - upstream/CROSSTALK-VERSION
|
|
13
13
|
// - upstream/actors/ (default actor profile starter set)
|
|
14
14
|
//
|
|
15
15
|
// What this command NEVER touches:
|
|
16
16
|
// - local/ — operator-owned actor profiles and identity
|
|
17
|
-
// - hosts/ — operator-owned host
|
|
17
|
+
// - hosts/ — operator-owned host files
|
|
18
18
|
// - data/ — channels and memories
|
|
19
|
-
// - cursors/, dlq/, errors/ — dispatcher-owned runtime state
|
|
20
19
|
// - root pointer files (CLAUDE.md etc.) — only updated if a --pointers flag
|
|
21
20
|
// is passed, since they rarely change and overwriting them surprises
|
|
22
21
|
// operators who've customized them
|
|
22
|
+
// (Dispatcher state — cursors, dlq, error logs — lives outside the repo
|
|
23
|
+
// in the state dir and is never in scope.)
|
|
23
24
|
//
|
|
24
25
|
// Usage:
|
|
25
26
|
// crosstalk upgrade — sync upstream/ from runtime template
|
|
26
27
|
// crosstalk upgrade --dry-run — show what would change, no writes
|
|
27
|
-
// crosstalk upgrade --pointers — also overwrite the
|
|
28
|
+
// crosstalk upgrade --pointers — also overwrite the entry pointer files
|
|
28
29
|
|
|
29
|
-
import { existsSync, readFileSync,
|
|
30
|
-
import { resolve, join, dirname
|
|
30
|
+
import { existsSync, readFileSync, cpSync, statSync, readdirSync, mkdirSync } from 'fs';
|
|
31
|
+
import { resolve, join, dirname } from 'path';
|
|
31
32
|
import { fileURLToPath } from 'url';
|
|
32
33
|
|
|
33
34
|
const transportRoot = resolve(process.cwd());
|
|
@@ -195,10 +196,7 @@ if (updatePointers) {
|
|
|
195
196
|
const fromPath = join(templateDir, pf);
|
|
196
197
|
const toPath = join(transportRoot, pf);
|
|
197
198
|
if (!existsSync(fromPath)) continue;
|
|
198
|
-
|
|
199
|
-
if (!existsSync(targetDir)) {
|
|
200
|
-
cpSync(targetDir, targetDir, { recursive: true });
|
|
201
|
-
}
|
|
199
|
+
mkdirSync(dirname(toPath), { recursive: true });
|
|
202
200
|
cpSync(fromPath, toPath, { force: true });
|
|
203
201
|
}
|
|
204
202
|
console.log(` ✓ pointer files synced`);
|
|
@@ -210,4 +208,4 @@ console.log(`Commit the upstream/ changes when ready:`);
|
|
|
210
208
|
console.log(` git add upstream/${updatePointers ? ' ' + POINTER_FILES.slice(0, 3).join(' ') + ' ...' : ''}`);
|
|
211
209
|
console.log(` git commit -m "spec: upgrade to ${toVersion}"`);
|
|
212
210
|
console.log('');
|
|
213
|
-
console.log('Your local/, hosts/, data
|
|
211
|
+
console.log('Your local/, hosts/, and data/ were not touched.');
|
package/src/wake.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
import { resolve, join } from 'path';
|
|
1
|
+
// crosstalk wake — poke the local dispatcher to tick immediately.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { sendWakeSignal } from './state.js';
|
|
5
|
+
|
|
6
|
+
sendWakeSignal(resolve(process.cwd()));
|
|
8
7
|
console.log('wake signal sent');
|
package/template/CLAUDE.md
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# Crosstalk transport
|
|
2
|
+
|
|
3
|
+
You are inside a Crosstalk transport — a git repo that carries async
|
|
4
|
+
messages between AI agents across machines.
|
|
5
|
+
|
|
6
|
+
Read these before doing anything here:
|
|
7
|
+
|
|
8
|
+
1. `upstream/PROTOCOL.md` — how to behave when dispatched (start here)
|
|
9
|
+
2. `upstream/CROSSTALK.md` — the full protocol spec, if you need details
|
|
10
|
+
|
|
11
|
+
Do not edit files under `data/` by hand — messages are written only by
|
|
12
|
+
`crosstalk send` and the dispatcher.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
6.0
|