@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/loop.mjs CHANGED
@@ -10,14 +10,14 @@ import { parseSessionWire, stringifySessionWire } from '@volter/supercode-teams/
10
10
  import { step, leaseId } from './reducer.mjs';
11
11
  import * as events from './events.mjs';
12
12
  import { read, save, retainWorld, releaseWorld, refreshVault } from './orchestration.mjs';
13
- import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, workerCwd, declareWorkerFolder, declareWorkerHome, approvalPolicy, sandboxArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
13
+ import { INSTRUCTION_ARGUMENTS, NAME_ARGUMENTS, activationEnv, activationCwd, workerCwd, declareWorkerFolder, declareWorkerHome, approvalPolicy, approvalsReviewer, mainApprovalArguments, modeArguments, limitArguments, materializePersona, materializeSkills, modelArguments, turnCounter, userHomeArguments, userHomePersona } from './activation.mjs';
14
14
  import { workerRoute, codexPaneRoute } from './worker-route.mjs';
15
15
  import { projectCodexPaneResume } from './codex-pane-resume.mjs';
16
16
  import { hermesAccess, hermesApproval, untrustedServerVerdict, hermesToolEnv, memorySnapshot, mirrorSession, mirrorState, recordIncident, sealMirror } from './hermes-cli.mjs';
17
17
  import { hermesSessionKey, homeChannel } from './ir.mjs';
18
18
  import { surfaceKeyString } from './ir.mjs';
19
19
  import { BREAKER_FAILURES, BREAKER_MINUTES, MAX_ATTEMPTS, historyRetention, jobGate } from './cron.mjs';
20
- import { startWebhookListener } from './webhook.mjs';
20
+ import { startWebhookListener, WebhookRefusal, findSubscription } from './webhook.mjs';
21
21
  import { startBridge } from './mcp/bridge.mjs';
22
22
  import { socketPathFor } from './mcp/server.mjs';
23
23
  import { mountOrchestratorMcp } from './mcp/mount.mjs';
@@ -29,7 +29,9 @@ import { agentScope,localProfileAgent,localAgentRecord,localAgentSessions,localF
29
29
  import { materializeAgentHome } from './agent-home.mjs';
30
30
  import { knowLocalMachine } from './board/dispatch.mjs';
31
31
  import { execFile } from 'node:child_process';
32
+ import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
32
33
  import { promisify } from 'node:util';
34
+ import { randomUUID } from 'node:crypto';
33
35
 
34
36
  /**
35
37
  * Where a worker keeps its transcripts inside its config home (`<profile>/<harness>`), as discovery and
@@ -80,6 +82,9 @@ export class Orchestrator {
80
82
  this.log = log;
81
83
  this.runtimes = new Map(); // leaseId -> ManagedRuntime
82
84
  this.queue = Promise.resolve();
85
+ this.internalDispatches = new Set();
86
+ this.internalFailures = [];
87
+ this.webhookAdmissions = new Map();
83
88
  this.effectLog = [];
84
89
  this.support = null; // memoized `supportReport()`: the registry does not move under a run
85
90
  }
@@ -90,7 +95,9 @@ export class Orchestrator {
90
95
  for (const [platform, adapter] of Object.entries(this.adapters)) await this.connectAdapter(platform, adapter);
91
96
  this.webhooks = await startWebhookListener({
92
97
  state: this.state, vault: this.vault, port: webhookPort(this.state),
93
- dispatch: (event) => this.dispatchLogged(event), clock: () => this.clock(), log: this.log,
98
+ // The listener owns admission, including complete bodies still verifying
99
+ // when stop begins. Join their real processing result before HTTP success.
100
+ dispatch: (event) => this.dispatchWebhook(event), clock: () => this.clock(), log: this.log,
94
101
  });
95
102
  this.bridge = await startBridge({ orch: this, socketPath: socketPathFor(this.state.root), log: this.log });
96
103
  // the queue already logs a failed event; a voided dispatch must not also
@@ -106,27 +113,34 @@ export class Orchestrator {
106
113
  * daemon's own doors keep serving, and the connect is tried again until it holds or the daemon stops.
107
114
  */
108
115
  async connectAdapter(platform, adapter) {
109
- let status;
116
+ if (this.stopping) return;
117
+ let joined;
118
+ const pending = new Promise(resolve => { joined = resolve; });
119
+ (this.adapterConnects ??= new Set()).add(pending);
110
120
  try {
111
- status = await adapter.connect((msg) => this.dispatchLogged(events.inbound({ ...msg, at: msg.at ?? this.clock() })));
112
- if (status?.state === 'down' && status.configured === false) {
113
- // not set up (a missing package or credential): no retry can fix it
114
- this.log('error', `${platform}: not configured, not retried: ${status.error}`);
115
- await this.dispatchLogged(events.adapterStatus({ platform, state: 'down', error: status.error, configured: false, at: this.clock() }));
121
+ let status;
122
+ try {
123
+ status = await adapter.connect((msg) => this.dispatchLogged(events.inbound({ ...msg, at: msg.at ?? this.clock() })));
124
+ if (status?.state === 'down' && status.configured === false) {
125
+ // not set up (a missing package or credential): no retry can fix it
126
+ this.log('error', `${platform}: not configured, not retried: ${status.error}`);
127
+ await this.dispatchLogged(events.adapterStatus({ platform, state: 'down', error: status.error, configured: false, at: this.clock() }));
128
+ return;
129
+ }
130
+ } catch (error) {
131
+ this.log('error', `${platform}: connect failed, retrying in ${ADAPTER_RETRY_MS / 1000}s: ${error instanceof Error ? error.message : String(error)}`);
132
+ await this.dispatchLogged(events.adapterStatus({ platform, state: 'down', error: error instanceof Error ? error.message : String(error), at: this.clock() }));
133
+ if (this.stopping) return;
134
+ this.adapterRetries ??= new Map();
135
+ const timer = setTimeout(() => { this.adapterRetries?.delete(platform); void this.connectAdapter(platform, adapter); }, ADAPTER_RETRY_MS);
136
+ timer.unref?.();
137
+ this.adapterRetries.set(platform, timer);
116
138
  return;
117
139
  }
118
- } catch (error) {
119
- this.log('error', `${platform}: connect failed, retrying in ${ADAPTER_RETRY_MS / 1000}s: ${error instanceof Error ? error.message : String(error)}`);
120
- await this.dispatchLogged(events.adapterStatus({ platform, state: 'down', error: error instanceof Error ? error.message : String(error), at: this.clock() }));
121
- this.adapterRetries ??= new Map();
122
- const timer = setTimeout(() => { this.adapterRetries?.delete(platform); void this.connectAdapter(platform, adapter); }, ADAPTER_RETRY_MS);
123
- timer.unref?.();
124
- this.adapterRetries.set(platform, timer);
125
- return;
126
- }
127
- // connected: recording that is the queue's (a failed save is logged there),
128
- // never a reason to connect the same adapter again
129
- await this.dispatchLogged(events.adapterStatus({ platform, state: status?.state ?? 'up', at: this.clock() }));
140
+ // connected: recording that is the queue's (a failed save is logged there),
141
+ // never a reason to connect the same adapter again
142
+ await this.dispatchLogged(events.adapterStatus({ platform, state: status?.state ?? 'up', at: this.clock() }));
143
+ } finally { this.adapterConnects.delete(pending); joined(); }
130
144
  }
131
145
 
132
146
  /** The port the webhook listener bound (0 in config means "any free port"). */
@@ -231,27 +245,111 @@ export class Orchestrator {
231
245
  }
232
246
  }
233
247
 
234
- async stop() {
248
+ stop() {
249
+ return this.stopPromise ??= this.stopOwned();
250
+ }
251
+
252
+ async stopOwned() {
253
+ this.stopping = true;
254
+ this.stopErrors = [];
255
+ const attempt = async (name, work) => {
256
+ try { await work(); }
257
+ catch (error) { this.stopErrors.push(new Error(`${name}: ${error.message}`, { cause: error })); }
258
+ };
235
259
  if (this.timer) { clearInterval(this.timer); this.timer = null; }
236
- // Before any runtime closes: each binding records the runtime to resume (§4.7), while adapters can still deliver.
237
- await this.dispatch(events.stopping(this.clock())).catch((error) => this.log('error', `stopping: ${error.message}`));
238
260
  for (const timer of this.adapterRetries?.values() ?? []) clearTimeout(timer);
239
261
  this.adapterRetries?.clear();
240
- if (this.webhooks) { await this.webhooks.close(); this.webhooks = null; }
241
- if (this.bridge) { await this.bridge.close(); this.bridge = null; }
262
+ // Unsent worker input has no positive HTTP receipt. Refuse it before
263
+ // joining handlers; a queued turn cannot hold stop waiting for a model.
264
+ for (const admission of this.webhookAdmissions.values()) if (!admission.sending) admission.finish(new WebhookRefusal('orchestrator is stopping; retry the webhook'));
242
265
  this.gateAbort?.abort();
243
- await this.drainHosted();
244
- for (const rt of this.runtimes.values()) { try { await rt.close(); } catch { /* already gone */ } }
266
+ // close() seals ingress synchronously, before any connect/cleanup wait.
267
+ // Already-admitted bodies retain dispatch authority through verification;
268
+ // their handlers join the queue before its stopping event below.
269
+ const webhooksClosed = this.webhooks ? attempt('webhooks', () => this.webhooks.close()) : Promise.resolve();
270
+ await Promise.allSettled([...(this.adapterConnects ?? [])]);
271
+ await webhooksClosed;
272
+ this.webhooks = null;
273
+ if (this.bridge) { await attempt('bridge', () => this.bridge.close()); this.bridge = null; }
274
+ await attempt('hosted jobs', () => this.drainHosted());
275
+ await attempt('internal completions', () => this.drainInternal());
276
+ // Join admitted work and record the resumable binding before closing runtimes.
277
+ await attempt('stopping', () => this.dispatch(events.stopping(this.clock())));
278
+ for (const rt of new Set([...this.runtimes.values(), ...this.retired.values()])) await attempt('runtime', () => rt.close());
245
279
  this.runtimes.clear();
246
- for (const adapter of Object.values(this.adapters)) await adapter.close?.();
247
- releaseWorld();
280
+ this.retired.clear();
281
+ for (const adapter of Object.values(this.adapters)) await attempt('adapter', () => adapter.close?.());
282
+ await attempt('World reference', () => releaseWorld());
283
+ if (this.stopErrors.length) throw new AggregateError(this.stopErrors, this.stopErrors.map(error => error.message).join('; '));
248
284
  }
249
285
 
250
286
  /** Events are applied one at a time, in arrival order; effects run before the next event. */
251
287
  dispatch(event) {
252
- const run = this.queue.then(() => this.apply(event));
288
+ const frame = { children: [] };
289
+ const run = this.queue.then(async () => {
290
+ this.applying = frame;
291
+ try { return await this.apply(event); }
292
+ finally { this.applying = null; }
293
+ });
253
294
  this.queue = run.catch((error) => this.log('error', `dispatch: ${error.message}`));
254
- return run;
295
+ // The queue serializes reducer applications, not these joins: a child
296
+ // must be able to run after its parent's apply without waiting on itself.
297
+ const joined = async (result, failures = []) => {
298
+ const children = await Promise.allSettled(frame.children);
299
+ const errors = [...failures, ...children.filter(row => row.status === 'rejected').map(row => row.reason)];
300
+ if (errors.length) throw new AggregateError(errors, errors.map(error => String(error?.message ?? error)).join('; '));
301
+ return result;
302
+ };
303
+ return run.then(result => joined(result), error => joined(null, [error]));
304
+ }
305
+
306
+ /** HTTP success for worker input means handoff, never just an in-memory queue. */
307
+ dispatchWebhook(event) {
308
+ if (this.stopping) return Promise.reject(new WebhookRefusal('orchestrator is stopping; retry the webhook'));
309
+ if (!event.verified) return this.dispatch(event);
310
+ const found = findSubscription(this.state, event.name);
311
+ if (!found?.profile.worker || found.sub.residue?.deliver_only === true) return this.dispatch(event);
312
+ const previous = event.delivery_id && [...this.webhookAdmissions.values()].find(row => row.name === event.name && row.delivery_id === event.delivery_id);
313
+ if (previous) return previous.completion;
314
+ const id = randomUUID();
315
+ let settle;
316
+ const admission = { name: event.name, delivery_id: event.delivery_id, result: new Promise(resolve => { settle = resolve; }) };
317
+ admission.finish = error => {
318
+ if (admission.outcome) return;
319
+ admission.outcome = { error };
320
+ settle(admission.outcome);
321
+ };
322
+ this.webhookAdmissions.set(id, admission);
323
+ admission.completion = (async () => {
324
+ try {
325
+ await this.dispatch({ ...event, admission_id: id });
326
+ if (!admission.awaitingInput) admission.finish(null);
327
+ const { error } = await admission.result;
328
+ if (error) throw error;
329
+ } catch (error) {
330
+ // A retryable refusal must not leave an in-memory dedup success for
331
+ // input that was never handed off. Concurrent retries join above.
332
+ try {
333
+ await this.dispatch({ type: 'webhook_refused', name: event.name, delivery_id: event.delivery_id, admission_id: id,
334
+ profile: admission.profile, keyString: admission.keyString, handed: Boolean(admission.outcome && !admission.outcome.error), stopping: Boolean(this.stopping), at: this.clock() });
335
+ } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'webhook refusal cleanup failed'); }
336
+ throw error;
337
+ } finally { this.webhookAdmissions.delete(id); }
338
+ })();
339
+ return admission.completion;
340
+ }
341
+
342
+ /** Drain descendants to a stable queue tail, then fence later worker callbacks. */
343
+ async drainInternal() {
344
+ for (;;) {
345
+ const tail = this.queue;
346
+ await Promise.allSettled([tail, ...this.internalDispatches]);
347
+ if (tail === this.queue && !this.internalDispatches.size) {
348
+ this.internalClosed = true;
349
+ break;
350
+ }
351
+ }
352
+ if (this.internalFailures.length) throw new AggregateError(this.internalFailures, 'internal completion failed');
255
353
  }
256
354
 
257
355
  /**
@@ -260,19 +358,40 @@ export class Orchestrator {
260
358
  * event currently being applied, i.e. on itself.
261
359
  */
262
360
  enqueue(event) {
263
- void this.dispatchLogged(event);
361
+ if (this.internalClosed) return;
362
+ const pending = this.dispatch(event);
363
+ this.applying?.children.push(pending);
364
+ this.internalDispatches.add(pending);
365
+ void pending.then(() => this.internalDispatches.delete(pending), error => {
366
+ this.internalFailures.push(error);
367
+ this.internalDispatches.delete(pending);
368
+ });
264
369
  }
265
370
 
266
371
  /**
267
372
  * `dispatch` for a caller that drops the promise (a timer, an adapter's
268
- * inbound callback, the webhook listener). The queue already logged the
373
+ * inbound callback). Webhooks retain their dispatch result for HTTP admission.
374
+ * The queue already logged the
269
375
  * failure; an unhandled rejection is fatal to Node and stopped the daemon.
270
376
  */
271
377
  dispatchLogged(event) {
378
+ if (this.stopping) return Promise.resolve();
272
379
  return this.dispatch(event).catch(() => undefined);
273
380
  }
274
381
 
275
382
  async apply(event) {
383
+ if (event.type === 'webhook' && this.webhookAdmissions.get(event.admission_id)?.outcome?.error) return [];
384
+ if (event.type === 'worker' && ['error', 'ended'].includes(event.ev.kind)) {
385
+ const binding = this.state.profiles[event.profile]?.bindings?.[event.keyString];
386
+ const current = this.state.runtime?.leases?.[leaseId(event.profile, event.keyString)]?.runtime_id ?? binding?.residue?.runtime_id ?? null;
387
+ if (!event.runtime_id || event.runtime_id === current) {
388
+ for (const admission of this.webhookAdmissions.values()) {
389
+ if (admission.profile === event.profile && admission.keyString === event.keyString) {
390
+ admission.finish(new Error(`webhook worker ended before handoff: ${event.ev.message ?? event.ev.reason ?? 'worker ended'}`));
391
+ }
392
+ }
393
+ }
394
+ }
276
395
  if(event.type==='inbound'&&!event.is_me) {
277
396
  // An adapter's display name or supplied principal never proves identity.
278
397
  const observation=`${event.key.platform}:${event.sender}`;
@@ -293,11 +412,29 @@ export class Orchestrator {
293
412
  if (index === firstSave) continue;
294
413
  await this.perform(effect);
295
414
  }
415
+ // Expiry/reset/end can remove a queued input before its handoff. Its HTTP
416
+ // owner must receive refusal rather than wait on a queue that no longer exists.
417
+ for (const [id, admission] of this.webhookAdmissions) {
418
+ if (!admission.awaitingInput || admission.outcome || admission.sending) continue;
419
+ const lid = leaseId(admission.profile, admission.keyString);
420
+ const rt = this.state.runtime;
421
+ if (rt?.turns?.[lid]?.webhook_input_id !== id && !rt?.queues?.[lid]?.some(row => row.webhook_input_id === id)) {
422
+ admission.finish(new WebhookRefusal('worker input ended before handoff; retry the webhook'));
423
+ }
424
+ }
296
425
  return effects;
297
426
  }
298
427
 
299
428
  async perform(effect) {
300
429
  switch (effect.type) {
430
+ case 'webhook_input': {
431
+ const admission = this.webhookAdmissions.get(effect.admission_id);
432
+ if (admission) {
433
+ Object.assign(admission, { awaitingInput: true, profile: effect.profile, keyString: effect.keyString });
434
+ if (effect.refusal) admission.finish(new WebhookRefusal(effect.refusal));
435
+ }
436
+ return;
437
+ }
301
438
  case 'execute_job': return this.executeHostedJob(effect);
302
439
  case 'run_gate': return this.runGate(effect);
303
440
  case 'runtime_start': return this.startRuntime(effect);
@@ -382,7 +519,8 @@ export class Orchestrator {
382
519
  const args = ['open', '--new', program, '--detach', '--window=true', '--on', machine, '--key', `cron-${fire.id}`, '--input', job.prompt ?? ''];
383
520
  if (job.workdir && job.workdir.startsWith('/')) args.push('--cwd', job.workdir);
384
521
  if (job.model) args.push('--', '--model', job.model);
385
- const { stdout } = await promisify(execFile)(process.env.SUPERCODE_BIN || 'supercode', args, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
522
+ const selected = supercodeInvocation(process.env.SUPERCODE_BIN || 'supercode', args);
523
+ const { stdout } = await promisify(execFile)(selected.program, selected.arguments, { timeout: 180_000, maxBuffer: 4 * 1024 * 1024 });
386
524
  const pane = /"pane"\s*:\s*"([^"]+)"/.exec(stdout)?.[1] ?? null;
387
525
  return { accepted: true, output: `Fire ${fire.id} of ${job.id} launched on ${machine}${pane ? ` in pane ${pane}` : ''}.`, machine, pane };
388
526
  }
@@ -415,7 +553,7 @@ export class Orchestrator {
415
553
  return { monitor, script };
416
554
  })()
417
555
  .catch((error) => ({ script: { ok: false, output: `Script execution failed: ${error.message}` } }))
418
- .then((answer) => (signal.aborted ? undefined : this.dispatchLogged(events.gateResult({ profile: name, fire_id, at: this.clock(), ...answer }))))
556
+ .then((answer) => { if (!signal.aborted) this.enqueue(events.gateResult({ profile: name, fire_id, at: this.clock(), ...answer })); })
419
557
  .finally(() => this.hostedTasks.delete(task));
420
558
  this.hostedTasks.add(task);
421
559
  }
@@ -494,8 +632,8 @@ export class Orchestrator {
494
632
  if (resume_runtime_id) resume_runtime_id = await this.continueFromHermes(profile, keyString, worker, resume_runtime_id, cwd,agentHome);
495
633
  // read before the runtime exists: a runtime emits from the moment it is made
496
634
  steer = Boolean((await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.steer);
497
- const policy = approvalPolicy(profile, worker.harness);
498
- const params = { harness: worker.harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}) };
635
+ const policy = approvalPolicy(profile, worker.harness), reviewer = approvalsReviewer(profile, worker.harness);
636
+ const params = { harness: worker.harness, cwd, launch: mount.launch, ...(mount.mcp_servers ? { mcp_servers: mount.mcp_servers } : {}), ...(policy ? { approval_policy: policy } : {}), ...(reviewer ? { approvals_reviewer: reviewer } : {}) };
499
637
  if (resume_runtime_id && (await this.descriptorFor(worker.harness).catch(() => null))?.runtime?.capabilities?.resume_session) {
500
638
  try {
501
639
  runtime = await this.client.resumeManagedRuntime({ ...params, runtime_id: resume_runtime_id });
@@ -581,9 +719,12 @@ export class Orchestrator {
581
719
  const instructions = agentPreamble(agent, { main: true,
582
720
  previous: localAgentSessions(this.state.root, scope.org, agent.identity.id) });
583
721
  const arguments_ = [...route.args, ...INSTRUCTION_ARGUMENTS.codex(instructions)];
722
+ // The profile's mode, on a resume too: an approvals-on main asks a person (`on-request`, reviewer `user`), an
723
+ // approvals-off main gets the auto-reviewer again (a resume's recorded mode gives way to it: teams/mail/agents.mjs
724
+ // `resume`).
725
+ arguments_.push(...mainApprovalArguments(profile));
584
726
  if (resume === null) {
585
- arguments_.push(...modelArguments({ ...worker, model: route.model }), '--ask-for-approval', approvalPolicy(profile, 'codex'));
586
- arguments_.push(...sandboxArguments(profile, 'codex'));
727
+ arguments_.push(...modelArguments({ ...worker, model: route.model }));
587
728
  if (worker.reasoning_effort) arguments_.push('-c', `model_reasoning_effort=${JSON.stringify(worker.reasoning_effort)}`);
588
729
  }
589
730
  declareWorkerHome({ harness: 'codex', env, cwd: folder, log: (level, text) => this.log(level, text) });
@@ -712,7 +853,7 @@ export class Orchestrator {
712
853
  async resolveLaunch(profile, worker, env, routeArgs = []) {
713
854
  if (this.launchFor) {
714
855
  const override = this.launchFor(profile, worker, env);
715
- if (override) return { ...override, arguments: [...(override.arguments ?? []), ...modelArguments(worker), ...routeArgs, ...sandboxArguments(profile, worker.harness), ...limitArguments(profile, worker, this.log)] };
856
+ if (override) return { ...override, arguments: [...(override.arguments ?? []), ...modelArguments(worker), ...routeArgs, ...modeArguments(profile, worker.harness), ...limitArguments(profile, worker, this.log)] };
716
857
  }
717
858
  const base = (await this.descriptorFor(worker.harness))?.runtime?.default_launch;
718
859
  if (!base) {
@@ -720,7 +861,7 @@ export class Orchestrator {
720
861
  if (worker.model) throw new Error(`${worker.harness} publishes no launch to carry model ${worker.model}`);
721
862
  return undefined;
722
863
  }
723
- return { program: base.program, arguments: [...(base.arguments ?? []), ...modelArguments(worker), ...routeArgs, ...sandboxArguments(profile, worker.harness), ...limitArguments(profile, worker, this.log)], env: { ...(base.env ?? {}), ...env } };
864
+ return { program: base.program, arguments: [...(base.arguments ?? []), ...modelArguments(worker), ...routeArgs, ...modeArguments(profile, worker.harness), ...limitArguments(profile, worker, this.log)], env: { ...(base.env ?? {}), ...env } };
724
865
  }
725
866
 
726
867
  /**
@@ -904,9 +1045,13 @@ export class Orchestrator {
904
1045
  } catch (error) { this.log('warn', `transfer history unread: ${error.message}`); return ''; }
905
1046
  }
906
1047
 
907
- async sendInput({ profile, keyString, text, context_from = null, skills = [], job_id = null, history = null }) {
1048
+ async sendInput({ profile, keyString, text, context_from = null, skills = [], job_id = null, history = null, webhook_input_id = null }) {
1049
+ const admission = webhook_input_id ? this.webhookAdmissions.get(webhook_input_id) : null;
1050
+ // Shutdown refused this unsent request (or its handler already failed).
1051
+ // It must not be delivered later by a queued started/final completion.
1052
+ if (webhook_input_id && (!admission || admission.outcome?.error)) return;
908
1053
  const runtime = this.runtimes.get(leaseId(profile, keyString));
909
- if (!runtime) { this.log('warn', `send_input with no runtime for ${keyString}`); return; }
1054
+ if (!runtime) { admission?.finish(new Error(`webhook input has no runtime for ${keyString}`)); this.log('warn', `send_input with no runtime for ${keyString}`); return; }
910
1055
  const owner = this.state.profiles[profile];
911
1056
  const dir = owner?.dir;
912
1057
  let input = context_from && dir ? contextFromPrefix(dir, job_id, context_from) + text : text;
@@ -914,19 +1059,25 @@ export class Orchestrator {
914
1059
  if (skills?.length && owner) input = await this.skillsPrefix(owner, skills, owner.jobs?.[job_id]?.workdir ?? null, input) ?? input;
915
1060
  const lid=leaseId(profile,keyString),preamble=this.runtimeAgentPreambles?.get(lid);
916
1061
  if(preamble)input=`${preamble}\n\n${input}`;
1062
+ if (admission?.outcome?.error) return;
1063
+ if (admission) admission.sending = true;
917
1064
  try {
918
1065
  await runtime.sendInput(input);
919
1066
  this.runtimeAgentPreambles?.delete(lid);
1067
+ admission?.finish(null);
920
1068
  } catch (error) {
1069
+ if (admission) admission.sending = false;
921
1070
  // A turn the harness is still running (one this loop did not start, or whose end it has not heard yet) is not a
922
1071
  // dead session: the line waits for that turn's end, as a refused steer does. Ending the binding on it killed a
923
1072
  // live lobby manager on the first message that reached it mid-turn.
924
1073
  if (/already in progress/.test(error.message)) {
1074
+ if (admission && this.stopping) { admission.finish(new WebhookRefusal('orchestrator stopped before the worker could take this input; retry')); return; }
925
1075
  this.log('info', `send ${keyString}: a turn is in progress; the line waits for its end`);
926
- this.enqueue(events.worker({ profile, keyString, runtime_id: runtime.handle.runtime_id, ev: { kind: 'steer_refused', text: input }, at: this.clock() }));
1076
+ this.enqueue(events.worker({ profile, keyString, runtime_id: runtime.handle.runtime_id, ev: { kind: 'steer_refused', text: webhook_input_id ? text : input, webhook_input_id }, at: this.clock() }));
927
1077
  return;
928
1078
  }
929
1079
  this.enqueue(events.worker({ profile, keyString, runtime_id: runtime.handle.runtime_id, ev: { kind: 'error', message: `send failed: ${error.message}` }, at: this.clock() }));
1080
+ admission?.finish(error);
930
1081
  }
931
1082
  }
932
1083
 
@@ -982,14 +1133,14 @@ export class Orchestrator {
982
1133
  const old = this.retired.get(runtime_id);
983
1134
  this.retired.delete(runtime_id);
984
1135
  this.turnCounters?.delete(runtime_id);
985
- try { await old.close(); } catch { /* already gone */ }
1136
+ try { await old.close(); } catch (error) { if (this.stopping) this.stopErrors.push(error); }
986
1137
  return;
987
1138
  }
988
1139
  const runtime = this.runtimes.get(lid);
989
1140
  if (runtime_id && runtime && runtime.handle.runtime_id !== runtime_id) return;
990
1141
  this.runtimes.delete(lid);
991
1142
  if (runtime) this.turnCounters?.delete(runtime.handle.runtime_id);
992
- if (runtime) { try { await runtime.close(); } catch { /* already gone */ } }
1143
+ if (runtime) { try { await runtime.close(); } catch (error) { if (this.stopping) this.stopErrors.push(error); } }
993
1144
  }
994
1145
 
995
1146
  /**
package/mcp/mount.mjs CHANGED
@@ -23,7 +23,7 @@ import { SERVER_NAME } from './tools.mjs';
23
23
  import { SOCKET_ENV, socketPathFor } from './server.mjs';
24
24
  import { mcpServers } from '../hermes-home.mjs';
25
25
  import { KANBAN_ENV } from '../hermes-cli.mjs';
26
- import { installedAgentRecord } from '../installed-apps.mjs';
26
+ import { installedAgentRecords } from '../installed-apps.mjs';
27
27
  import { supercodeHome } from '@volter/teams/home';
28
28
 
29
29
  export { MCP_DOORS, doorFor } from '@volter/supercode-harness-sdk/mcp-doors';
@@ -41,11 +41,12 @@ function stableSupercode() {
41
41
  * `rh2` server, whatever it holds, is neither replaced nor relied on). */
42
42
  export const RH2_AGENT_SERVER = 'rh2-agent';
43
43
 
44
- /** `rh2 mcp` run as the agent's installed RH2 principal: `apps mcp` puts the record's origin, organization and token in
45
- * its environment, so no config file carries the token. Null when no rh2-agent record is the identity of `agentId` (its
46
- * Teams id): a record is found by that id alone, so an agent sharing another's name is never mounted with its record. */
44
+ /** `rh2 mcp` run as the agent's installed RH2 principal: `apps mcp` puts its first record's origin, organization and
45
+ * token in its environment, and the home whose records hold its other Workplaces', so no config file carries a token.
46
+ * Null when no rh2-agent record is an identity of `agentId` (its Teams id): a record is found by that id alone, so an
47
+ * agent sharing another's name is never mounted with its record. */
47
48
  function rh2AgentServer(root, agentId, program) {
48
- if (!installedAgentRecord(root, agentId)) return null;
49
+ if (!installedAgentRecords(root, agentId).length) return null;
49
50
  return { command: stableSupercode(), args: ['orchestrator', 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId, '--node', program] };
50
51
  }
51
52
 
package/orchestration.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // The folder, through the harness's orchestration doors.
2
2
  //
3
3
  // ONT-7: this package reads and writes its home through `harness.v1.orchestration.*`
4
- // (`crates/harness/src/orchestration_doors.rs`), never with a codec of its own. The
5
- // ontology's one codec is the Rust one; this module is its client side:
4
+ // (the SDK's orchestration owners), never with a codec of its own. The
5
+ // ontology's one codec is TypeScript; this module is its client side:
6
6
  //
7
7
  // - `load(root)` asks `orchestration.load` for the orchestration value and adds the runtime
8
8
  // half (`OrchestratorState` = orchestration + runtime, docs/ORCHESTRATOR-IR.md §2.1).
@@ -14,8 +14,7 @@
14
14
  // moves credentials, so `import` and `export` are composed inside the
15
15
  // harness process (see the orchestration_doors module doc).
16
16
  //
17
- // One door per process: the first verb spawns `supercode harness serve`
18
- // (the binary that spawned us, else this checkout's build) and every verb
17
+ // One door per process: the first verb opens the SDK's owned transport and every verb
19
18
  // after it reuses it. The door is held open while a loop runs
20
19
  // (`retainWorld` / `releaseWorld`, the loop's `start` / `stop`) or a verb is
21
20
  // in flight; otherwise it closes itself after a short idle, so a process
@@ -23,26 +22,21 @@
23
22
  // `closeWorld()` closes it now.
24
23
 
25
24
  import { existsSync, readFileSync } from 'node:fs';
26
- import { dirname, join, resolve } from 'node:path';
27
- import { fileURLToPath } from 'node:url';
25
+ import { join } from 'node:path';
28
26
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
29
27
  import { emptyRuntime, persistedView } from './ir.mjs';
30
- import { lockedNative } from './source-lock.mjs';
28
+ import { lockedProgram } from './source-lock.mjs';
29
+ import { sourceRoot } from '@volter/supercode-teams/source-lock';
31
30
  import { SESSION_READ_REQUEST_UNITS, stringifySessionWire } from '@volter/supercode-teams/session-read-client';
32
31
 
33
- const here = dirname(fileURLToPath(import.meta.url));
34
-
35
- /**
36
- * The binary the doors are opened with: `SUPERCODE_BIN` (set by `supercode
37
- * orchestrator start` to the very binary that spawned the daemon), else this
38
- * checkout's debug build, else `supercode` on PATH.
39
- */
32
+ /** Selected CLI for clients that need it: the locked Node entry, explicit
33
+ * override, this source tree's front door, or the installed PATH command. */
40
34
  export function supercodeCommand() {
41
- const pinned = lockedNative();
35
+ const pinned = lockedProgram();
42
36
  if (pinned) return pinned;
43
37
  if (process.env.SUPERCODE_BIN) return process.env.SUPERCODE_BIN;
44
- const built = resolve(here, '../../target/debug/supercode');
45
- return existsSync(built) ? built : 'supercode';
38
+ const root = sourceRoot(import.meta.url);
39
+ return root ? join(root, 'packaging/npm/bin/supercode.js') : 'supercode';
46
40
  }
47
41
 
48
42
  const IDLE_MS = 250;
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.215",
4
- "gitHead": "8267b3766f01d81c1c3e2acd3451b36aa587bf47",
3
+ "version": "0.5.217",
5
4
  "type": "module",
6
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
7
6
  "exports": {
7
+ "./daemon-control": {
8
+ "types": "./daemon-control.d.ts",
9
+ "import": "./daemon-control.mjs"
10
+ },
8
11
  "./session-recovery": {
9
12
  "types": "./session-recovery.d.ts",
10
13
  "import": "./session-recovery.mjs"
@@ -33,10 +36,10 @@
33
36
  "check-types": "node scripts/generate-types.mjs --check"
34
37
  },
35
38
  "dependencies": {
36
- "@volter/supercode-teams": "^0.3.350",
37
- "@volter/supercode-terminal": "^0.3.10",
38
- "@volter/supercode-harness-sdk": "^0.3.104",
39
- "@volter/teams": "0.2.99",
39
+ "@volter/supercode-teams": "^0.3.361",
40
+ "@volter/supercode-terminal": "^0.3.12",
41
+ "@volter/supercode-harness-sdk": "^0.3.106",
42
+ "@volter/teams": "0.2.102",
40
43
  "@volter/ztrack": "^2.4.23",
41
44
  "yaml": "^2.9.0"
42
45
  },
package/reducer.mjs CHANGED
@@ -499,7 +499,7 @@ function handleWorker(state, ev, effects) {
499
499
  const q = rt.queues[lid] ?? [];
500
500
  if (q.length && !rt.turns[lid]) {
501
501
  const next = q.shift();
502
- rt.turns[lid] = { started_at: now, buffer: '' };
502
+ rt.turns[lid] = { started_at: now, buffer: '', ...(next.webhook_input_id ? { webhook_input_id: next.webhook_input_id } : {}) };
503
503
  effects.push(queuedInput(profile.name, ev.keyString, next));
504
504
  }
505
505
  effects.push({ type: 'save' });
@@ -507,7 +507,7 @@ function handleWorker(state, ev, effects) {
507
507
  }
508
508
  case 'steer_refused': {
509
509
  // The harness would not take the line mid-turn: it waits for the turn's end like any other.
510
- (rt.queues[lid] ??= []).push({ text: w.text, at: now });
510
+ (rt.queues[lid] ??= []).push({ text: w.text, at: now, ...(w.webhook_input_id ? { webhook_input_id: w.webhook_input_id } : {}) });
511
511
  break;
512
512
  }
513
513
  case 'text': {
@@ -556,7 +556,7 @@ function handleWorker(state, ev, effects) {
556
556
  const q = rt.queues[lid] ?? [];
557
557
  if (q.length && rt.leases[lid]) {
558
558
  const next = q.shift();
559
- rt.turns[lid] = { started_at: now, buffer: '' };
559
+ rt.turns[lid] = { started_at: now, buffer: '', ...(next.webhook_input_id ? { webhook_input_id: next.webhook_input_id } : {}) };
560
560
  effects.push(queuedInput(profile.name, ev.keyString, next));
561
561
  }
562
562
  // G3: the session's usage is read again from the harness's own records after every turn
@@ -630,7 +630,28 @@ export function permissionResponse(options, allow, reason = null) {
630
630
  */
631
631
  function queuedInput(profile, keyString, next) {
632
632
  const framed = next.job_id && (next.context_from || next.skills?.length);
633
- return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}), ...(next.history ? { history: next.history } : {}) };
633
+ return { type: 'send_input', profile, keyString, text: next.text, ...(framed ? { context_from: next.context_from ?? null, skills: next.skills ?? [], job_id: next.job_id } : {}), ...(next.history ? { history: next.history } : {}), ...(next.webhook_input_id ? { webhook_input_id: next.webhook_input_id } : {}) };
634
+ }
635
+
636
+ /** Remove only this unacknowledged input; a refused queue slot is no live turn. */
637
+ function refuseWebhook(state, ev, effects) {
638
+ if (ev.handed) return;
639
+ // Only an apply that claimed this input may retire its dedup entry. A
640
+ // request refused before apply must not erase an earlier successful delivery.
641
+ if (!ev.profile || !ev.keyString) return;
642
+ const rt = ensureRuntime(state);
643
+ const seen = rt.webhook_deliveries?.[ev.name];
644
+ const index = seen?.indexOf(ev.delivery_id) ?? -1;
645
+ if (index !== -1) seen.splice(index, 1);
646
+ const lid = leaseId(ev.profile, ev.keyString);
647
+ if (rt.queues[lid]) rt.queues[lid] = rt.queues[lid].filter(row => row.webhook_input_id !== ev.admission_id);
648
+ if (rt.turns[lid]?.webhook_input_id !== ev.admission_id) return;
649
+ delete rt.turns[lid];
650
+ if (!ev.stopping && rt.leases[lid] && rt.queues[lid]?.length) {
651
+ const next = rt.queues[lid].shift();
652
+ rt.turns[lid] = { started_at: ev.at, buffer: '', ...(next.webhook_input_id ? { webhook_input_id: next.webhook_input_id } : {}) };
653
+ effects.push(queuedInput(ev.profile, ev.keyString, next));
654
+ }
634
655
  }
635
656
 
636
657
  /** An admin's numeric reply resolves the oldest pending request on that surface. */
@@ -688,10 +709,13 @@ function handleStopping(state, ev, effects) {
688
709
  delete b.residue.runtime_id;
689
710
  // a stop for a restart: what was live now is live again once the daemon is back (handleRestart)
690
711
  if (b.key.platform !== 'webhook' && b.key.platform !== 'cron') b.residue.reopen_at_start = true;
691
- if (rt.turns[leaseId(profile.name, ks)] && !b.recurrence && b.key.platform !== 'webhook' && b.key.platform !== 'cron') {
712
+ if (rt.turns[leaseId(profile.name, ks)] && !b.recurrence) {
692
713
  b.residue.interrupted_at = ev.at;
693
- reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
714
+ if (b.key.platform !== 'webhook' && b.key.platform !== 'cron') reply(state, profile, b.key, REPLY.interrupted, ev.at, effects);
694
715
  }
716
+ // the runtime the stop is about to close no longer serves the surface: its `closed` is not heard (handleWorker),
717
+ // so it cannot end a binding, or fail a fire, that the next daemon resumes
718
+ delete rt.leases[leaseId(profile.name, ks)];
695
719
  }
696
720
  }
697
721
  effects.push({ type: 'save' });
@@ -962,6 +986,7 @@ export function step(state, event) {
962
986
  case 'hosted_result': finishHostedFire(state, event, effects); break;
963
987
  case 'gate_result': gateResult(state, event, effects); break;
964
988
  case 'webhook': handleWebhook(state, event, effects); break;
989
+ case 'webhook_refused': refuseWebhook(state, event, effects); break;
965
990
  case 'restart': handleRestart(state, event, effects); break;
966
991
  case 'stopping': handleStopping(state, event, effects); break;
967
992
  case 'adapter': handleAdapter(state, event); break;