@cordfuse/crosstalk 6.0.0-alpha.1 → 6.0.0-alpha.2

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/bin/crosstalk.js CHANGED
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'url';
14
14
  import { createRequire } from 'module';
15
15
 
16
16
  const SUBCOMMANDS = [
17
- 'dispatch', 'send', 'replies', 'wake', 'status', 'init', 'dlq', 'channel',
17
+ 'dispatch', 'stop', 'send', 'replies', 'wake', 'status', 'init', 'dlq', 'channel',
18
18
  'chat', 'open', 'attach', 'upgrade',
19
19
  ];
20
20
  const STANDALONE_SUBCOMMANDS = new Set(['init']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "6.0.0-alpha.1",
3
+ "version": "6.0.0-alpha.2",
4
4
  "description": "Crosstalk runtime — async messaging between agents over git, across machines.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/dispatch.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  readCursor,
37
37
  writeCursor,
38
38
  writeHeartbeat,
39
+ writePidfile,
40
+ removePidfile,
39
41
  logError,
40
42
  } from './state.js';
41
43
  import { recipients, reList, decideWake, splitForConcurrency } from './activation.js';
@@ -360,26 +362,32 @@ async function dispatchTick(): Promise<TickResult> {
360
362
  const cursor = readCursor(transportRoot, actorName, channelUuid);
361
363
  if (cursor === head) continue;
362
364
 
365
+ // First encounter: seed to HEAD so only future messages are dispatched.
366
+ // Without this, a null cursor falls through to `post = messages` and
367
+ // replays the full channel history on every fresh-state boot.
368
+ if (cursor === null) {
369
+ writeCursor(transportRoot, actorName, channelUuid, head);
370
+ continue;
371
+ }
372
+
363
373
  const messages = listChannelMessages(transportRoot, channelUuid);
364
374
  const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
365
375
  const senderOf = (relPath: string) => senderByRelPath.get(relPath);
366
376
 
367
- let post = messages;
368
- if (cursor) {
369
- let added = addedSince.get(cursor);
370
- if (added === undefined) {
371
- const files = newFilesSince(transportRoot, cursor);
372
- added = files === null ? null : new Set(files);
373
- addedSince.set(cursor, added);
374
- if (added === null) {
375
- logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
376
- }
377
- }
378
- if (added !== null) {
379
- const prefix = `data/channels/${channelUuid}/`;
380
- post = messages.filter((m) => added.has(prefix + m.relPath));
377
+ let added = addedSince.get(cursor);
378
+ if (added === undefined) {
379
+ const files = newFilesSince(transportRoot, cursor);
380
+ added = files === null ? null : new Set(files);
381
+ addedSince.set(cursor, added);
382
+ if (added === null) {
383
+ logError(transportRoot, 'other', `cursor commit ${cursor.slice(0, 12)} unknown to this clone — full channel re-scan`);
381
384
  }
382
385
  }
386
+ let post = messages;
387
+ if (added !== null) {
388
+ const prefix = `data/channels/${channelUuid}/`;
389
+ post = messages.filter((m) => added.has(prefix + m.relPath));
390
+ }
383
391
  if (post.length === 0) {
384
392
  writeCursor(transportRoot, actorName, channelUuid, head);
385
393
  continue;
@@ -466,6 +474,12 @@ async function waitForWakeOrTimeout(ms: number): Promise<void> {
466
474
  }
467
475
 
468
476
  async function main(): Promise<void> {
477
+ writePidfile(transportRoot);
478
+ const cleanup = () => removePidfile(transportRoot);
479
+ process.on('exit', cleanup);
480
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
481
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
482
+
469
483
  log('dispatch_start', { transport: transportRoot, version: RUNTIME_VERSION, state_dir: stateDir(transportRoot) });
470
484
  if (onceMode) {
471
485
  await dispatchTick();
package/src/send.ts CHANGED
@@ -77,7 +77,7 @@ async function main(): Promise<void> {
77
77
  console.error(`but git ${pushResult.committed ? 'push' : 'commit'} FAILED:`);
78
78
  console.error(` ${pushResult.error.slice(0, 300)}`);
79
79
  console.error('\nYour message is in the local clone but not on origin. Recover with:');
80
- console.error(' git pull --rebase && git push');
80
+ console.error(' git fetch origin && git rebase origin/main && git push');
81
81
  process.exit(3);
82
82
  }
83
83
 
package/src/state.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // dlq/<id>.md — failed-dispatch entries
9
9
  // errors.log — infra failures, JSONL, append-only
10
10
  // heartbeat — last tick timestamp + pid + version
11
+ // dispatcher.pid — PID of the running dispatcher process
11
12
  // wake.signal — touched to wake the dispatch loop
12
13
  //
13
14
  // Location: $CROSSTALK_STATE_DIR if set (exact dir — container-friendly),
@@ -19,6 +20,7 @@ import {
19
20
  mkdirSync,
20
21
  readFileSync,
21
22
  writeFileSync,
23
+ unlinkSync,
22
24
  appendFileSync,
23
25
  } from 'fs';
24
26
  import { join, dirname } from 'path';
@@ -95,6 +97,34 @@ export function writeCursor(
95
97
  writeFileSync(p, commit + '\n');
96
98
  }
97
99
 
100
+ // ── pidfile ──
101
+
102
+ export function pidfilePath(transportRoot: string): string {
103
+ return join(stateDir(transportRoot), 'dispatcher.pid');
104
+ }
105
+
106
+ export function writePidfile(transportRoot: string): void {
107
+ try {
108
+ writeFileSync(pidfilePath(transportRoot), `${process.pid}\n`);
109
+ } catch { /* best-effort */ }
110
+ }
111
+
112
+ export function removePidfile(transportRoot: string): void {
113
+ try {
114
+ unlinkSync(pidfilePath(transportRoot));
115
+ } catch { /* already gone */ }
116
+ }
117
+
118
+ export function readPidfile(transportRoot: string): number | null {
119
+ try {
120
+ const raw = readFileSync(pidfilePath(transportRoot), 'utf-8').trim();
121
+ const n = parseInt(raw, 10);
122
+ return Number.isFinite(n) && n > 0 ? n : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
98
128
  // ── heartbeat + wake ──
99
129
 
100
130
  export function writeHeartbeat(transportRoot: string, version: string): void {
package/src/stop.ts ADDED
@@ -0,0 +1,37 @@
1
+ // crosstalk stop — send SIGTERM to the running dispatcher and wait for it to exit.
2
+
3
+ import { resolve } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { readPidfile, removePidfile } from './state.js';
6
+
7
+ const transportRoot = resolve(process.cwd());
8
+
9
+ function processRunning(pid: number): boolean {
10
+ try { process.kill(pid, 0); return true; } catch { return false; }
11
+ }
12
+
13
+ const pid = readPidfile(transportRoot);
14
+ if (pid === null) {
15
+ console.error('crosstalk stop: no dispatcher.pid found — is dispatch running?');
16
+ process.exit(1);
17
+ }
18
+
19
+ if (!processRunning(pid)) {
20
+ console.error(`crosstalk stop: pid ${pid} is not running — removing stale pidfile`);
21
+ removePidfile(transportRoot);
22
+ process.exit(1);
23
+ }
24
+
25
+ process.kill(pid, 'SIGTERM');
26
+
27
+ const deadline = Date.now() + 5_000;
28
+ while (Date.now() < deadline) {
29
+ if (!processRunning(pid)) {
30
+ console.log(`crosstalk stop: dispatcher (pid ${pid}) stopped`);
31
+ process.exit(0);
32
+ }
33
+ spawnSync('sleep', ['0.1']);
34
+ }
35
+
36
+ console.error(`crosstalk stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
37
+ process.exit(1);