@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/README.md +71 -9
- package/activation.mjs +47 -11
- package/adapters/chat-sdk.mjs +82 -15
- package/adapters/rh2.mjs +11 -7
- package/agents.mjs +1 -5
- package/bin/agent-channel.mjs +33 -177
- package/bin/connection-runner.mjs +3 -17
- package/bin/orchestrator.mjs +12 -7
- package/bin/runtime.mjs +74 -51
- package/board/call.mjs +2 -1
- package/board/ci.mjs +55 -28
- package/board/cli.mjs +147 -13
- package/board/client.mjs +9 -0
- package/board/dispatch.mjs +45 -15
- package/board/engine.mjs +2 -2
- package/board/fence.mjs +2 -0
- package/board/generation-owner.mjs +294 -0
- package/board/generation.mjs +49 -0
- package/board/listing.mjs +17 -5
- package/board/owner-door-worker.mjs +22 -7
- package/board/owner-door.mjs +2 -2
- package/board/owner-files.mjs +1 -1
- package/board/published.mjs +12 -7
- package/board/reply.mjs +2 -2
- package/board/session-work.mjs +85 -15
- package/connection.mjs +14 -20
- package/cron.mjs +57 -4
- package/daemon-control.d.ts +39 -0
- package/daemon-control.mjs +275 -0
- package/hermes-locks.mjs +13 -3
- package/index.d.ts +9 -4
- package/installed-apps.mjs +35 -14
- package/kanban-worker.mjs +4 -4
- package/lease.mjs +1 -1
- package/loop.mjs +198 -47
- package/mcp/mount.mjs +6 -5
- package/orchestration.mjs +11 -17
- package/package.json +9 -6
- package/reducer.mjs +31 -6
- package/source-lock.mjs +15 -24
- package/webhook.mjs +65 -12
package/README.md
CHANGED
|
@@ -77,6 +77,14 @@ Other SDK callers that persist runtime state set `preserve_workflow: true` on
|
|
|
77
77
|
|
|
78
78
|
## Board machine-call deadlines
|
|
79
79
|
|
|
80
|
+
`workflow serve` retains its public socket and kernel lock while dispatcher generations change.
|
|
81
|
+
`workflow handover --entry /absolute/path/to/bin/orchestrator.mjs --supercode /absolute/path/to/native --root HOME`
|
|
82
|
+
prepares a successor through the normal source checks, drains admitted work, and transfers its writer epoch.
|
|
83
|
+
It routes calls only after a clean round and a successful stats reply. The parked predecessor remains available
|
|
84
|
+
for rollback after candidate loss. A request already sent to a lost generation returns an unknown outcome and
|
|
85
|
+
is never replayed. This source mechanism still needs the complete machine release integration and recorded
|
|
86
|
+
qualification described in [ADR 0080](../../docs/adr/0080-serving-generation-handovers.md).
|
|
87
|
+
|
|
80
88
|
The board owner's machine calls share one timeout budget across channel admission,
|
|
81
89
|
node or harness admission, and the method request. A call whose admission expires
|
|
82
90
|
never sends the method after its caller has stopped waiting. It leaves the owner's
|
|
@@ -109,6 +117,11 @@ retains a separate partial file and reports its path; there is no automatic resu
|
|
|
109
117
|
The board host needs an orchestrator that serves `attachment-read` and its normal
|
|
110
118
|
running board owner. Remote reads keep the board door's caller and grant checks.
|
|
111
119
|
|
|
120
|
+
A card's published snapshot lists its attachments (`attachments`: each `{ id, name, content_type, size, by, at }`,
|
|
121
|
+
never where the board keeps it), and attaching or removing a file publishes the card, so an integration that reads only
|
|
122
|
+
the board (runhuman-2 ADR 0063 decision 3: Project Rooms reposting a card's images and videos) learns of a new file from
|
|
123
|
+
the card's change and reads its bytes through the board door with `attachment-get` or `attachment-read`.
|
|
124
|
+
|
|
112
125
|
`workflow attachment-read <card> <attachment-id>` returns JSON with at most 1 MiB
|
|
113
126
|
of `content_base64`, metadata, offsets, `eof` and a filesystem metadata `version`.
|
|
114
127
|
Subsequent reads pass `--offset`, `--length` and the first response's `--version`.
|
|
@@ -326,7 +339,8 @@ await orch.tick(); // the same, on the orchestrator's
|
|
|
326
339
|
job's `last_delivery_error`; a delivery that finally fails writes it.
|
|
327
340
|
- A fire whose worker has been silent for `HERMES_CRON_TIMEOUT` seconds (600 by default, 0 for no
|
|
328
341
|
limit, as in Hermes) is closed as a timeout, its failure delivered, and its runtime closed; before,
|
|
329
|
-
a hung fire kept its job from ever running again.
|
|
342
|
+
a hung fire kept its job from ever running again. A fire in flight when the daemon stopped or crashed
|
|
343
|
+
is not that: the next daemon reopens its session and tells it to carry the run on.
|
|
330
344
|
- A job's `model` (and a profile's, and `/model`'s) reaches the worker's launch (`claude --model`,
|
|
331
345
|
`codex app-server -c model=…`, `gemini -m`). For a harness with no such flag it is refused where it is
|
|
332
346
|
set (`/model`, `jobs.create|update`, `cronjob_create`) and again at the start, rather than run on
|
|
@@ -366,8 +380,16 @@ await orch.tick(); // the same, on the orchestrator's
|
|
|
366
380
|
decided goes to the job's `failure_deliver`, else `deliver`, else home (a webhook's: its
|
|
367
381
|
`deliver`). A chat's prompt is still relayed to the chat.
|
|
368
382
|
- A Codex worker asks only when started with an approval policy: where the profile keeps Hermes's
|
|
369
|
-
approvals (`approvals.mode` unset or not `off`) it starts with `approval_policy: untrusted
|
|
370
|
-
|
|
383
|
+
approvals (`approvals.mode` unset or not `off`) it starts with `approval_policy: untrusted` and
|
|
384
|
+
`approvals_reviewer: user` on its thread (`APPROVAL_POLICY`, `APPROVALS_REVIEWER`), so it asks before
|
|
385
|
+
every command and Hermes's rule answers, whatever reviewer the worker's config names. A headful
|
|
386
|
+
main takes its policy on Codex's CLI, which refuses `untrusted` (codex-cli 0.163.0), so it starts
|
|
387
|
+
with `--ask-for-approval on-request -c approvals_reviewer="user"` (`MAIN_ASKING_ARGUMENTS`): what Codex
|
|
388
|
+
asks about comes to a person. With approvals off it asks no person: it starts with
|
|
389
|
+
`approval_policy: on-request`, sandbox `danger-full-access` and Codex's auto-reviewer
|
|
390
|
+
(`approvals_reviewer = "auto_review"`) under an `auto_review.policy` that approves every request
|
|
391
|
+
(`OPEN_POLICY`, `OPEN_ARGUMENTS` in `activation.mjs`). It never starts with `never`, under which
|
|
392
|
+
Codex refuses outright any command its own check flags as dangerous.
|
|
371
393
|
|
|
372
394
|
```yaml
|
|
373
395
|
worker:
|
|
@@ -490,10 +512,14 @@ daemon start attaches the `rh2` channel for that profile. Uninstalling in RH2 re
|
|
|
490
512
|
the channel stops. A `platforms: rh2` block grants no integration authority and the runtime reports
|
|
491
513
|
it as unsupported. Native Hermes channel configuration remains the worker's configuration.
|
|
492
514
|
|
|
493
|
-
A mail agent the RH2 organization declares is one participant there, as its own agent principal
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
515
|
+
A mail agent the RH2 organization declares is one participant there, as its own agent principal, with one record per
|
|
516
|
+
Workplace in the home: the Agents → Room agents integration (`sdk/teams/workplace`, `launch.root`) provisions one for
|
|
517
|
+
every agent it provides, the token of its principal minted at RH2's agent-token door on that registration's own
|
|
518
|
+
installation (`rh2-agent.<agent id>@<Workplace host>`, renewed before it lapses, revoked when Teams ends the agent); one
|
|
519
|
+
can also be kept by hand. Every session of the agent acts through them (the `rh2` CLI and MCP read the record for the
|
|
520
|
+
Workplace each call goes to, from `RH2_AGENT_HOMES`), the agent's channel joins its Room's conversation on its record,
|
|
521
|
+
and orchestrator-launched sessions mount them as the `rh2-agent` MCP server, never in another agent's that shares its
|
|
522
|
+
name or profile. Kept by hand:
|
|
497
523
|
|
|
498
524
|
```sh
|
|
499
525
|
supercode orchestrator apps install rh2-agent --origin https://pilot.runhuman.com --organization volter \
|
|
@@ -613,7 +639,39 @@ lock names a live process on this machine, since this boot, is refused; a lock n
|
|
|
613
639
|
that is **gone**, or one another machine or an earlier boot wrote (a home on a volume the
|
|
614
640
|
sandbox before this one mounted), is the mark of a daemon that died, and the daemon dispatches
|
|
615
641
|
`restart` (§4.7) before it accepts anything. Nothing is signalled on the word of a lock from
|
|
616
|
-
elsewhere. `leaseIsLive` and `readLease` are
|
|
642
|
+
elsewhere. `leaseIsLive` and `readLease` are observations; PID liveness never
|
|
643
|
+
authorizes stop. A clean stop removes its own lock, so an ordinary start replays nothing.
|
|
644
|
+
|
|
645
|
+
`supercode orchestrator stop --root <home> [--json]` requests the daemon's
|
|
646
|
+
incarnation-bound local control socket. Its issued lease names the canonical
|
|
647
|
+
home, host, boot and random incarnation. The daemon checks that exact record
|
|
648
|
+
before accepting shutdown, joins its owners and removes only its own unchanged
|
|
649
|
+
lease. The CLI sends no PID signal and deletes no lease. Old leases without an
|
|
650
|
+
incarnation refuse; their actual process/service owner retains shutdown authority.
|
|
651
|
+
JSON results name the selected daemon and `stopped`, `failed` or `refused`.
|
|
652
|
+
Lost replies or a five-second deadline report an unknown outcome and retain the
|
|
653
|
+
incarnation; there is no automatic retry. Shutdown continues in the daemon.
|
|
654
|
+
Successful completion concerns that incarnation; a service manager may launch a
|
|
655
|
+
successor. See the [stop contract](../../docs/adr/0165-the-complete-orchestrator-entry-is-owned-by-node.md#stop-names-the-issued-daemon-2026-10-19-t_b37f54c7).
|
|
656
|
+
Foreground start reports that same actual daemon only after its live control
|
|
657
|
+
door confirms the invocation's issued lease. A gateway re-exec never turns the
|
|
658
|
+
launcher PID into an invented daemon lease; unconfirmed startup refuses.
|
|
659
|
+
Channel close retains the actual Chat SDK platform/state disconnect results,
|
|
660
|
+
including failures swallowed by `chat@4.39.0` itself, and joins the owned gateway
|
|
661
|
+
and its pending work. A failed channel keeps its token claim and yields failed
|
|
662
|
+
stop/exit status; it cannot become `stopped` merely because SDK shutdown fulfilled.
|
|
663
|
+
The daemon retains its lease on cleanup failure. Passive webhook bodies are
|
|
664
|
+
cancelled on close while complete-body handlers already admitted are joined.
|
|
665
|
+
Ingress closes before adapter connection waits. Already-admitted bodies keep
|
|
666
|
+
their processing authority through asynchronous signature verification and are
|
|
667
|
+
joined before the loop's stopping event. Direct-delivery HTTP 202 follows dispatch
|
|
668
|
+
and persistence. Worker-backed HTTP 202 also waits for actual runtime input
|
|
669
|
+
handoff; a memory queue alone receives no success acknowledgement. Stop refuses
|
|
670
|
+
unsent input with retryable HTTP 503, joins sends already in progress, drains
|
|
671
|
+
internal completion events and records interrupted handed turns before teardown.
|
|
672
|
+
Refusal removes only that unhanded input's queue/turn and dedup entry, permitting
|
|
673
|
+
retry. Concurrent matching delivery IDs share the handoff result. Intentional
|
|
674
|
+
refusal permits clean stop; actual processing/cleanup failures still fail close.
|
|
617
675
|
|
|
618
676
|
On `restart`: every pending obligation goes out again, and every binding that held a lease is
|
|
619
677
|
re-opened. The lease table is never persisted (§2.8), so a lease start records `runtime_id` on the
|
|
@@ -623,7 +681,11 @@ binding's residue; the loop tries `runtimes.attach_existing` where the harness's
|
|
|
623
681
|
mark without the notice — nobody is reading them.
|
|
624
682
|
|
|
625
683
|
The repo's Hermes fixture home (`crates/harness/fixtures/hermes_home`) is a ready home to drive it
|
|
626
|
-
against by hand
|
|
684
|
+
against by hand after the source tree's locked SDK dependencies are installed.
|
|
685
|
+
The source orchestrator selects that tree's Node front door; it requires no
|
|
686
|
+
debug native build. A source lock must explicitly bind Node at its exact root,
|
|
687
|
+
version and commit. Legacy native-bound declarations refuse until explicitly
|
|
688
|
+
re-pinned; no source lock or installed service is migrated by a command start.
|
|
627
689
|
|
|
628
690
|
## Board session cleanup
|
|
629
691
|
|
package/activation.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { mkdirSync, readFileSync, readdirSync, lstatSync, rmSync, writeFileSync,
|
|
|
8
8
|
import { CLAUDE_BRIDGE, linkTo, skillSources } from './hermes-home.mjs';
|
|
9
9
|
import { ownLogin } from './worker-route.mjs';
|
|
10
10
|
import { execFile } from 'node:child_process';
|
|
11
|
+
import { supercodeInvocation } from '@volter/supercode-harness-sdk/supercode-program';
|
|
11
12
|
|
|
12
13
|
/** Which env var each harness reads its config home from (supercode registry evidence). */
|
|
13
14
|
export const HOME_ENV = Object.freeze({
|
|
@@ -66,7 +67,8 @@ export function declareWorkerHome({ harness, env, cwd, log = () => {} }) {
|
|
|
66
67
|
const home = ['codex', 'claude-code'].includes(harness) ? env?.[HOME_ENV[harness]] : null;
|
|
67
68
|
if (!home || declaredHomes.has(`${harness}\0${home}\0${cwd}`)) return;
|
|
68
69
|
declaredHomes.add(`${harness}\0${home}\0${cwd}`);
|
|
69
|
-
|
|
70
|
+
const selected = supercodeInvocation(process.env.SUPERCODE_BIN || 'supercode', ['teams', 'roots', 'home', 'add', home, '--harness', harness, '--cwd', cwd, '--json']);
|
|
71
|
+
execFile(selected.program, selected.arguments, { timeout: 30_000 }, (error, _stdout, stderr) => {
|
|
70
72
|
if (!error) return;
|
|
71
73
|
declaredHomes.delete(`${harness}\0${home}\0${cwd}`);
|
|
72
74
|
log('warn', `worker home ${home} not declared to this machine's connector: ${String(stderr || error.message).trim().split('\n').at(-1)}`);
|
|
@@ -104,7 +106,10 @@ export function declareWorkerFolder({ cwd, root, profile = null, profileDirs = [
|
|
|
104
106
|
const held = coveredFolders.get(folder);
|
|
105
107
|
if (held && (held.until === undefined || Date.now() < held.until)) return held.answer;
|
|
106
108
|
const program = process.env.SUPERCODE_BIN || 'supercode';
|
|
107
|
-
const run = (args) =>
|
|
109
|
+
const run = (args) => {
|
|
110
|
+
const selected = supercodeInvocation(program, args);
|
|
111
|
+
return new Promise((done) => execFile(selected.program, selected.arguments, { timeout: 30_000 }, (error, stdout, stderr) => done({ error, stdout, stderr })));
|
|
112
|
+
};
|
|
108
113
|
const said = (answer) => String(answer.stderr ?? '').trim().split('\n')
|
|
109
114
|
.filter((line) => line && !/^\(node:\d+\) ExperimentalWarning: SQLite|^\(Use `node --trace-warnings/.test(line)).at(-1)
|
|
110
115
|
?? (answer.error?.code != null ? `exited ${answer.error.code}` : 'failed');
|
|
@@ -308,21 +313,36 @@ export function modelArguments(worker) {
|
|
|
308
313
|
* Where each harness takes an approval policy at start (the harness SDK's `approval_policy`, Codex's
|
|
309
314
|
* `approvalPolicy` on `thread/start` and `thread/resume`): `untrusted` asks before every command, and the
|
|
310
315
|
* orchestrator answers each ask with Hermes's approval rule (loop.mjs `onWorkerEvent`). Codex takes no such
|
|
311
|
-
* policy from its config (`approval_policy = "untrusted"` in `config.toml` stops its app server: measured)
|
|
316
|
+
* policy from its config (`approval_policy = "untrusted"` in `config.toml` stops its app server: measured); its
|
|
317
|
+
* app-server protocol still takes `untrusted`, its CLI does not (see `MAIN_ASKING_ARGUMENTS`).
|
|
312
318
|
*/
|
|
313
319
|
export const APPROVAL_POLICY = Object.freeze({ codex: 'untrusted' });
|
|
320
|
+
/**
|
|
321
|
+
* A headful main's CLI arguments with approvals on: a person answers. codex-cli 0.163.0 takes only `on-request` or
|
|
322
|
+
* `never` for `--ask-for-approval` (`untrusted` and `on-failure` are refused: measured), so the main runs `on-request`
|
|
323
|
+
* with its asks going to the user, never its auto-reviewer, whatever the person's config.toml says.
|
|
324
|
+
*/
|
|
325
|
+
export const MAIN_ASKING_ARGUMENTS = Object.freeze({ codex: ['--ask-for-approval', 'on-request', '-c', 'approvals_reviewer="user"'] });
|
|
314
326
|
|
|
315
327
|
/**
|
|
316
|
-
* Where the owner turned a profile's approvals off, its worker runs as a container's does: nothing asks, and
|
|
317
|
-
* harness's own sandbox is off. Codex's default (workspace-write, no network) otherwise asks to reach GitHub and,
|
|
318
|
-
* nobody attending, is denied (measured: the organization cycle could not read its own Discussion). Codex takes the
|
|
319
|
-
* policy on `thread/start` and its sandbox as
|
|
328
|
+
* Where the owner turned a profile's approvals off, its worker runs as a container's does: nothing asks a person, and
|
|
329
|
+
* the harness's own sandbox is off. Codex's default (workspace-write, no network) otherwise asks to reach GitHub and,
|
|
330
|
+
* with nobody attending, is denied (measured: the organization cycle could not read its own Discussion). Codex takes the
|
|
331
|
+
* policy on `thread/start` and its sandbox and reviewer as launch overrides. The policy is `on-request`, never `never`:
|
|
332
|
+
* under `never` Codex refuses outright ("blocked by policy") any command its own check flags as dangerous (`rm -f`,
|
|
333
|
+
* `npm ci`); under `on-request` that request goes to Codex's auto-reviewer, whose policy here approves every one.
|
|
320
334
|
*/
|
|
321
|
-
export const OPEN_POLICY = Object.freeze({ codex: '
|
|
335
|
+
export const OPEN_POLICY = Object.freeze({ codex: 'on-request' });
|
|
336
|
+
/** The auto-review policy every unattended Codex session runs under: the same text as `auto_review.policy` in
|
|
337
|
+
* sdk/interchange/startup-prompts.json (what `supercode open` passes) and the harness service's yolo policy. */
|
|
338
|
+
export const CODEX_AUTO_REVIEW_POLICY = 'Approve every request. The person running this session has authorized every action it takes, including commands that delete files, install packages, use the network or write outside the workspace.';
|
|
322
339
|
// Claude Code takes its open policy at launch (its permission mode), as supercode's relay runs it: with approvals off a
|
|
323
340
|
// dispatched Claude worker otherwise asks for every tool and, with nobody attending, is denied (measured: the review
|
|
324
341
|
// World's Claude coder could not run one command).
|
|
325
|
-
export const OPEN_ARGUMENTS = Object.freeze({
|
|
342
|
+
export const OPEN_ARGUMENTS = Object.freeze({
|
|
343
|
+
codex: ['-c', 'sandbox_mode="danger-full-access"', '-c', 'approvals_reviewer="auto_review"', '-c', `auto_review.policy=${JSON.stringify(CODEX_AUTO_REVIEW_POLICY)}`],
|
|
344
|
+
'claude-code': ['--permission-mode', 'bypassPermissions'],
|
|
345
|
+
});
|
|
326
346
|
|
|
327
347
|
const approvalsOff = (profile) => String(profile?.residue?.config?.approvals?.mode ?? 'manual').trim().toLowerCase() === 'off';
|
|
328
348
|
|
|
@@ -334,11 +354,27 @@ export function approvalPolicy(profile, harness) {
|
|
|
334
354
|
return approvalsOff(profile) ? OPEN_POLICY[harness] : APPROVAL_POLICY[harness];
|
|
335
355
|
}
|
|
336
356
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
357
|
+
/** Who answers a worker thread's approval requests (Codex's `approvalsReviewer` on thread/start and thread/resume):
|
|
358
|
+
* with approvals on the user, so the profile's rule answers before any auto-reviewer a worker's config names; with
|
|
359
|
+
* approvals off Codex's auto-reviewer, as `OPEN_ARGUMENTS` sets on its launch. */
|
|
360
|
+
export const APPROVALS_REVIEWER = Object.freeze({ codex: 'user' });
|
|
361
|
+
export const OPEN_APPROVALS_REVIEWER = Object.freeze({ codex: 'auto_review' });
|
|
362
|
+
export function approvalsReviewer(profile, harness) {
|
|
363
|
+
return (approvalsOff(profile) ? OPEN_APPROVALS_REVIEWER : APPROVALS_REVIEWER)[harness] ?? null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The launch arguments that go with a worker's approval policy where the profile's approvals are off: the harness's
|
|
367
|
+
* own sandbox off and (Codex) its auto-reviewer approving every request. */
|
|
368
|
+
export function modeArguments(profile, harness) {
|
|
339
369
|
return approvalsOff(profile) ? [...(OPEN_ARGUMENTS[harness] ?? [])] : [];
|
|
340
370
|
}
|
|
341
371
|
|
|
372
|
+
/** A headful Codex main's approval arguments on its CLI: the open policy and its arguments with approvals off, a
|
|
373
|
+
* person answering with approvals on (`MAIN_ASKING_ARGUMENTS`). */
|
|
374
|
+
export function mainApprovalArguments(profile) {
|
|
375
|
+
return approvalsOff(profile) ? ['--ask-for-approval', OPEN_POLICY.codex, ...OPEN_ARGUMENTS.codex] : [...MAIN_ASKING_ARGUMENTS.codex];
|
|
376
|
+
}
|
|
377
|
+
|
|
342
378
|
/**
|
|
343
379
|
* Hermes `resolve_turn_limit`: the profile's `agent.max_turns` as a positive
|
|
344
380
|
* cap, or null for unlimited (Hermes's default; `none`, `inf`, 0, -1 and
|
package/adapters/chat-sdk.mjs
CHANGED
|
@@ -179,6 +179,28 @@ export class ChatSdkAdapter {
|
|
|
179
179
|
this.emit = null;
|
|
180
180
|
/** thread_id -> the live `Thread`, so `send` posts through the SDK's own door. */
|
|
181
181
|
this.threads = new Map();
|
|
182
|
+
this.chats = new Set();
|
|
183
|
+
this.disconnects = new Map();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// chat@4.39.0 logs platform disconnect rejections and fulfills shutdown().
|
|
187
|
+
// Observe the actual lifecycle door, preserving its receiver and original
|
|
188
|
+
// rejection. SDK shutdown and our final join share ONE physical disconnect.
|
|
189
|
+
ownDisconnect(owner, name) {
|
|
190
|
+
if (!owner || typeof owner.disconnect !== 'function' || this.disconnects.has(owner)) return;
|
|
191
|
+
const original = owner.disconnect;
|
|
192
|
+
let result;
|
|
193
|
+
const disconnect = (...args) => result ??= Promise.resolve().then(() => original.apply(owner, args));
|
|
194
|
+
owner.disconnect = disconnect;
|
|
195
|
+
this.disconnects.set(owner, { name, disconnect });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
ownChat(chat) {
|
|
199
|
+
this.chats.add(chat);
|
|
200
|
+
this.ownDisconnect(this.sdkAdapter, `platform ${this.platform}`);
|
|
201
|
+
// Public SDK doors also cover an already-built Chat supplied by a client.
|
|
202
|
+
for (const name of Object.keys(chat.webhooks ?? {})) this.ownDisconnect(chat.getAdapter?.(name), `platform ${name}`);
|
|
203
|
+
this.ownDisconnect(chat.getState?.(), 'state');
|
|
182
204
|
}
|
|
183
205
|
|
|
184
206
|
/** The block's `extra:` map, whose keys the loader prefixes with `extra.` (the world codec's channel decoder). */
|
|
@@ -294,15 +316,20 @@ export class ChatSdkAdapter {
|
|
|
294
316
|
* and a follow-up in a subscribed thread all become one `inbound`.
|
|
295
317
|
*/
|
|
296
318
|
async connect(emit) {
|
|
319
|
+
if (this.closePromise) throw new Error('chat-sdk adapter is closing');
|
|
297
320
|
this.emit = emit;
|
|
298
321
|
if (!this.chat) {
|
|
299
322
|
try {
|
|
300
323
|
const { Chat } = await import(/* @vite-ignore */ CHAT_SDK_PACKAGE);
|
|
301
324
|
const sdkAdapter = this.sdkAdapter ?? (await this.buildSdkAdapter());
|
|
302
325
|
this.sdkAdapter = sdkAdapter;
|
|
326
|
+
// Retain custody even if constructing/initializing Chat fails.
|
|
327
|
+
this.ownDisconnect(sdkAdapter, `platform ${this.platform}`);
|
|
328
|
+
const state = await this.buildState();
|
|
329
|
+
this.ownDisconnect(state, 'state');
|
|
303
330
|
this.chat = new Chat({
|
|
304
331
|
adapters: { [sdkAdapter.name]: sdkAdapter },
|
|
305
|
-
state
|
|
332
|
+
state,
|
|
306
333
|
// F4 finding 11: the IR owns the queue, so the SDK's is off by default
|
|
307
334
|
concurrency: this.extra('concurrency', 'concurrent'),
|
|
308
335
|
logger: this.extra('logger', 'silent'),
|
|
@@ -320,6 +347,7 @@ export class ChatSdkAdapter {
|
|
|
320
347
|
return { state: 'down', error: error.message, configured: false };
|
|
321
348
|
}
|
|
322
349
|
}
|
|
350
|
+
this.ownChat(this.chat);
|
|
323
351
|
if (!this.handlersOn) {
|
|
324
352
|
// registered once: a retried connect must not answer each message twice
|
|
325
353
|
const handle = (thread, message) => this.handle(thread, message);
|
|
@@ -357,15 +385,25 @@ export class ChatSdkAdapter {
|
|
|
357
385
|
while (!abort.signal.aborted && typeof this.sdkAdapter?.startGatewayListener === 'function') {
|
|
358
386
|
const tasks = [];
|
|
359
387
|
const began = Date.now();
|
|
360
|
-
|
|
388
|
+
const errors = [];
|
|
361
389
|
try {
|
|
362
|
-
const res = await this.sdkAdapter.startGatewayListener({ waitUntil: (task) => {
|
|
363
|
-
|
|
364
|
-
|
|
390
|
+
const res = await this.sdkAdapter.startGatewayListener({ waitUntil: (task) => {
|
|
391
|
+
const pending = Promise.resolve(task);
|
|
392
|
+
void pending.catch(() => {}); // the slice owns and joins it below
|
|
393
|
+
tasks.push(pending);
|
|
394
|
+
} }, GATEWAY_SLICE_MS, abort.signal);
|
|
395
|
+
if (res && typeof res.status === 'number' && res.status >= 400) errors.push(new Error(`the listener answered ${res.status}`));
|
|
365
396
|
} catch (error) {
|
|
366
|
-
|
|
397
|
+
errors.push(error);
|
|
398
|
+
}
|
|
399
|
+
// Join waitUntil work even when the listener itself rejected.
|
|
400
|
+
for (const result of await Promise.allSettled(tasks)) if (result.status === 'rejected') errors.push(result.reason);
|
|
401
|
+
if (abort.signal.aborted) {
|
|
402
|
+
const failed = errors.filter(error => error !== abort.signal.reason && error?.cause !== abort.signal.reason);
|
|
403
|
+
if (failed.length) throw new AggregateError(failed, failed.map(error => String(error?.message ?? error)).join('; '));
|
|
404
|
+
break;
|
|
367
405
|
}
|
|
368
|
-
|
|
406
|
+
const why = errors.length ? errors.map(error => String(error?.message ?? error)).join('; ') : null;
|
|
369
407
|
// a slice that ended well before its time (a refused login, a
|
|
370
408
|
// dropped socket) is retried with backoff, never in a tight loop
|
|
371
409
|
if (why || Date.now() - began < GATEWAY_SLICE_MS * 0.9) {
|
|
@@ -380,7 +418,9 @@ export class ChatSdkAdapter {
|
|
|
380
418
|
} else backoff = 0;
|
|
381
419
|
}
|
|
382
420
|
};
|
|
383
|
-
|
|
421
|
+
this.gatewayTask = run();
|
|
422
|
+
// Keep the original rejected promise for close(), even before it joins.
|
|
423
|
+
void this.gatewayTask.catch(() => {});
|
|
384
424
|
}
|
|
385
425
|
|
|
386
426
|
/**
|
|
@@ -392,7 +432,8 @@ export class ChatSdkAdapter {
|
|
|
392
432
|
if (!this.emit) throw new Error('chat-sdk adapter is not connected');
|
|
393
433
|
const inbound = toInbound({ profile: this.profile, thread, message });
|
|
394
434
|
// one message reaches several handlers (a mention in a free channel): it is one turn
|
|
395
|
-
|
|
435
|
+
this.seen ??= new Set();
|
|
436
|
+
const seen = this.seen;
|
|
396
437
|
if (seen.has(inbound.message_id)) return undefined;
|
|
397
438
|
if (!this.admitDiscord(inbound, unmentioned)) return undefined;
|
|
398
439
|
seen.add(inbound.message_id);
|
|
@@ -408,11 +449,12 @@ export class ChatSdkAdapter {
|
|
|
408
449
|
*/
|
|
409
450
|
discordRules() {
|
|
410
451
|
if (this.rules !== undefined) return this.rules;
|
|
411
|
-
if (String(this.extra('slug', this.platform)) !== 'discord')
|
|
452
|
+
if (String(this.extra('slug', this.platform)) !== 'discord') { this.rules = null; return this.rules; }
|
|
412
453
|
const file = this.profileDir ? join(this.profileDir, '.env') : null;
|
|
413
454
|
const env = file && existsSync(file) ? parseDotenv(readFileSync(file, 'utf8')) : new Map();
|
|
414
455
|
const list = (name) => new Set(String(env.get(name) ?? process.env[name] ?? '').split(',').map((v) => v.trim()).filter(Boolean));
|
|
415
|
-
|
|
456
|
+
this.rules = { allowed: list('DISCORD_ALLOWED_CHANNELS'), ignored: list('DISCORD_IGNORED_CHANNELS'), free: list('DISCORD_FREE_RESPONSE_CHANNELS') };
|
|
457
|
+
return this.rules;
|
|
416
458
|
}
|
|
417
459
|
|
|
418
460
|
/** Whether Hermes's Discord adapter would take this message: DMs always; else not ignored, allowed, and mentioned unless free. */
|
|
@@ -471,12 +513,37 @@ export class ChatSdkAdapter {
|
|
|
471
513
|
return this.sdkAdapter.encodeThreadId({ guildId: '@me', channelId: chat });
|
|
472
514
|
}
|
|
473
515
|
|
|
474
|
-
|
|
516
|
+
close() {
|
|
517
|
+
this.closePromise ??= this.closeOwned();
|
|
518
|
+
return this.closePromise;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async closeOwned() {
|
|
475
522
|
this.gateway?.abort();
|
|
476
|
-
this.gateway = null;
|
|
477
523
|
this.emit = null;
|
|
478
524
|
this.threads.clear();
|
|
479
|
-
|
|
480
|
-
|
|
525
|
+
if (this.chat) this.ownChat(this.chat);
|
|
526
|
+
const sdkErrors = [];
|
|
527
|
+
for (const chat of this.chats) {
|
|
528
|
+
try { await chat.shutdown(); } catch (error) { sdkErrors.push(error); }
|
|
529
|
+
}
|
|
530
|
+
const errors = [], seen = new Set();
|
|
531
|
+
const attempt = async (name, work) => {
|
|
532
|
+
try { await work(); }
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (seen.has(error)) return;
|
|
535
|
+
seen.add(error);
|
|
536
|
+
errors.push(new Error(`${name}: ${String(error?.message ?? error)}`, { cause: error }));
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
// Rejoin the SDK's actual disconnects, including rejections it swallowed,
|
|
540
|
+
// and attempt any owner it never reached. No disconnect is issued twice.
|
|
541
|
+
for (const { name, disconnect } of this.disconnects.values()) await attempt(name, disconnect);
|
|
542
|
+
for (const error of sdkErrors) await attempt('SDK shutdown', () => { throw error; });
|
|
543
|
+
await attempt('gateway', () => this.gatewayTask);
|
|
544
|
+
this.gateway = null;
|
|
545
|
+
// A failed physical owner must not be presented as a released token claim.
|
|
546
|
+
if (!errors.length && this.tokenLock) await attempt('token lock', () => { releaseTokenLock(this.tokenLock); this.tokenLock = null; });
|
|
547
|
+
if (errors.length) throw new AggregateError(errors, errors.map(error => error.message).join('; '));
|
|
481
548
|
}
|
|
482
549
|
}
|
package/adapters/rh2.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// The RH2 adapter: a Runhuman Room conversation as an ordinary channel, the
|
|
2
2
|
// same three-method trait as every adapter — connect(emit) / send(target,
|
|
3
3
|
// content) / close(). The orchestrator holds an RH2 session (the bot
|
|
4
|
-
// principal's automation token),
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// principal's automation token), reads the Rooms it may read and answers
|
|
5
|
+
// every line its conversation participation asks for only in a Room it was
|
|
6
|
+
// brought into by the join command (RH2's `broughtIn` on the Room's members),
|
|
7
|
+
// elsewhere only a line naming it, the way it does in a Slack channel. RH2's own rule keeps a coding seat and this loop
|
|
7
8
|
// from answering each other: a seat is never woken by an agent's message, and
|
|
8
9
|
// this adapter turns only a person's line (kind `human`, or a trigger's
|
|
9
10
|
// `automation`) into an `inbound`.
|
|
10
11
|
//
|
|
11
12
|
// Doors (RH2 `/api/v3`, bearer + `x-rh2-organization`):
|
|
12
13
|
// GET /rooms the Rooms to follow
|
|
13
|
-
// GET /rooms/:room/view members (names), conversations (permissions, nextSequence)
|
|
14
|
+
// GET /rooms/:room/view members (names, broughtIn), conversations (permissions, nextSequence)
|
|
14
15
|
// GET /rooms/:room/conversations/:c/messages?after=&limit=
|
|
15
16
|
// POST /rooms/:room/conversations/:c/messages {body, correlationId, kind: "agent", parentMessageId?}
|
|
16
17
|
// PATCH /rooms/:room/conversations/:c/messages/:id {body} — a streamed reply grows, a progress line updates, by edit
|
|
@@ -49,8 +50,8 @@ export class Rh2Adapter {
|
|
|
49
50
|
this.running = false;
|
|
50
51
|
/** conversation id -> { roomId, after } */
|
|
51
52
|
this.cursors = new Map();
|
|
52
|
-
/** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, name>, seated: whether the
|
|
53
|
-
* delivers this Room's lines to this principal's own session } */
|
|
53
|
+
/** room id -> { name, names: Map<principalId, displayName>, conversations: Map<id, { name, answers }>, seated: whether the
|
|
54
|
+
* Workplace delivers this Room's lines to this principal's own session } */
|
|
54
55
|
this.rooms = new Map();
|
|
55
56
|
/** message id -> the root of the thread it is in (its own id for a root). */
|
|
56
57
|
this.threadOf = new Map();
|
|
@@ -159,6 +160,9 @@ export class Rh2Adapter {
|
|
|
159
160
|
const room = view.room ?? {};
|
|
160
161
|
if (room.state && room.state !== 'active') continue;
|
|
161
162
|
const names = new Map((view.members ?? []).filter((m) => m.principalId).map((m) => [String(m.principalId), String(m.displayName ?? m.principalId)]));
|
|
163
|
+
// Whether this principal is in the Room is RH2's own answer: brought in by the join command (`broughtIn`, runhuman-2
|
|
164
|
+
// ADR 0063). Only then does it take the lines its channel participation asks for; elsewhere, only those naming it.
|
|
165
|
+
const broughtIn = (view.members ?? []).some((m) => String(m.principalId) === this.principalId() && m.broughtIn === true);
|
|
162
166
|
// Where this principal's own seat carries a session, the Workplace delivers the Room's lines to that session itself
|
|
163
167
|
// (runhuman-2 ADR 0046 decision 4); this adapter then hands none of them on, so no line reaches it twice.
|
|
164
168
|
const seated = (view.members ?? []).some((m) => String(m.principalId) === this.principalId() && m.sessionBinding);
|
|
@@ -166,7 +170,7 @@ export class Rh2Adapter {
|
|
|
166
170
|
for (const conversation of view.conversations ?? []) {
|
|
167
171
|
if (!conversation.conversationId || !(conversation.permissions ?? []).includes('conversation.read')) continue;
|
|
168
172
|
const participation = (conversation.agentParticipants ?? []).find((p) => String(p.principalId) === this.principalId());
|
|
169
|
-
conversations.set(conversation.conversationId, { name: String(conversation.name ?? conversation.conversationId), answers: participation?.answers ?? null });
|
|
173
|
+
conversations.set(conversation.conversationId, { name: String(conversation.name ?? conversation.conversationId), answers: broughtIn ? participation?.answers ?? null : null });
|
|
170
174
|
live.add(conversation.conversationId);
|
|
171
175
|
if (!this.cursors.has(conversation.conversationId)) {
|
|
172
176
|
// At start, a conversation is read from its current end: no backlog is answered. One that becomes
|
package/agents.mjs
CHANGED
|
@@ -251,7 +251,7 @@ export function renameCardAgent(root, assignment, readableId) {
|
|
|
251
251
|
}
|
|
252
252
|
/** The id a home's mail agent runs as, read and never minted: its own declared identity (`agent/<name>`) where the home
|
|
253
253
|
* holds one, else its profile's (`profile/<profile>`), as localProfileAgent keys them, each at its current generation.
|
|
254
|
-
* Null when the home holds neither, or has no agent store.
|
|
254
|
+
* Null when the home holds neither, or has no agent store. */
|
|
255
255
|
export function homeAgentId(root,{org,server,name,profile=name}){
|
|
256
256
|
if(!existsSync(join(root,'agent-state.sqlite')))return null;
|
|
257
257
|
const db=store(root);
|
|
@@ -512,7 +512,3 @@ export function agentEnvironment(agent,{root,context,session=null,main=true}={})
|
|
|
512
512
|
...(session?{SUPERCODE_AGENT_SESSION:session}:{})};
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
-
/** A standing profile's conversation runtime; scheduled/card runs never select its conversation. */
|
|
516
|
-
export function localAgentConversation(root,org,id){
|
|
517
|
-
const db=store(root);try{return db.prepare('SELECT session_ref session,started_at,ended_at FROM agent_runs WHERE org=? AND agent_id=? AND conversation=1 ORDER BY (ended_at IS NULL) DESC,started_at DESC,rowid DESC LIMIT 1').get(org,id)??null;}finally{db.close();}
|
|
518
|
-
}
|