@volter/supercode-teams 0.3.98 → 0.3.99
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/machine.mjs +30 -2
- package/mail/relay.mjs +92 -17
- package/mail/send.mjs +18 -2
- package/mail/store.mjs +6 -0
- package/package.json +1 -1
package/machine.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execFile } from 'node:child_process';
|
|
|
30
30
|
import { EventEmitter } from 'node:events';
|
|
31
31
|
import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
|
|
32
32
|
import { basename, join, resolve, dirname, isAbsolute } from 'node:path';
|
|
33
|
+
import { open as openAsync, readdir as readdirAsync } from 'node:fs/promises';
|
|
33
34
|
import { askBoardOwner, BOARD_OPERATION_VERBS, boardHome } from '@volter/teams/board-owner';
|
|
34
35
|
import { openMachine, normalMachineName } from '@volter/teams/machine-door';
|
|
35
36
|
import { RecordHolder, RecordProducer, RECENT_PER_MAILBOX, RECORDS_NOT_GRANTED } from './machine-records.mjs';
|
|
@@ -153,6 +154,31 @@ const LAUNCH_SETTLE_MS = 500;
|
|
|
153
154
|
/** How long a send to another machine waits for its mail door's answer (its receiver's door there: a relay's receipt
|
|
154
155
|
* alone may take 90 s). An answer that does not come is unanswered, never refused or failed. */
|
|
155
156
|
const MAIL_SEND_MS = 120_000;
|
|
157
|
+
|
|
158
|
+
/** How much of a transcript's end is read for a message that just landed. */
|
|
159
|
+
const LANDING_TAIL_BYTES = 4 * 1024 * 1024;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether Claude session `sessionId`'s transcript (`<projects>/<folder>/<id>.jsonl`, in any of `projectsDirs`) holds
|
|
163
|
+
* `landed` in its last LANDING_TAIL_BYTES: a relay's send whose receipt was lost is read where it lands. Read apart from
|
|
164
|
+
* the daemon's loop (it runs once per unconfirmed send).
|
|
165
|
+
*/
|
|
166
|
+
async function claudeTranscriptHolds(projectsDirs, sessionId, landed) {
|
|
167
|
+
for (const projects of projectsDirs) {
|
|
168
|
+
let folders = [];
|
|
169
|
+
try { folders = await readdirAsync(projects); } catch { continue; }
|
|
170
|
+
for (const folder of folders) {
|
|
171
|
+
let file;
|
|
172
|
+
try { file = await openAsync(join(projects, folder, `${sessionId}.jsonl`), 'r'); } catch { continue; }
|
|
173
|
+
try {
|
|
174
|
+
const { size } = await file.stat(), length = Math.min(size, LANDING_TAIL_BYTES);
|
|
175
|
+
const { buffer } = await file.read(Buffer.alloc(length), 0, length, size - length);
|
|
176
|
+
if (buffer.toString('utf8').includes(landed)) return true;
|
|
177
|
+
} finally { await file.close(); }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
156
182
|
/**
|
|
157
183
|
* The doors whose work waits on a session, with the bound each answers within (the node door's own is 30 s): a send or
|
|
158
184
|
* a hand-over waits on its receiver's door (a relay's receipt, up to 90 s; another machine's door, MAIL_SEND_MS), a
|
|
@@ -682,6 +708,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
682
708
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
683
709
|
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
684
710
|
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
711
|
+
arrived: (sessionId, landed) => claudeTranscriptHolds([...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')), sessionId, landed),
|
|
685
712
|
log: (line) => this.emit('mail-log', line) });
|
|
686
713
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
687
714
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
@@ -1115,7 +1142,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1115
1142
|
this.internalRequests.set(id, { resolve, reject });
|
|
1116
1143
|
if (!this.#toServe(`${JSON.stringify({ jsonrpc: '2.0', id: joinId(INTERNAL_CONNECTION, id), method, params })}\n`)) {
|
|
1117
1144
|
this.internalRequests.delete(id);
|
|
1118
|
-
return reject(new Error('harness serve is not running'));
|
|
1145
|
+
return reject(Object.assign(new Error('harness serve is not running'), { notAsked: true }));
|
|
1119
1146
|
}
|
|
1120
1147
|
setTimeout(() => { if (this.internalRequests.delete(id)) reject(new Error(`${method} timed out`)); }, timeoutMs).unref();
|
|
1121
1148
|
});
|
|
@@ -1145,7 +1172,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
1145
1172
|
if (typeof value === 'string') { if (!this.routes.has(value)) this.routes.set(value, new Set()); this.routes.get(value).add(INTERNAL_CONNECTION); }
|
|
1146
1173
|
}
|
|
1147
1174
|
}
|
|
1148
|
-
|
|
1175
|
+
// an answer that is an error is marked answered: a call refused, not one whose outcome is unknown
|
|
1176
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error.message ?? 'harness serve refused'), { answered: true, code: message.error.code ?? null }));
|
|
1149
1177
|
else pending.resolve(message.result);
|
|
1150
1178
|
return;
|
|
1151
1179
|
}
|
package/mail/relay.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
11
11
|
import { connect } from 'node:net';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import { blake3Hex } from './envelope.mjs';
|
|
13
|
+
import { blake3Hex, shortId } from './envelope.mjs';
|
|
14
14
|
|
|
15
15
|
/** Model name a relay's requests carry; the relay endpoint answers them without any model. */
|
|
16
16
|
const RELAY_MODEL = 'haiku';
|
|
@@ -18,6 +18,26 @@ const RELAY_MODEL = 'haiku';
|
|
|
18
18
|
export const RELAY_NAME_PREFIX = 'sc-';
|
|
19
19
|
/** How long a send waits for Claude's own receipt. */
|
|
20
20
|
const SEND_TIMEOUT_MS = 90_000;
|
|
21
|
+
|
|
22
|
+
/** How long an unconfirmed send is given to land in its receiver's transcript before that is read: a SendMessage that
|
|
23
|
+
* passed the gate just before the send ended lands within it. */
|
|
24
|
+
const LANDING_MS = 2000;
|
|
25
|
+
|
|
26
|
+
/** How long a hand-in waits for its relay's last turn to end: a relay's receipt is written mid-turn (at its
|
|
27
|
+
* SendMessage), so the next send through it may find that turn still finishing. Past this the turn is stuck. */
|
|
28
|
+
const TURN_END_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
/** A relay close's bound: over harness serve's own (RUNTIME_CONTROL_DEADLINE, 30 s), past which serve ends the
|
|
31
|
+
* runtime's process group itself, so a relay stuck in a turn is closed through serve, never left running. */
|
|
32
|
+
const CLOSE_MS = 35_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether harness serve's error for a relay's input leaves its outcome unknown: no answer (its bound passed, serve ended
|
|
36
|
+
* mid-call) or serve's own deadline on the runtime ("gave up after", harness_service.rs `within_control_deadline`).
|
|
37
|
+
* Any other answer is a refusal of the input (a turn in progress, an unknown or closed connection), and a call never
|
|
38
|
+
* made (no serve to ask) was not handed in either.
|
|
39
|
+
*/
|
|
40
|
+
const answerLost = (error) => !error.notAsked && (!error.answered || /gave up after \d+s/.test(error.message));
|
|
21
41
|
/** A relay send slower than this says where its time went. */
|
|
22
42
|
const SLOW_SEND_MS = 2000;
|
|
23
43
|
/** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
|
|
@@ -68,8 +88,8 @@ export class Relays {
|
|
|
68
88
|
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
69
89
|
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
70
90
|
*/
|
|
71
|
-
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, log = () => {} }) {
|
|
72
|
-
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, log });
|
|
91
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, log = () => {} }) {
|
|
92
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, log });
|
|
73
93
|
this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
|
|
74
94
|
this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
|
|
75
95
|
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
@@ -86,7 +106,7 @@ export class Relays {
|
|
|
86
106
|
this.running.delete(directory);
|
|
87
107
|
// closed under the relay's own lock: a send to it that arrives meanwhile waits for the close, then starts it
|
|
88
108
|
// again, never handing its turn to a runtime that is closing
|
|
89
|
-
void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id },
|
|
109
|
+
void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, CLOSE_MS))
|
|
90
110
|
.catch((error) => {
|
|
91
111
|
// still running, so still counted: the next cap closes it again (unless a send started it anew meanwhile)
|
|
92
112
|
if (!this.running.has(directory)) this.running.set(directory, relay);
|
|
@@ -102,7 +122,7 @@ export class Relays {
|
|
|
102
122
|
for (const ids of this.runtimesIn('claude-code', directory)) {
|
|
103
123
|
if (ids.includes(named)) continue;
|
|
104
124
|
void this.#locked(directory, async () => {
|
|
105
|
-
for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id },
|
|
125
|
+
for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id }, CLOSE_MS); return; } catch { /* the other id */ } }
|
|
106
126
|
this.log(`relay ${directory.split(/[\\/]/).pop()}: a runtime its lost start left running was not closed`);
|
|
107
127
|
});
|
|
108
128
|
}
|
|
@@ -135,7 +155,7 @@ export class Relays {
|
|
|
135
155
|
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
136
156
|
const record = readJson(join(directory, 'relay.json'));
|
|
137
157
|
if (record?.runtime_id) {
|
|
138
|
-
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id },
|
|
158
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, CLOSE_MS); }
|
|
139
159
|
catch (error) {
|
|
140
160
|
// a runtime still live that could not be closed keeps its folder: it is retired again at the next end or start
|
|
141
161
|
if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${directory.split(/[\\/]/).pop()} was not retired: ${error.message}`); return false; }
|
|
@@ -244,7 +264,7 @@ export class Relays {
|
|
|
244
264
|
|
|
245
265
|
/** The relay's runtime, started in this daemon's harness serve when it is not running (claude_relay.rs
|
|
246
266
|
* `ensure_relay_runtime`): `{ runtime (its receipt), connection }`. */
|
|
247
|
-
async #ensure(spec, step = () => {}) {
|
|
267
|
+
async #ensure(spec, step = () => {}, { stale = false } = {}) {
|
|
248
268
|
const endpoint = await this.endpoint();
|
|
249
269
|
step('relay endpoint');
|
|
250
270
|
if (!endpoint) throw new Error('the relay endpoint is not served by this machine\'s daemon (its log says why); a Claude session can be sent mail once the daemon starts again');
|
|
@@ -254,15 +274,16 @@ export class Relays {
|
|
|
254
274
|
const record = readJson(spec.record);
|
|
255
275
|
if (record?.runtime_id) {
|
|
256
276
|
const runtime = this.runtimeOf('claude-code', record.runtime_id);
|
|
257
|
-
if (runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
|
|
277
|
+
if (!stale && runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
|
|
258
278
|
step('runtime reused');
|
|
259
279
|
return { runtime, connection: record.connection };
|
|
260
280
|
}
|
|
261
281
|
// why it is not reused, said in the timing line: a relay that should have been warm and was not shows here
|
|
262
|
-
step(`runtime not reused (${!runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
|
|
282
|
+
step(`runtime not reused (${stale ? 'it held a stale turn' : !runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
|
|
263
283
|
// closed through the owner's door even after its receipt went
|
|
264
|
-
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id },
|
|
284
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, CLOSE_MS); }
|
|
265
285
|
catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
|
|
286
|
+
step('old runtime closed (runtimes.close)');
|
|
266
287
|
}
|
|
267
288
|
const key = spec.directory.split(/[\\/]/).pop();
|
|
268
289
|
const started = await this.#start(spec, {
|
|
@@ -297,7 +318,7 @@ export class Relays {
|
|
|
297
318
|
if (!runtime) {
|
|
298
319
|
// a runtime no send can use is closed, never left running untracked
|
|
299
320
|
this.running.delete(spec.directory);
|
|
300
|
-
try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId },
|
|
321
|
+
try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId }, CLOSE_MS); } catch { /* said below */ }
|
|
301
322
|
throw new Error('the relay started but registered no live runtime; it was closed');
|
|
302
323
|
}
|
|
303
324
|
return { runtime, connection };
|
|
@@ -312,11 +333,12 @@ export class Relays {
|
|
|
312
333
|
const check = () => {
|
|
313
334
|
const value = readJson(spec.receipt);
|
|
314
335
|
if (value) return finish(readReceipt(value));
|
|
315
|
-
|
|
336
|
+
// not known either way: it may have sent before it ended
|
|
337
|
+
if (pid && !alive(pid)) finish({ delivered: false, unknown: true, detail: 'the Claude relay process exited before confirming the send' });
|
|
316
338
|
};
|
|
317
339
|
try { watcher = watch(spec.directory, () => check()); watcher.on('error', () => {}); } catch { /* checked on the timer */ }
|
|
318
340
|
timer = setInterval(check, 500);
|
|
319
|
-
deadline = setTimeout(() => finish({ delivered: false, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
|
|
341
|
+
deadline = setTimeout(() => finish({ delivered: false, unknown: true, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
|
|
320
342
|
check();
|
|
321
343
|
});
|
|
322
344
|
}
|
|
@@ -329,14 +351,16 @@ export class Relays {
|
|
|
329
351
|
async send(sender, senderName, receiver, message, messageId) {
|
|
330
352
|
const spec = this.spec(sender, senderName);
|
|
331
353
|
const asked = performance.now();
|
|
332
|
-
|
|
354
|
+
// the message as its receiver's transcript holds it once delivered (envelope.mjs render, its tag escaped there)
|
|
355
|
+
const landed = `cross-session-message id=\\"${shortId(messageId)}\\" from=\\"${sender}\\"`;
|
|
356
|
+
try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked, landed)); }
|
|
333
357
|
finally {
|
|
334
358
|
if (String(sender).split(':')[1] !== this.machine) this.#keepRemote(spec.directory);
|
|
335
359
|
this.#cap();
|
|
336
360
|
}
|
|
337
361
|
}
|
|
338
362
|
|
|
339
|
-
async #send(spec, receiver, message, messageId, asked) {
|
|
363
|
+
async #send(spec, receiver, message, messageId, asked, landed) {
|
|
340
364
|
try {
|
|
341
365
|
// made under its lock: a retire that held the lock meanwhile may have removed it
|
|
342
366
|
mkdirSync(spec.directory, { recursive: true });
|
|
@@ -351,8 +375,30 @@ export class Relays {
|
|
|
351
375
|
writeFileSync(spec.queue, JSON.stringify(queued));
|
|
352
376
|
let receipt;
|
|
353
377
|
for (let attempt = 0; ; attempt++) {
|
|
354
|
-
|
|
355
|
-
|
|
378
|
+
// A hand-in whose answer did not come (its bound passed, serve ended mid-call, serve's own deadline on the
|
|
379
|
+
// runtime) may still have reached the relay, which then sends: its receipt is waited on as after any hand-in. A
|
|
380
|
+
// hand-in refused (an answer that is an error) or never asked (no serve) did not reach it, and is said at once,
|
|
381
|
+
// never held for a receipt that cannot come.
|
|
382
|
+
let handed = true;
|
|
383
|
+
const error = await this.#handIn(connection, queued, step);
|
|
384
|
+
if (!error) step('turn handed in (send_input)');
|
|
385
|
+
else if (answerLost(error)) step(`turn handed in, its answer lost (send_input: ${error.message})`);
|
|
386
|
+
else {
|
|
387
|
+
handed = false;
|
|
388
|
+
// still in a turn after TURN_END_MS while this send holds its lock: a turn of a send that already ended (its
|
|
389
|
+
// queue gone, so its gate refuses whatever that turn tries), stuck. The relay is closed through serve and
|
|
390
|
+
// started again, once
|
|
391
|
+
if (attempt === 0 && /already in progress/.test(error.message)) {
|
|
392
|
+
step(`relay still in a turn after ${TURN_END_MS / 1000} s, closed (${error.message})`);
|
|
393
|
+
({ runtime, connection } = await this.#ensure(spec, step, { stale: true }));
|
|
394
|
+
this.#used(spec, connection);
|
|
395
|
+
rmSync(spec.receipt, { force: true });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
receipt = { delivered: false, detail: `the Claude relay did not take the send: ${error.message}` };
|
|
399
|
+
}
|
|
400
|
+
if (!handed) break;
|
|
401
|
+
receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt');
|
|
356
402
|
const pid = this.#pid(spec, runtime);
|
|
357
403
|
// only a relay proven dead is started again; one alive but slow may still deliver
|
|
358
404
|
if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
|
|
@@ -360,6 +406,16 @@ export class Relays {
|
|
|
360
406
|
rmSync(spec.receipt, { force: true });
|
|
361
407
|
}
|
|
362
408
|
rmSync(spec.queue, { force: true });
|
|
409
|
+
// an outcome not confirmed is read where it would have landed: the relay can no longer send it (its queue is
|
|
410
|
+
// gone), so the receiver's transcript says whether it did
|
|
411
|
+
if (!receipt.delivered && receipt.unknown) {
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, LANDING_MS));
|
|
413
|
+
let found = false;
|
|
414
|
+
try { found = await this.arrived(receiver.sessionId, landed); } catch { /* unread: unknown */ }
|
|
415
|
+
step(found ? 'found in its receiver\'s transcript' : 'not in its receiver\'s transcript');
|
|
416
|
+
if (found) receipt = { delivered: true };
|
|
417
|
+
else receipt = { ...receipt, landed: false };
|
|
418
|
+
}
|
|
363
419
|
if (receipt.delivered) {
|
|
364
420
|
const sent = readJson(spec.sent) ?? {};
|
|
365
421
|
sent[receiver.sessionId] = messageId;
|
|
@@ -374,6 +430,25 @@ export class Relays {
|
|
|
374
430
|
}
|
|
375
431
|
}
|
|
376
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Hand the send's turn to the relay (`runtimes.send_input`): null once taken, else the error. A relay still in a turn
|
|
435
|
+
* (its last send's: its receipt is written mid-turn, the turn ends a moment later) is asked again every 250 ms for up
|
|
436
|
+
* to TURN_END_MS; the error is then that refusal.
|
|
437
|
+
*/
|
|
438
|
+
async #handIn(connection, queued, step) {
|
|
439
|
+
const asked = performance.now();
|
|
440
|
+
for (;;) {
|
|
441
|
+
try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); }
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (answerLost(error) || !/already in progress/.test(error.message) || performance.now() - asked > TURN_END_MS) return error;
|
|
444
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (performance.now() - asked > 250) step(`waited for its last turn to end`);
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
377
452
|
/** `runtimes.start` for a relay: a start that fails (its answer lost, or late past its bound) may still have left a
|
|
378
453
|
* runtime running, so its folder is looked at by the next caps (#cap). */
|
|
379
454
|
async #start(spec, params, timeoutMs) {
|
package/mail/send.mjs
CHANGED
|
@@ -254,6 +254,8 @@ export class MailSend {
|
|
|
254
254
|
catch (error) {
|
|
255
255
|
if (error instanceof Refused && error.kind === 'queue') return outcome(EXIT_REFUSED, `Not sent: ${name} is idle, and a Claude session always starts a turn when a message arrives, so --queue cannot hold it. Send without --queue to wake it.`);
|
|
256
256
|
if (error instanceof Refused) return outcome(EXIT_REFUSED, `Not sent: the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}. Write it to a file and send its path instead.`);
|
|
257
|
+
// may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
|
|
258
|
+
if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
|
|
257
259
|
return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
|
|
258
260
|
}
|
|
259
261
|
const [code, what] = {
|
|
@@ -321,7 +323,17 @@ export class MailSend {
|
|
|
321
323
|
const filedHere = this.store.findIn(to, handed.id) == null;
|
|
322
324
|
const filed = this.store.deliverRead(to, handed);
|
|
323
325
|
const relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id);
|
|
324
|
-
|
|
326
|
+
// a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
|
|
327
|
+
// relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
|
|
328
|
+
// mailbox meanwhile (its recipient claim) has reached it. Otherwise the relay can no longer make it (its queue is
|
|
329
|
+
// gone, so its gate refuses a late send) and it is not in the receiver's transcript: a copy this delivery filed
|
|
330
|
+
// goes back unread, for the receiver's own mailbox read and the mailbox's wake, which hands it over again
|
|
331
|
+
const received = this.store.claimOf(to, handed.id)?.recipient === true;
|
|
332
|
+
if (!relayed.delivered && relayed.unknown && !received) {
|
|
333
|
+
if (filedHere) { try { this.store.markUnread(to, filed); if (wake) this.store.requestWake(to, handed.id); } catch { /* kept read */ } }
|
|
334
|
+
throw Object.assign(new Error(relayed.detail), { unanswered: true });
|
|
335
|
+
}
|
|
336
|
+
if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
|
|
325
337
|
delivered = busy ? 'native_busy' : 'native_idle';
|
|
326
338
|
} else if (door.kind === 'hook') {
|
|
327
339
|
this.store.deliver(to, sent);
|
|
@@ -368,7 +380,7 @@ export class MailSend {
|
|
|
368
380
|
continue;
|
|
369
381
|
}
|
|
370
382
|
try { reached.push(`${label} (${await this.#deliverWoken(sent, recipient.address, sessions, queue, notify_when_idle)})`); }
|
|
371
|
-
catch (error) { failed.push(`${label} (${error.message})`); }
|
|
383
|
+
catch (error) { failed.push(error.unanswered ? `${label} (unanswered, whether it arrived is unknown: ${error.message})` : `${label} (${error.message})`); }
|
|
372
384
|
}
|
|
373
385
|
const thread = plan.thread ? ` Thread ${shortId(plan.thread.id)}.` : '';
|
|
374
386
|
let text;
|
|
@@ -517,6 +529,7 @@ export class MailSend {
|
|
|
517
529
|
try { how = await this.route(sent, receiver, door, true, params.notify_when_idle === true); }
|
|
518
530
|
catch (error) {
|
|
519
531
|
if (error instanceof Refused && error.kind === 'too_long') return refused('too_long', `the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
|
|
532
|
+
if (error.unanswered) return { ...refused('unanswered', unansweredText(`the Claude relay`, error.message, sent.id)), message_id: sent.id };
|
|
520
533
|
return refused('delivery_failed', error.message);
|
|
521
534
|
}
|
|
522
535
|
return {
|
|
@@ -686,6 +699,9 @@ export class MailSend {
|
|
|
686
699
|
return { outcome: 'delivered', detail: null };
|
|
687
700
|
} catch (error) {
|
|
688
701
|
// a message its door can never take (too long for a relay) stays filed for the session to read
|
|
702
|
+
// one whose hand-over is unanswered was not found where it would have landed: it stays unread and its wake stays,
|
|
703
|
+
// tried again after its backoff (a wake settled as filed would leave an idle receiver never told)
|
|
704
|
+
if (error.unanswered) return { outcome: 'later', detail: `unanswered: ${error.message}` };
|
|
689
705
|
return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
|
|
690
706
|
} finally {
|
|
691
707
|
const after = this.store.wakeState(address, id);
|
package/mail/store.mjs
CHANGED
|
@@ -397,6 +397,12 @@ export class MailStore extends EventEmitter {
|
|
|
397
397
|
renameSync(path, join(box.dir, 'cur', path.split(/[\\/]/).pop()));
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/** A message filed read moved back to `new/`, unread, under its own name: a hand-over that may not have reached it. */
|
|
401
|
+
markUnread(address, path) {
|
|
402
|
+
const box = this.open(address);
|
|
403
|
+
renameSync(path, join(box.dir, 'new', path.split(/[\\/]/).pop()));
|
|
404
|
+
}
|
|
405
|
+
|
|
400
406
|
/** A wake kept beside the envelope (mailbox.rs `request_wake`): its watcher hands it to the session's door. */
|
|
401
407
|
requestWake(address, id) {
|
|
402
408
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('invalid wake message id');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.99",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|