@volter/supercode-teams 0.3.151 → 0.3.153
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/README.md +14 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +128 -43
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- package/mail/envelope.mjs +8 -10
- package/mail/hooks.mjs +43 -27
- package/mail/idle.mjs +14 -5
- package/mail/import-maildir.mjs +350 -0
- package/mail/questions.mjs +38 -46
- package/mail/reads.mjs +15 -26
- package/mail/relay-endpoint.mjs +14 -22
- package/mail/relay.mjs +100 -65
- package/mail/retire-maildir.mjs +122 -0
- package/mail/send.mjs +70 -83
- package/mail/sqlite-store.mjs +476 -0
- package/mail/sqlite.mjs +353 -0
- package/mail/transcript.mjs +3 -3
- package/mail/upkeep.mjs +46 -26
- package/mail/verbs.mjs +14 -36
- package/native-codex-pane.mjs +6 -6
- package/native-initial-input.mjs +6 -6
- package/native-launch-binding.mjs +35 -0
- package/package.json +2 -2
- package/session-people.mjs +7 -8
- package/terminal-mail.mjs +8 -19
- package/mail/store.mjs +0 -531
package/mail/envelope.mjs
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// reads of it (`render`). Ported line for line: what a session reads does not depend on which process read it out.
|
|
3
3
|
import { blake3 } from '@noble/hashes/blake3.js';
|
|
4
4
|
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
5
|
|
|
8
6
|
/** Command an agent runs to send or reply. */
|
|
9
7
|
export const SEND_COMMAND = 'supercode message send';
|
|
@@ -101,13 +99,13 @@ export const threadId = (envelope) => envelope.thread ?? envelope.id;
|
|
|
101
99
|
* When a native question's reply reached its session (mail_question.rs `delivered_at`): only for a reply to a `q-`
|
|
102
100
|
* question whose recorded reply is this envelope.
|
|
103
101
|
*/
|
|
104
|
-
export function questionDeliveredAt(envelope,
|
|
102
|
+
export function questionDeliveredAt(envelope, store) {
|
|
105
103
|
const id = envelope.in_reply_to;
|
|
106
104
|
if (typeof id !== 'string' || !id.startsWith('q-')) return null;
|
|
107
105
|
try {
|
|
108
|
-
const reply =
|
|
106
|
+
const reply = store.record('native_questions', `${id}.reply`);
|
|
109
107
|
if (reply?.envelope?.id !== envelope.id) return null;
|
|
110
|
-
const delivered =
|
|
108
|
+
const delivered = store.record('native_questions', `${id}.delivered`);
|
|
111
109
|
return Number.isFinite(delivered) ? delivered : null;
|
|
112
110
|
} catch { return null; }
|
|
113
111
|
}
|
|
@@ -128,9 +126,9 @@ export function describe(delivery) {
|
|
|
128
126
|
const printable = (name) => [...String(name)].filter((character) => /^[A-Za-z0-9\-_.@ ]$/.test(character)).slice(0, 80).join('');
|
|
129
127
|
|
|
130
128
|
/** A message as an inbox lists it (message.rs `listed`). */
|
|
131
|
-
export function listed(envelope, deliveries,
|
|
129
|
+
export function listed(envelope, deliveries, store) {
|
|
132
130
|
const sent = rfc3339(envelope.created_at_ms);
|
|
133
|
-
const native = questionDeliveredAt(envelope,
|
|
131
|
+
const native = questionDeliveredAt(envelope, store);
|
|
134
132
|
const delivered = describe(native !== null ? { state: 'delivered', at_ms: native } : deliveries.get(envelope.id) ?? null);
|
|
135
133
|
if (envelope.kind === 'answer') return `${render(envelope)}\n(sent ${sent})`;
|
|
136
134
|
if (envelope.kind !== 'user') return `${render(envelope)}\n(sent ${sent}; delivered to the model: ${delivered})`;
|
|
@@ -143,13 +141,13 @@ export function listed(envelope, deliveries, mailRoot) {
|
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
/** A message as `show` and `inbox --json` give it (message.rs `message_row`). */
|
|
146
|
-
export function messageRow(address, stored, deliveries,
|
|
147
|
-
const native = questionDeliveredAt(stored.envelope,
|
|
144
|
+
export function messageRow(address, stored, deliveries, store) {
|
|
145
|
+
const native = questionDeliveredAt(stored.envelope, store);
|
|
148
146
|
return {
|
|
149
147
|
session: address,
|
|
150
148
|
state: stored.state,
|
|
151
149
|
delivery: native !== null ? { state: 'delivered', at_ms: native } : deliveries.get(stored.envelope.id) ?? null,
|
|
152
150
|
envelope: stored.envelope,
|
|
153
|
-
rendered: listed(stored.envelope, deliveries,
|
|
151
|
+
rendered: listed(stored.envelope, deliveries, store),
|
|
154
152
|
};
|
|
155
153
|
}
|
package/mail/hooks.mjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// that send went, `relay-inbound` files mail a relay's Claude was handed in the mailbox of the session it speaks for.
|
|
8
8
|
// - `message native-question-hook` (mail_question.rs `hook`): a native session's creator recorded at its start, and its
|
|
9
9
|
// AskUserQuestion asked as mail and answered by the reply.
|
|
10
|
-
import {
|
|
11
|
-
import { join, resolve, sep } from 'node:path';
|
|
10
|
+
import { readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
12
12
|
import { envelope as makeEnvelope, makeAddress, newMessageId, parseAddress } from './compose.mjs';
|
|
13
13
|
import { blake3Hex } from './envelope.mjs';
|
|
14
14
|
import { typedLineId } from './transcript.mjs';
|
|
@@ -34,13 +34,14 @@ export class MailHooks {
|
|
|
34
34
|
/** `handOver(address, id)` a filed message to its session's door; `threadOf(id)` the thread a reply to `id` joins. */
|
|
35
35
|
constructor({ machine, mailRoot, store, live, handOver, threadOf, stateDir = null, log = () => {} }) {
|
|
36
36
|
Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
|
|
37
|
+
this.stopped = false;
|
|
38
|
+
this.waiting = new Set();
|
|
37
39
|
// Windows: each Codex session's last hook (the process it ran under, the rollout it named, when), which serve's
|
|
38
40
|
// Codex listing reads through this daemon (codex_peer.rs `hook_recorded_rollouts`); kept beside this daemon's state,
|
|
39
41
|
// never in the mail folder (mail step 1c)
|
|
40
42
|
this.codexHooksFile = stateDir ? join(stateDir, 'codex-hooks.json') : null;
|
|
41
43
|
this.codexHooks = new Map(Object.entries((this.codexHooksFile && readJson(this.codexHooksFile)) || {}));
|
|
42
|
-
this.
|
|
43
|
-
this.questions = join(mailRoot, 'native-questions');
|
|
44
|
+
this.relaysRoots = [resolve(mailRoot, 'relays'), resolve(dirname(mailRoot), 'relays')];
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// ---- `message hook <harness>` -------------------------------------------------------------------------------------
|
|
@@ -50,7 +51,7 @@ export class MailHooks {
|
|
|
50
51
|
const session = typeof input?.session_id === 'string' ? input.session_id : null;
|
|
51
52
|
const address = session ? makeAddress(this.machine, harness, session) : null;
|
|
52
53
|
if (!address) return null;
|
|
53
|
-
|
|
54
|
+
this.store.open(address);
|
|
54
55
|
// Windows: where the listing cannot see a Codex's open rollout, the session's own hook says it runs (the process
|
|
55
56
|
// that ran it, and the rollout it names), until the live map knows Codex there (step 1c)
|
|
56
57
|
if (process.platform === 'win32' && harness === 'codex' && Number.isInteger(claim?.ppid)) {
|
|
@@ -64,9 +65,8 @@ export class MailHooks {
|
|
|
64
65
|
return null;
|
|
65
66
|
}
|
|
66
67
|
if (event !== 'PostToolUse' && !(event === 'Stop' && input.stop_hook_active !== true)) return null;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const unread = this.store.inventory(address).stored.filter((item) => item.state === 'unread' && !pending.includes(item.envelope.id));
|
|
68
|
+
const pending = new Set(this.store.wakes(address));
|
|
69
|
+
const unread = this.store.unreadMessages(address).filter((item) => !pending.has(item.envelope.id));
|
|
70
70
|
if (!unread.length) return null;
|
|
71
71
|
const pointer = this.#pointer(unread);
|
|
72
72
|
return event === 'PostToolUse'
|
|
@@ -112,42 +112,45 @@ export class MailHooks {
|
|
|
112
112
|
|
|
113
113
|
// ---- a relay's hooks ----------------------------------------------------------------------------------------------
|
|
114
114
|
|
|
115
|
-
/** A relay's
|
|
115
|
+
/** A relay's runtime directory identifies its row. Older hooks can keep their
|
|
116
|
+
* Maildir-era identifier; neither spelling opens that directory for mail. */
|
|
116
117
|
#relay(directory) {
|
|
117
118
|
const dir = resolve(String(directory ?? ''));
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
const root = this.relaysRoots.find((root) => dir.startsWith(root + sep) && !dir.slice(root.length + 1).includes(sep));
|
|
120
|
+
if (!root) throw Object.assign(new Error(`${dir} is not a relay's folder`), { code: 400 });
|
|
121
|
+
const key = dir.slice(root.length + 1);
|
|
122
|
+
return { dir, queue: `${key}/queue.json`, receipt: `${key}/receipt.json`, turn: `${key}/turn.txt`, sent: `${key}/sent.json` };
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/** `message gate`: only a SendMessage equal to the queued one passes (ListAgents always); anything else is denied, and a
|
|
123
126
|
* queued send refused still answers its sender (claude_relay.rs `gate_decision`). */
|
|
124
127
|
gate(directory, input) {
|
|
125
128
|
const paths = this.#relay(directory);
|
|
126
|
-
const queued =
|
|
129
|
+
const queued = this.store.record('relays', paths.queue);
|
|
127
130
|
const reason = gateDecision(input ?? {}, queued);
|
|
128
131
|
if (reason == null) return null;
|
|
129
|
-
if (queued
|
|
132
|
+
if (queued) { try { this.store.putRecord('relays', paths.receipt, { denied: reason }, { create: true }); } catch { /* best kept */ } }
|
|
130
133
|
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason } };
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
/** `message relay-receipt`: Claude's own answer to the queued send, its error, or that the turn ended without one. */
|
|
134
137
|
receipt(directory, input) {
|
|
135
138
|
const paths = this.#relay(directory);
|
|
136
|
-
if (!
|
|
139
|
+
if (!this.store.record('relays', paths.queue) || this.store.record('relays', paths.receipt)) return null;
|
|
137
140
|
const event = input?.hook_event_name;
|
|
138
141
|
let receipt;
|
|
139
142
|
if (event === 'PostToolUse') receipt = { tool_response: input.tool_response ?? null };
|
|
140
143
|
else if (event === 'PostToolUseFailure') receipt = { send_failed: typeof input.error === 'string' ? input.error : input.error == null ? '' : JSON.stringify(input.error) };
|
|
141
144
|
else if (event === 'Stop' || event === 'StopFailure') {
|
|
142
145
|
// a turn that began before this send was queued (the last send's, still ending) is not its answer
|
|
143
|
-
const queued =
|
|
144
|
-
|
|
146
|
+
const queued = this.store.record('relays', paths.queue);
|
|
147
|
+
const prompt = this.store.record('relays', paths.turn);
|
|
145
148
|
if (queued && prompt != null && !prompt.includes(sendTurn(queued))) return null;
|
|
146
149
|
const text = (key) => (typeof input[key] === 'string' ? input[key] : null);
|
|
147
150
|
const said = event === 'StopFailure' ? `${text('error_details') ?? text('last_assistant_message') ?? ''} (${text('error') ?? 'error'})` : text('last_assistant_message');
|
|
148
151
|
receipt = { turn_ended: true, said: said == null ? null : Array.from(said).slice(0, 300).join('') };
|
|
149
152
|
} else return null;
|
|
150
|
-
|
|
153
|
+
this.store.putRecord('relays', paths.receipt, receipt, { create: true });
|
|
151
154
|
return null;
|
|
152
155
|
}
|
|
153
156
|
|
|
@@ -158,7 +161,7 @@ export class MailHooks {
|
|
|
158
161
|
const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
|
|
159
162
|
const trimmed = prompt.trimStart();
|
|
160
163
|
if (!(trimmed.startsWith(NATIVE_PEER_OPENING) || trimmed.startsWith(NATIVE_IDLE_NOTICE) || trimmed.startsWith(NATIVE_DELIVERY_NOTICE))) {
|
|
161
|
-
try {
|
|
164
|
+
try { this.store.putRecord('relays', paths.turn, prompt); } catch { /* best kept */ }
|
|
162
165
|
return null;
|
|
163
166
|
}
|
|
164
167
|
if (!parseAddress(represented)) throw Object.assign(new Error(`not an address: ${represented}`), { code: 400 });
|
|
@@ -169,7 +172,7 @@ export class MailHooks {
|
|
|
169
172
|
// the replying Claude session runs beside the relay, on this machine
|
|
170
173
|
const sender = this.live.sessions().find((row) => row.door === 'native' && row.socket && row.socket === event.from.replace(/^uds:/, ''));
|
|
171
174
|
const sessionId = sender ? sender.address.split(':').slice(3).join(':') : null;
|
|
172
|
-
const lastSent =
|
|
175
|
+
const lastSent = this.store.record('relays', paths.sent) ?? {};
|
|
173
176
|
const inReplyTo = sessionId ? lastSent[sessionId] ?? null : null;
|
|
174
177
|
this.#fileHome(represented, makeEnvelope({ id, from: makeAddress(this.machine, 'claude-code', sessionId ?? 'unknown'),
|
|
175
178
|
from_name: sender ? sender.name : event.fromName ?? 'an unknown Claude session', kind: 'peer', reply_via: { mode: 'command' },
|
|
@@ -225,14 +228,15 @@ export class MailHooks {
|
|
|
225
228
|
const seedFile = resolve(String(seedPath ?? ''));
|
|
226
229
|
const launches = resolve(this.mailRoot, 'native-launches');
|
|
227
230
|
if (!seedFile.startsWith(launches + sep)) throw Object.assign(new Error(`${seedFile} is not a native launch's binding`), { code: 400 });
|
|
228
|
-
const
|
|
231
|
+
const seedKey = seedFile.slice(launches.length + 1);
|
|
232
|
+
const seed = this.store.record('native_launches', seedKey);
|
|
229
233
|
if (!seed || (seed.kind !== 'codex' && seed.kind !== 'claude-code')) throw new Error('unsupported native question harness');
|
|
230
234
|
const binding = questions.bind(seed, { session_id: input?.session_id, transcript_path: typeof input?.transcript_path === 'string' ? input.transcript_path : null }, seed.kind);
|
|
231
235
|
if (input?.hook_event_name === 'SessionStart' && binding.transcript_path) {
|
|
232
236
|
// this launch's baseline precedes its native initial prompt, including on resume
|
|
233
|
-
const receipt = `${
|
|
237
|
+
const receipt = `${seedKey.replace(/\.json$/, '')}.session.json`;
|
|
234
238
|
let offset = 0; try { offset = statSync(binding.transcript_path).size; } catch { /* not there yet */ }
|
|
235
|
-
|
|
239
|
+
this.store.putRecord('native_launches', receipt, { path: binding.transcript_path, offset }, { create: true });
|
|
236
240
|
}
|
|
237
241
|
if (input?.tool_name !== 'AskUserQuestion') return {};
|
|
238
242
|
const tool = input.tool_input ?? {};
|
|
@@ -242,23 +246,35 @@ export class MailHooks {
|
|
|
242
246
|
const id = questions.idOf(binding.session, native);
|
|
243
247
|
const answersOf = (reply) => Object.fromEntries(Object.entries(reply?.answers ?? {}).map(([key, value]) => [key, value?.answers?.[0] ?? null]));
|
|
244
248
|
if (input.hook_event_name === 'PostToolUse') {
|
|
245
|
-
const reply =
|
|
249
|
+
const reply = this.store.record('native_questions', `${id}.reply`);
|
|
246
250
|
if (reply && JSON.stringify(input.tool_response?.answers ?? null) === JSON.stringify(answersOf(reply))) questions.confirm(id, reply);
|
|
247
251
|
return {};
|
|
248
252
|
}
|
|
249
253
|
if (input.hook_event_name !== 'PreToolUse') return {};
|
|
254
|
+
if (this.store.record('native_questions', `${id}.reply`)?.body_expired_at_ms != null) {
|
|
255
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
256
|
+
permissionDecisionReason: 'This native question was already answered; its answer text expired under mail retention. Start a new question instead of reusing its tool id.' } };
|
|
257
|
+
}
|
|
250
258
|
// one hook owns this wait, marked by its process: a hook its harness killed ends the wait, and never answers
|
|
251
|
-
const waiting =
|
|
252
|
-
|
|
259
|
+
const waiting = `${id}.waiting`;
|
|
260
|
+
if (!this.store.putRecord('native_questions', waiting, { pid: claim?.pid ?? null }, { create: true })) throw Object.assign(new Error(`native question already has a waiting hook: ${id}`), { code: 'EEXIST' });
|
|
261
|
+
this.waiting.add(waiting);
|
|
253
262
|
try {
|
|
254
263
|
questions.file(binding, native);
|
|
255
264
|
for (;;) {
|
|
256
|
-
|
|
265
|
+
if (this.stopped) throw Object.assign(new Error('the daemon stopped while waiting for a native question reply'), { code: 503 });
|
|
266
|
+
const reply = this.store.record('native_questions', `${id}.reply`);
|
|
257
267
|
if (reply) return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { ...tool, answers: answersOf(reply) } } };
|
|
258
268
|
if (claim?.pid && !alive(Number(claim.pid))) return {};
|
|
259
269
|
await new Promise((done) => setTimeout(done, 250));
|
|
260
270
|
}
|
|
261
|
-
} finally {
|
|
271
|
+
} finally { if (!this.stopped) this.store.removeRecord('native_questions', waiting); this.waiting.delete(waiting); }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
stop() {
|
|
275
|
+
this.stopped = true;
|
|
276
|
+
for (const key of this.waiting) this.store.removeRecord('native_questions', key);
|
|
277
|
+
this.waiting.clear();
|
|
262
278
|
}
|
|
263
279
|
}
|
|
264
280
|
|
package/mail/idle.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export class IdleNotices {
|
|
|
28
28
|
*/
|
|
29
29
|
constructor({ machine, store, live, serveRequest, locatorOf, log = () => {} }) {
|
|
30
30
|
Object.assign(this, { machine, store, live, serveRequest, locatorOf, log });
|
|
31
|
+
this.stopped = false;
|
|
31
32
|
this.targets = new Set(); // addresses here with a subscription on them
|
|
32
33
|
this.activity = new Map(); // address → { turn, presence } as serve last reported it
|
|
33
34
|
this.subscription = null; this.followed = '';
|
|
@@ -36,14 +37,15 @@ export class IdleNotices {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
start() {
|
|
39
|
-
this.
|
|
40
|
-
|
|
40
|
+
this.onSubscriptions = (address) => this.#track(address);
|
|
41
|
+
this.store.on('subscriptions', this.onSubscriptions);
|
|
42
|
+
for (const address of this.store.subscribed()) this.#track(address);
|
|
41
43
|
this.sweeper = setInterval(() => { for (const address of this.targets) void this.#settle(address); }, SWEEP_MS);
|
|
42
44
|
this.sweeper.unref?.();
|
|
43
45
|
return this;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
stop() { clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
|
|
48
|
+
stop() { this.stopped = true; this.store.off('subscriptions', this.onSubscriptions); clearInterval(this.sweeper); clearInterval(this.runtimeTimer); this.#unfollow(); }
|
|
47
49
|
|
|
48
50
|
/** Serve's activity notification for this subscription: each changed session settled. */
|
|
49
51
|
notified(params) {
|
|
@@ -56,6 +58,7 @@ export class IdleNotices {
|
|
|
56
58
|
serveExited() { this.subscription = null; this.followed = ''; this.#follow(); }
|
|
57
59
|
|
|
58
60
|
#track(address) {
|
|
61
|
+
if (this.stopped) return;
|
|
59
62
|
const parsed = parseAddress(address);
|
|
60
63
|
if (!parsed || parsed.machine !== this.machine) return;
|
|
61
64
|
const has = this.store.subscriptions(address).length > 0;
|
|
@@ -66,6 +69,7 @@ export class IdleNotices {
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
#seen(activity) {
|
|
72
|
+
if (this.stopped) return;
|
|
69
73
|
const address = `sc:${this.machine}:${activity.harness}:${activity.session_id}`;
|
|
70
74
|
if (!this.targets.has(address)) return;
|
|
71
75
|
this.activity.set(address, { turn: activity.turn, presence: activity.presence });
|
|
@@ -76,6 +80,7 @@ export class IdleNotices {
|
|
|
76
80
|
|
|
77
81
|
/** The subscribed sessions' activity followed (one subscription for all of them, made again when the set changes). */
|
|
78
82
|
async #follow() {
|
|
83
|
+
if (this.stopped) return;
|
|
79
84
|
const locators = [];
|
|
80
85
|
for (const address of this.targets) {
|
|
81
86
|
if (this.#runtime(address)) continue; // a hosted runtime is asked itself
|
|
@@ -90,7 +95,7 @@ export class IdleNotices {
|
|
|
90
95
|
if (locators.length) {
|
|
91
96
|
try {
|
|
92
97
|
const answer = await this.serveRequest('harness.v1.sessions.activity.subscribe', { locators: locators.slice(0, MAX_FOLLOWED) }, 30_000);
|
|
93
|
-
if (this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
|
|
98
|
+
if (this.stopped || this.followed !== fingerprint) { if (answer?.subscription) void this.serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: answer.subscription }, 30_000).catch(() => {}); return; }
|
|
94
99
|
this.subscription = answer?.subscription ?? null;
|
|
95
100
|
if (locators.length > MAX_FOLLOWED) this.log(`idle notices: ${locators.length - MAX_FOLLOWED} subscribed sessions are not followed (at most ${MAX_FOLLOWED}); they settle on expiry`);
|
|
96
101
|
for (const activity of answer?.initial ?? []) this.#seen(activity);
|
|
@@ -110,6 +115,7 @@ export class IdleNotices {
|
|
|
110
115
|
|
|
111
116
|
/** The hosted runtimes with a final reply waiting, asked for their turn every RUNTIME_POLL_MS while one waits. */
|
|
112
117
|
#pollRuntimes() {
|
|
118
|
+
if (this.stopped) return;
|
|
113
119
|
const waiting = [...this.targets].filter((address) => this.#runtime(address) && this.store.subscriptions(address).some((item) => item.final_reply));
|
|
114
120
|
this.polling = new Set(waiting);
|
|
115
121
|
if (!waiting.length) { clearInterval(this.runtimeTimer); this.runtimeTimer = null; return; }
|
|
@@ -130,7 +136,7 @@ export class IdleNotices {
|
|
|
130
136
|
|
|
131
137
|
/** One pass over one receiver's subscriptions (mail_watch.rs `tick`, for one mailbox). */
|
|
132
138
|
async #settle(address) {
|
|
133
|
-
if (this.settling.has(address)) return;
|
|
139
|
+
if (this.stopped || this.settling.has(address)) return;
|
|
134
140
|
this.settling.add(address);
|
|
135
141
|
try {
|
|
136
142
|
const subscriptions = this.store.subscriptions(address);
|
|
@@ -151,6 +157,7 @@ export class IdleNotices {
|
|
|
151
157
|
const seen = this.activity.get(address);
|
|
152
158
|
if (seen) { working = seen.turn === 'working'; idle = seen.turn === 'idle'; ended = seen.presence === 'persisted'; }
|
|
153
159
|
}
|
|
160
|
+
if (this.stopped) return;
|
|
154
161
|
// one turn settles every message a sender subscribed with; the sender hears it once, about its latest message,
|
|
155
162
|
// and its earlier subscriptions settle with it silently
|
|
156
163
|
subscriptions.sort((a, b) => (b.created_at_ms ?? 0) - (a.created_at_ms ?? 0));
|
|
@@ -162,6 +169,7 @@ export class IdleNotices {
|
|
|
162
169
|
// however fast the turn was
|
|
163
170
|
if (subscription.final_reply && !ended && !expired) {
|
|
164
171
|
const answer = receipt && idle ? await this.#answerTo(address, receipt, subscription.message_id) : null;
|
|
172
|
+
if (this.stopped) return;
|
|
165
173
|
if (answer && this.store.removeSubscription(address, subscription.message_id)) {
|
|
166
174
|
this.#reply(address, subscription, answer);
|
|
167
175
|
if (subscription.notice !== false) this.#notice(address, subscription, 'idle');
|
|
@@ -237,6 +245,7 @@ export class IdleNotices {
|
|
|
237
245
|
|
|
238
246
|
/** Filed in the subscriber's mailbox with a wake: the carrier delivers it here, or carries it to its machine. */
|
|
239
247
|
#file(subscriber, envelope) {
|
|
248
|
+
if (this.stopped) return;
|
|
240
249
|
try { this.store.deliver(subscriber, envelope); this.store.requestWake(subscriber, envelope.id); }
|
|
241
250
|
catch (error) { this.log(`idle notices: ${envelope.id} for ${subscriber} was not filed: ${error.message}`); }
|
|
242
251
|
}
|