@volter/supercode-teams 0.3.98 → 0.3.100
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 +32 -3
- package/mail/relay.mjs +92 -17
- package/mail/send.mjs +43 -7
- 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
|
}
|
|
@@ -3391,7 +3419,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
3391
3419
|
case 'harness.v1.mail.handover': {
|
|
3392
3420
|
if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is handed over here by this machine\'s own processes'), { code: 403 });
|
|
3393
3421
|
if (typeof params.address !== 'string' || typeof params.id !== 'string' || !parseAddress(params.address)) throw Object.assign(new Error('a hand-over names an address and a message id'), { code: 400 });
|
|
3394
|
-
|
|
3422
|
+
// a hand-over is moved to cur/ once delivered whatever `mark_read` says (MailSend.handOver)
|
|
3423
|
+
return this.mailSend.handOver(params.address, params.id, { wake: params.wake === true });
|
|
3395
3424
|
}
|
|
3396
3425
|
// harness serve's `sessions.message` (its mail to a session or an agent, not its `as_user` turn), asked here by
|
|
3397
3426
|
// harness serve itself: one implementation of a send, whatever the entry.
|
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
|
@@ -52,6 +52,7 @@ export class MailSend {
|
|
|
52
52
|
*/
|
|
53
53
|
constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
|
|
54
54
|
Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
|
|
55
|
+
this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
// ---- one send ----------------------------------------------------------------------------------------------------
|
|
@@ -254,6 +255,8 @@ export class MailSend {
|
|
|
254
255
|
catch (error) {
|
|
255
256
|
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
257
|
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.`);
|
|
258
|
+
// may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
|
|
259
|
+
if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
|
|
257
260
|
return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
|
|
258
261
|
}
|
|
259
262
|
const [code, what] = {
|
|
@@ -321,7 +324,17 @@ export class MailSend {
|
|
|
321
324
|
const filedHere = this.store.findIn(to, handed.id) == null;
|
|
322
325
|
const filed = this.store.deliverRead(to, handed);
|
|
323
326
|
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
|
-
|
|
327
|
+
// a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
|
|
328
|
+
// relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
|
|
329
|
+
// mailbox meanwhile (its recipient claim) has reached it. Otherwise the relay can no longer make it (its queue is
|
|
330
|
+
// gone, so its gate refuses a late send) and it is not in the receiver's transcript: a copy this delivery filed
|
|
331
|
+
// goes back unread, for the receiver's own mailbox read and the mailbox's wake, which hands it over again
|
|
332
|
+
const received = this.store.claimOf(to, handed.id)?.recipient === true;
|
|
333
|
+
if (!relayed.delivered && relayed.unknown && !received) {
|
|
334
|
+
if (filedHere) { try { this.store.markUnread(to, filed); if (wake) this.store.requestWake(to, handed.id); } catch { /* kept read */ } }
|
|
335
|
+
throw Object.assign(new Error(relayed.detail), { unanswered: true });
|
|
336
|
+
}
|
|
337
|
+
if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
|
|
325
338
|
delivered = busy ? 'native_busy' : 'native_idle';
|
|
326
339
|
} else if (door.kind === 'hook') {
|
|
327
340
|
this.store.deliver(to, sent);
|
|
@@ -368,7 +381,7 @@ export class MailSend {
|
|
|
368
381
|
continue;
|
|
369
382
|
}
|
|
370
383
|
try { reached.push(`${label} (${await this.#deliverWoken(sent, recipient.address, sessions, queue, notify_when_idle)})`); }
|
|
371
|
-
catch (error) { failed.push(`${label} (${error.message})`); }
|
|
384
|
+
catch (error) { failed.push(error.unanswered ? `${label} (unanswered, whether it arrived is unknown: ${error.message})` : `${label} (${error.message})`); }
|
|
372
385
|
}
|
|
373
386
|
const thread = plan.thread ? ` Thread ${shortId(plan.thread.id)}.` : '';
|
|
374
387
|
let text;
|
|
@@ -517,6 +530,7 @@ export class MailSend {
|
|
|
517
530
|
try { how = await this.route(sent, receiver, door, true, params.notify_when_idle === true); }
|
|
518
531
|
catch (error) {
|
|
519
532
|
if (error instanceof Refused && error.kind === 'too_long') return refused('too_long', `the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
|
|
533
|
+
if (error.unanswered) return { ...refused('unanswered', unansweredText(`the Claude relay`, error.message, sent.id)), message_id: sent.id };
|
|
520
534
|
return refused('delivery_failed', error.message);
|
|
521
535
|
}
|
|
522
536
|
return {
|
|
@@ -652,16 +666,27 @@ export class MailSend {
|
|
|
652
666
|
/**
|
|
653
667
|
* A message filed unread in `address`'s mailbox handed to its session's door, once (mail_watch.rs `carry` and message.rs
|
|
654
668
|
* `push`, and the native watch's notices): only a door that hands it to the session (a relay, a runtime) takes it, a
|
|
655
|
-
* hook's is woken when `wake`, and an agent's mail is planned
|
|
656
|
-
*
|
|
669
|
+
* hook's is woken when `wake`, and an agent's mail is planned. One handed over is moved to `cur/` here, whoever asked:
|
|
670
|
+
* its receiver has it, so its next inbox read never shows it again (the carrier marks it read only when its own call
|
|
671
|
+
* answers delivered, which a hand-over past its bound never does). The
|
|
672
|
+
* hand-over is recorded on its wake before it starts, so one interrupted mid-way (its daemon ended) is never handed over
|
|
673
|
+
* again; one still running here, its carrier past its bound, answers 'later' until it settles. Answers
|
|
657
674
|
* `{ outcome: 'delivered' | 'filed' | 'later', detail }`.
|
|
658
675
|
*/
|
|
659
|
-
async handOver(address, id, {
|
|
676
|
+
async handOver(address, id, { wake = false } = {}) {
|
|
660
677
|
const found = this.store.findIn(address, id);
|
|
661
678
|
if (!found) return { outcome: 'filed', detail: 'not in its mailbox' };
|
|
662
679
|
const sent = readJson(found.path);
|
|
663
680
|
if (!sent) return { outcome: 'later', detail: 'its envelope could not be read' };
|
|
664
681
|
if (found.state !== 'unread') return { outcome: 'filed', detail: 'already read' };
|
|
682
|
+
// one already handed to its receiver's door (that delivery's claim, the door's or the carrier's: a daemon that ended
|
|
683
|
+
// between the delivery and moving the copy left it unread) is moved to `cur/`, never handed over again. A reader's
|
|
684
|
+
// claim is not one: a reader that ended before reading put the copy back unread, and it is handed over
|
|
685
|
+
const claim = this.store.claimOf(address, id);
|
|
686
|
+
if (claim?.recipient === true && (claim.via === 'door' || claim.via === 'carrier')) {
|
|
687
|
+
this.store.markRead(address, found.path);
|
|
688
|
+
return { outcome: 'filed', detail: 'already handed over' };
|
|
689
|
+
}
|
|
665
690
|
const parsed = parseAddress(address);
|
|
666
691
|
if (parsed?.harness === AGENT_HARNESS) {
|
|
667
692
|
const plan = await this.#plan(sent, address, {});
|
|
@@ -678,16 +703,27 @@ export class MailSend {
|
|
|
678
703
|
return { outcome: 'filed', detail: `its door is ${door.kind}` };
|
|
679
704
|
}
|
|
680
705
|
const state = this.store.wakeState(address, id);
|
|
706
|
+
// one still running here (its carrier gave up on its answer, past its bound) is waited for: the wake stays and is
|
|
707
|
+
// tried again after its backoff, settled by that hand-over's own outcome
|
|
708
|
+
const key = `${address}\n${id}`;
|
|
709
|
+
if (this.handing.has(key)) return { outcome: 'later', detail: 'its hand-over is still in progress' };
|
|
681
710
|
if (state.handover_at_ms != null) return { outcome: 'filed', detail: 'an earlier hand-over never settled' };
|
|
682
711
|
this.store.setWakeState(address, id, { ...state, handover_at_ms: Date.now() });
|
|
712
|
+
this.handing.add(key);
|
|
683
713
|
try {
|
|
684
|
-
await this.route(sent, address, door, true, false);
|
|
685
|
-
|
|
714
|
+
const how = await this.route(sent, address, door, true, false);
|
|
715
|
+
// handed to the session (or already its): moved to cur/ (one only queued for a runtime not woken would stay unread)
|
|
716
|
+
const now = this.store.findIn(address, id);
|
|
717
|
+
if (how !== 'queued' && now?.state === 'unread') this.store.markRead(address, now.path);
|
|
686
718
|
return { outcome: 'delivered', detail: null };
|
|
687
719
|
} catch (error) {
|
|
688
720
|
// a message its door can never take (too long for a relay) stays filed for the session to read
|
|
721
|
+
// one whose hand-over is unanswered was not found where it would have landed: it stays unread and its wake stays,
|
|
722
|
+
// tried again after its backoff (a wake settled as filed would leave an idle receiver never told)
|
|
723
|
+
if (error.unanswered) return { outcome: 'later', detail: `unanswered: ${error.message}` };
|
|
689
724
|
return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
|
|
690
725
|
} finally {
|
|
726
|
+
this.handing.delete(key);
|
|
691
727
|
const after = this.store.wakeState(address, id);
|
|
692
728
|
this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
|
|
693
729
|
}
|
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.100",
|
|
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",
|