@volter/supercode-orchestrator 0.5.215 → 0.5.217

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/source-lock.mjs CHANGED
@@ -1,8 +1,9 @@
1
- // The orchestrator and its native codec share the CLI/Teams source lock; no second declaration.
1
+ // @ts-check
2
+ // The orchestrator shares the CLI/Teams source lock; it declares no second binding.
2
3
  import { existsSync, realpathSync } from 'node:fs';
3
4
  import { resolve } from 'node:path';
4
- import { spawnSync } from 'node:child_process';
5
5
  import { enforceSourceLock, sourceRoot, SourceLockError } from '@volter/supercode-teams/source-lock';
6
+ import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
6
7
 
7
8
  const lock = enforceSourceLock(import.meta.url, 'the orchestrator');
8
9
  if (lock) {
@@ -10,30 +11,20 @@ if (lock) {
10
11
  enforceSourceLock(import.meta.resolve('@volter/supercode-teams/source-lock'), 'the orchestrator Teams package');
11
12
  }
12
13
 
13
- let driftSaid = false;
14
-
15
- /** The locked tree's release native, proved against the same lock before any codec door opens. */
16
- export function lockedNative() {
14
+ /** The exact pinned Node front door; no native build receipt is inferred. */
15
+ export function lockedProgram() {
17
16
  const lock = enforceSourceLock(import.meta.url, 'the orchestrator');
18
17
  if (!lock) return null;
18
+ if (lock.binding !== 'node') throw new SourceLockError("supercode source lock: this declaration binds a native build; run the intended tree's Node front door with `source pin` to select its Node binding");
19
19
  const root = sourceRoot(import.meta.url);
20
- const binary = resolve(root, 'target/release', process.platform === 'win32' ? 'supercode.exe' : 'supercode');
21
- if (!existsSync(binary)) throw new SourceLockError(`supercode source lock: the orchestrator needs the pinned tree's release native (${binary}); build it there with cargo build --release -p supercode-cli`);
22
- if (process.env.SUPERCODE_BIN && (!existsSync(process.env.SUPERCODE_BIN) || realpathSync(process.env.SUPERCODE_BIN) !== realpathSync(binary))) {
23
- // A host normally passes its source-linked Node front door as SUPERCODE_BIN.
24
- enforceSourceLock(process.env.SUPERCODE_BIN, 'the orchestrator SUPERCODE_BIN');
25
- if (realpathSync(process.env.SUPERCODE_BIN) !== realpathSync(resolve(root, 'packaging/npm/bin/supercode.js'))) {
26
- throw new SourceLockError(`supercode source lock: SUPERCODE_BIN must name the pinned release native or its front door (${binary})`);
27
- }
28
- }
29
- const result = spawnSync(binary, ['source', 'show', '--json'], { encoding: 'utf8', timeout: 5000 });
30
- if (result.error || result.status !== 0) throw new SourceLockError(`supercode source lock: the orchestrator native ${binary} cannot be verified: ${result.error?.message || result.stderr}`);
31
- const receipt = JSON.parse(result.stdout);
32
- // The native is judged by its binding alone: a retired copy that came back (drift) is said, and never stops the board.
33
- const binding = receipt.binding ?? receipt.verdict;
34
- if (binding !== 'matches' || receipt.native.version !== lock.supercode || receipt.native.commit !== lock.commit || receipt.native.dirty) {
35
- throw new SourceLockError(`supercode source lock: the orchestrator native ${binary} does not match ${lock.path}: ${binding}`);
20
+ const entry = resolve(root, 'packaging/npm/bin/supercode.js');
21
+ if (!existsSync(entry)) throw new SourceLockError(`supercode source lock: the orchestrator needs the pinned tree's Node front door (${entry})`);
22
+ enforceSourceLock(entry, 'the orchestrator supercode front door');
23
+ if (process.env.SUPERCODE_BIN) {
24
+ const selected = supercodeInvocation(process.env.SUPERCODE_BIN, [], process.env);
25
+ if (!selected.node || !existsSync(selected.arguments[0]) || realpathSync(selected.arguments[0]) !== realpathSync(entry))
26
+ throw new SourceLockError(`supercode source lock: SUPERCODE_BIN must name the pinned Node front door (${entry}) or its known Node shim`);
27
+ enforceSourceLock(selected.arguments[0], 'the orchestrator SUPERCODE_BIN');
36
28
  }
37
- if (Array.isArray(receipt.drift) && receipt.drift.length && !driftSaid) { driftSaid = true; process.stderr.write(`supercode source lock: ${receipt.drift.join('; ')}\n`); }
38
- return binary;
29
+ return entry;
39
30
  }
package/webhook.mjs CHANGED
@@ -10,11 +10,14 @@
10
10
  // and `node:http` at call time instead.
11
11
 
12
12
  import { surfaceKeyString, sessionReset } from './ir.mjs';
13
- import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation, reply } from './reducer.mjs';
13
+ import { endBinding, ensureRuntime, expired, leaseId, liveBinding, newObligation, startWorker } from './reducer.mjs';
14
14
  import { budgetRefusal } from './conversations.mjs';
15
15
  import { compactStamp, resolveTarget } from './cron.mjs';
16
16
  import * as events from './events.mjs';
17
17
 
18
+ /** An intentional pre-acknowledgement refusal, not a physical cleanup failure. */
19
+ export class WebhookRefusal extends Error {}
20
+
18
21
  /** The synthetic surface a subscription's turns run on (§4.4). */
19
22
  export function webhookKey(name) {
20
23
  return { platform: 'webhook', kind: 'dm', chat_id: name };
@@ -114,7 +117,10 @@ export function handleWebhook(state, ev, effects) {
114
117
  // a redelivery (GitHub's "Redeliver", a sender's retry) is the same event:
115
118
  // it runs once. The memory is runtime state (§2.8), as Hermes's is: a
116
119
  // restart forgets it.
117
- const seen = ((ensureRuntime(state).webhook_deliveries ??= {})[ev.name] ??= []);
120
+ const runtime = ensureRuntime(state);
121
+ runtime.webhook_deliveries ??= {};
122
+ runtime.webhook_deliveries[ev.name] ??= [];
123
+ const seen = runtime.webhook_deliveries[ev.name];
118
124
  if (seen.includes(ev.delivery_id)) {
119
125
  effects.push({ type: 'log', level: 'info', message: `webhook ${ev.name}: delivery ${ev.delivery_id} already handled, dropped` });
120
126
  return;
@@ -145,6 +151,7 @@ export function handleWebhook(state, ev, effects) {
145
151
 
146
152
  const key = webhookKey(ev.name);
147
153
  const keyString = surfaceKeyString(key);
154
+ if (ev.admission_id) effects.push({ type: 'webhook_input', admission_id: ev.admission_id, profile: profile.name, keyString });
148
155
  let b = liveBinding(profile, keyString);
149
156
  if (b) {
150
157
  const reason = expired(b, sessionReset(profile), now);
@@ -153,7 +160,11 @@ export function handleWebhook(state, ev, effects) {
153
160
  if (!b) {
154
161
  // G3: an agent over its budget starts nothing; the refusal is told, never a pause
155
162
  const refused = budgetRefusal(state, profile, profile.name, now);
156
- if (refused) { effects.push({ type: 'budget_refused', profile: profile.name, agent: profile.name, reason: refused }); return; }
163
+ if (refused) {
164
+ effects.push({ type: 'budget_refused', profile: profile.name, agent: profile.name, reason: refused });
165
+ if (ev.admission_id) effects.push({ type: 'webhook_input', admission_id: ev.admission_id, profile: profile.name, keyString, refusal: refused });
166
+ return;
167
+ }
157
168
  b = {
158
169
  key, agent: profile.name,
159
170
  worker: { harness: profile.worker.harness, session_id: `webhook_${ev.name}_${compactStamp(now)}`, locator: null },
@@ -161,7 +172,6 @@ export function handleWebhook(state, ev, effects) {
161
172
  recurrence: null, residue: { webhook: ev.name },
162
173
  };
163
174
  profile.bindings[keyString] = b;
164
- effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, agent: profile.name });
165
175
  }
166
176
  b.residue.webhook = ev.name;
167
177
  b.last_activity_at = now;
@@ -169,10 +179,14 @@ export function handleWebhook(state, ev, effects) {
169
179
  const text = renderTemplate(sub.prompt_template, ev.payload);
170
180
  const rt = ensureRuntime(state);
171
181
  const lid = leaseId(profile.name, keyString);
172
- if (rt.turns[lid] || !rt.leases[lid]) (rt.queues[lid] ??= []).push({ text, at: now });
182
+ if (!rt.leases[lid] && !rt.starting[lid] && !startWorker(state, profile, keyString, effects, now)) {
183
+ if (ev.admission_id) effects.push({ type: 'webhook_input', admission_id: ev.admission_id, profile: profile.name, keyString, refusal: 'worker start refused; retry after its budget permits' });
184
+ return;
185
+ }
186
+ if (rt.turns[lid] || !rt.leases[lid]) { rt.queues[lid] ??= []; rt.queues[lid].push({ text, at: now, ...(ev.admission_id ? { webhook_input_id: ev.admission_id } : {}) }); }
173
187
  else {
174
- rt.turns[lid] = { started_at: now, buffer: '' };
175
- effects.push({ type: 'send_input', profile: profile.name, keyString, text });
188
+ rt.turns[lid] = { started_at: now, buffer: '', ...(ev.admission_id ? { webhook_input_id: ev.admission_id } : {}) };
189
+ effects.push({ type: 'send_input', profile: profile.name, keyString, text, ...(ev.admission_id ? { webhook_input_id: ev.admission_id } : {}) });
176
190
  }
177
191
  effects.push({ type: 'save' });
178
192
  }
@@ -214,7 +228,10 @@ const MAX_BODY = 1024 * 1024;
214
228
  */
215
229
  export async function startWebhookListener({ state, vault, port = 0, host = '127.0.0.1', dispatch, clock, log = () => {} }) {
216
230
  const { createServer } = await import('node:http');
231
+ const sockets = new Set(), admitted = new Map(), tasks = new Set(), failures = [];
232
+ let closing = false, closePromise;
217
233
  const server = createServer((req, res) => {
234
+ if (closing) { req.destroy(); return; }
218
235
  const match = /^\/webhooks\/([^/?#]+)/.exec(req.url ?? '');
219
236
  if (req.method !== 'POST' || !match) { res.writeHead(404, { 'content-type': 'application/json' }); res.end('{"error":"not found"}'); return; }
220
237
  let name;
@@ -229,10 +246,36 @@ export async function startWebhookListener({ state, vault, port = 0, host = '127
229
246
  chunks.push(c);
230
247
  });
231
248
  req.on('end', () => {
249
+ if (closing) return;
232
250
  const body = Buffer.concat(chunks).toString('utf8');
233
- void handleRequest({ state, vault, name, body, headers: req.headers, dispatch, clock, log, res });
251
+ const socket = req.socket;
252
+ admitted.set(socket, (admitted.get(socket) ?? 0) + 1);
253
+ const task = handleRequest({ state, vault, name, body, headers: req.headers, dispatch, clock, log, res });
254
+ tasks.add(task);
255
+ const finished = () => {
256
+ tasks.delete(task);
257
+ const count = admitted.get(socket) - 1;
258
+ if (count) admitted.set(socket, count); else admitted.delete(socket);
259
+ if (closing) socket.destroy();
260
+ };
261
+ void task.then(finished, (error) => {
262
+ if (!(error instanceof WebhookRefusal)) failures.push(error);
263
+ // No positive acknowledgement may precede failed verification or loop
264
+ // processing. Retain the failure for close as well as telling the sender.
265
+ if (!res.headersSent && !res.destroyed) {
266
+ res.writeHead(503, { 'content-type': 'application/json', 'retry-after': '1' });
267
+ res.end('{"ok":false,"error":"webhook processing failed; retry"}');
268
+ }
269
+ finished();
270
+ log('warn', `webhook ${name}: ${String(error?.message ?? error)}`);
271
+ });
234
272
  });
235
- req.on('error', (error) => log('warn', `webhook ${name}: ${error.message}`));
273
+ req.on('error', (error) => { if (!closing) log('warn', `webhook ${name}: ${error.message}`); });
274
+ });
275
+ server.on('connection', (socket) => {
276
+ sockets.add(socket);
277
+ socket.once('close', () => sockets.delete(socket));
278
+ if (closing) socket.destroy();
236
279
  });
237
280
  await new Promise((resolve, reject) => {
238
281
  server.once('error', reject);
@@ -242,7 +285,17 @@ export async function startWebhookListener({ state, vault, port = 0, host = '127
242
285
  return {
243
286
  server,
244
287
  port: server.address().port,
245
- close: () => new Promise((resolve) => server.close(() => resolve())),
288
+ close: () => closePromise ??= (async () => {
289
+ closing = true;
290
+ const closed = new Promise(resolve => server.close(error => { if (error) failures.push(error); resolve(); }));
291
+ // A partial body is passive ingress, not admitted loop work. It cannot
292
+ // hold later runtime/adapter cleanup behind HTTP's request timeout.
293
+ for (const socket of sockets) if (!admitted.has(socket)) socket.destroy();
294
+ await Promise.allSettled([...tasks]);
295
+ for (const socket of sockets) socket.destroy();
296
+ await closed;
297
+ if (failures.length) throw new AggregateError(failures, failures.map(error => String(error?.message ?? error)).join('; '));
298
+ })(),
246
299
  };
247
300
  }
248
301
 
@@ -259,9 +312,9 @@ async function handleRequest({ state, vault, name, body, headers, dispatch, cloc
259
312
  let payload;
260
313
  try { payload = JSON.parse(body); } catch { payload = { body }; }
261
314
  const at = clock();
262
- res.writeHead(verified ? 202 : 401, { 'content-type': 'application/json' });
263
- res.end(JSON.stringify({ ok: verified, name }));
264
315
  const event = firstHeader(headers, EVENT_HEADERS) ?? eventType(payload);
265
316
  const delivery_id = firstHeader(headers, DELIVERY_HEADERS);
266
317
  await dispatch(events.webhook({ name, payload, verified, at, event, delivery_id }));
318
+ res.writeHead(verified ? 202 : 401, { 'content-type': 'application/json' });
319
+ res.end(JSON.stringify({ ok: verified, name }));
267
320
  }