@botcord/daemon 0.2.20 → 0.2.22
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/dist/agent-workspace.d.ts +10 -4
- package/dist/agent-workspace.js +57 -19
- package/dist/daemon.js +28 -1
- package/dist/gateway/channels/botcord.js +25 -10
- package/dist/gateway/dispatcher.js +91 -12
- package/dist/index.js +11 -0
- package/dist/provision.d.ts +32 -0
- package/dist/provision.js +52 -1
- package/package.json +1 -1
- package/src/__tests__/agent-workspace.test.ts +45 -0
- package/src/__tests__/provision.test.ts +80 -0
- package/src/agent-workspace.ts +60 -19
- package/src/daemon.ts +31 -1
- package/src/gateway/channels/botcord.ts +41 -16
- package/src/gateway/dispatcher.ts +94 -11
- package/src/index.ts +15 -0
- package/src/provision.ts +86 -1
package/package.json
CHANGED
|
@@ -12,11 +12,13 @@ import os from "node:os";
|
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
+
agentCodexHomeDir,
|
|
15
16
|
agentHermesHomeDir,
|
|
16
17
|
agentHomeDir,
|
|
17
18
|
agentStateDir,
|
|
18
19
|
agentWorkspaceDir,
|
|
19
20
|
applyAgentIdentity,
|
|
21
|
+
ensureAgentCodexHome,
|
|
20
22
|
ensureAgentHermesWorkspace,
|
|
21
23
|
ensureAgentWorkspace,
|
|
22
24
|
} from "../agent-workspace.js";
|
|
@@ -105,6 +107,49 @@ describe("ensureAgentWorkspace", () => {
|
|
|
105
107
|
expect(reseeded).toContain("name: botcord");
|
|
106
108
|
});
|
|
107
109
|
|
|
110
|
+
it("seeds bundled skills under codex-home/skills/ so per-agent CODEX_HOME sees them", () => {
|
|
111
|
+
ensureAgentWorkspace("ag_codex_skills", {});
|
|
112
|
+
const skillsDir = path.join(agentCodexHomeDir("ag_codex_skills"), "skills");
|
|
113
|
+
expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
|
|
114
|
+
expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("re-seeds codex skills on subsequent ensureAgentCodexHome calls", () => {
|
|
118
|
+
ensureAgentCodexHome("ag_codex_reseed");
|
|
119
|
+
const skillFile = path.join(
|
|
120
|
+
agentCodexHomeDir("ag_codex_reseed"),
|
|
121
|
+
"skills",
|
|
122
|
+
"botcord",
|
|
123
|
+
"SKILL.md",
|
|
124
|
+
);
|
|
125
|
+
writeFileSync(skillFile, "stale content from a prior daemon version\n");
|
|
126
|
+
|
|
127
|
+
ensureAgentCodexHome("ag_codex_reseed");
|
|
128
|
+
|
|
129
|
+
const reseeded = readFileSync(skillFile, "utf8");
|
|
130
|
+
expect(reseeded).not.toBe("stale content from a prior daemon version\n");
|
|
131
|
+
expect(reseeded).toContain("name: botcord");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("seeds bundled skills under hermes-home/skills/ so per-agent HERMES_HOME sees them", () => {
|
|
135
|
+
const { hermesHome } = ensureAgentHermesWorkspace("ag_hermes_skills");
|
|
136
|
+
const skillsDir = path.join(hermesHome, "skills");
|
|
137
|
+
expect(existsSync(path.join(skillsDir, "botcord", "SKILL.md"))).toBe(true);
|
|
138
|
+
expect(existsSync(path.join(skillsDir, "botcord-user-guide", "SKILL.md"))).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("re-seeds hermes skills on subsequent ensureAgentHermesWorkspace calls", () => {
|
|
142
|
+
const { hermesHome } = ensureAgentHermesWorkspace("ag_hermes_reseed");
|
|
143
|
+
const skillFile = path.join(hermesHome, "skills", "botcord", "SKILL.md");
|
|
144
|
+
writeFileSync(skillFile, "stale content from a prior daemon version\n");
|
|
145
|
+
|
|
146
|
+
ensureAgentHermesWorkspace("ag_hermes_reseed");
|
|
147
|
+
|
|
148
|
+
const reseeded = readFileSync(skillFile, "utf8");
|
|
149
|
+
expect(reseeded).not.toBe("stale content from a prior daemon version\n");
|
|
150
|
+
expect(reseeded).toContain("name: botcord");
|
|
151
|
+
});
|
|
152
|
+
|
|
108
153
|
it("does not overwrite a user-modified memory.md on a second call", () => {
|
|
109
154
|
ensureAgentWorkspace("ag_keep", {});
|
|
110
155
|
const memoryPath = path.join(agentWorkspaceDir("ag_keep"), "memory.md");
|
|
@@ -778,6 +778,86 @@ describe("provision_agent seeds workspace + hot-adds managed route", () => {
|
|
|
778
778
|
expect(gw.listManagedRoutes()).toHaveLength(0);
|
|
779
779
|
});
|
|
780
780
|
});
|
|
781
|
+
|
|
782
|
+
// Regression: the daemon's per-agent caches (credentialPathByAgentId,
|
|
783
|
+
// hubUrlByAgentId, displayNameByAgent) used to be seeded only at boot.
|
|
784
|
+
// Hot-provisioning then left those caches missing the new agent until the
|
|
785
|
+
// next restart, and `room-context-fetcher` logged
|
|
786
|
+
// `daemon.room-context.no-credentials` on every turn. This contract test
|
|
787
|
+
// pins the install path: a successful provision MUST fire the hook with
|
|
788
|
+
// the credential file + hub URL + display name.
|
|
789
|
+
it("fires onAgentInstalled after a successful install so daemon caches stay warm", async () => {
|
|
790
|
+
await withSandboxHome(async ({ tmp, path: nodePath }) => {
|
|
791
|
+
const gw = makeFakeGateway();
|
|
792
|
+
const installed: Array<Record<string, unknown>> = [];
|
|
793
|
+
const provisioner = createProvisioner({
|
|
794
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
795
|
+
onAgentInstalled: (info) => {
|
|
796
|
+
installed.push({ ...info });
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
const privateKey = Buffer.alloc(32, 41).toString("base64");
|
|
800
|
+
const ack = await provisioner({
|
|
801
|
+
id: "req_hook",
|
|
802
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
803
|
+
params: {
|
|
804
|
+
// `name` only flows into `displayName` on the slow (daemon-register)
|
|
805
|
+
// path. Hub's fast path carries it via `credentials.displayName`.
|
|
806
|
+
runtime: "claude-code",
|
|
807
|
+
credentials: {
|
|
808
|
+
agentId: "ag_hook",
|
|
809
|
+
keyId: "k_hook",
|
|
810
|
+
privateKey,
|
|
811
|
+
hubUrl: "https://hub.example",
|
|
812
|
+
displayName: "zhejian's cc",
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
expect(ack.ok).toBe(true);
|
|
817
|
+
expect(installed).toHaveLength(1);
|
|
818
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_hook.json");
|
|
819
|
+
expect(installed[0]).toMatchObject({
|
|
820
|
+
agentId: "ag_hook",
|
|
821
|
+
credentialsFile: credFile,
|
|
822
|
+
hubUrl: "https://hub.example",
|
|
823
|
+
displayName: "zhejian's cc",
|
|
824
|
+
runtime: "claude-code",
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// The hook is best-effort wiring, not part of the install transaction.
|
|
830
|
+
// A throwing hook must not roll back the install (the agent is already
|
|
831
|
+
// on disk and in the gateway), and must not flip the control-frame ack
|
|
832
|
+
// to failure — the operator only sees a loud error log.
|
|
833
|
+
it("does not roll back the install when onAgentInstalled throws", async () => {
|
|
834
|
+
await withSandboxHome(async ({ tmp, fs, path: nodePath }) => {
|
|
835
|
+
const gw = makeFakeGateway();
|
|
836
|
+
const provisioner = createProvisioner({
|
|
837
|
+
gateway: gw as unknown as Parameters<typeof createProvisioner>[0]["gateway"],
|
|
838
|
+
onAgentInstalled: () => {
|
|
839
|
+
throw new Error("hook boom");
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
const privateKey = Buffer.alloc(32, 43).toString("base64");
|
|
843
|
+
const ack = await provisioner({
|
|
844
|
+
id: "req_hook_throws",
|
|
845
|
+
type: CONTROL_FRAME_TYPES.PROVISION_AGENT,
|
|
846
|
+
params: {
|
|
847
|
+
credentials: {
|
|
848
|
+
agentId: "ag_hookboom",
|
|
849
|
+
keyId: "k_hb",
|
|
850
|
+
privateKey,
|
|
851
|
+
hubUrl: "https://hub.example",
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
expect(ack.ok).toBe(true);
|
|
856
|
+
const credFile = nodePath.join(tmp, ".botcord", "credentials", "ag_hookboom.json");
|
|
857
|
+
expect(fs.existsSync(credFile)).toBe(true);
|
|
858
|
+
expect(gw.listManagedRoutes()).toHaveLength(1);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
781
861
|
});
|
|
782
862
|
|
|
783
863
|
describe("adoptDiscoveredOpenclawAgents", () => {
|
package/src/agent-workspace.ts
CHANGED
|
@@ -333,14 +333,18 @@ function isSymlink(p: string): boolean {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
/**
|
|
336
|
-
* Idempotently create the per-agent CODEX_HOME directory
|
|
337
|
-
* user's codex `auth.json` into it
|
|
338
|
-
*
|
|
336
|
+
* Idempotently create the per-agent CODEX_HOME directory, link the
|
|
337
|
+
* user's codex `auth.json` into it, and seed the bundled BotCord skills
|
|
338
|
+
* under `<dir>/skills/` so the codex runtime (which sees this as
|
|
339
|
+
* `CODEX_HOME`, not the user's `~/.codex`) can discover them. Does NOT
|
|
340
|
+
* write an initial `AGENTS.md` — the codex adapter writes it fresh per
|
|
341
|
+
* turn from `systemContext`.
|
|
339
342
|
*/
|
|
340
343
|
export function ensureAgentCodexHome(agentId: string): string {
|
|
341
344
|
const dir = agentCodexHomeDir(agentId);
|
|
342
345
|
mkdirTolerant(dir);
|
|
343
346
|
linkCodexAuth(dir);
|
|
347
|
+
seedCodexSkills(dir);
|
|
344
348
|
return dir;
|
|
345
349
|
}
|
|
346
350
|
|
|
@@ -348,7 +352,10 @@ export function ensureAgentCodexHome(agentId: string): string {
|
|
|
348
352
|
* Idempotently create the per-agent HERMES_HOME and HERMES workspace
|
|
349
353
|
* directories. Writes a stub `.env` inside HERMES_HOME so hermes-acp's
|
|
350
354
|
* `_load_env` does not log "No .env found" on every spawn; users can edit
|
|
351
|
-
* this file to add API keys / model overrides.
|
|
355
|
+
* this file to add API keys / model overrides. Also seeds the bundled
|
|
356
|
+
* BotCord skills under `<hermes-home>/skills/` so hermes-acp's skill
|
|
357
|
+
* loader (which only sees this isolated HERMES_HOME, not `~/.hermes`)
|
|
358
|
+
* can discover them.
|
|
352
359
|
*/
|
|
353
360
|
export function ensureAgentHermesWorkspace(agentId: string): {
|
|
354
361
|
hermesHome: string;
|
|
@@ -365,16 +372,21 @@ export function ensureAgentHermesWorkspace(agentId: string): {
|
|
|
365
372
|
);
|
|
366
373
|
seedHermesConfig(hermesHome);
|
|
367
374
|
mergeHermesProviderEnv(path.join(hermesHome, ".env"));
|
|
375
|
+
seedHermesAgentSkills(hermesHome);
|
|
368
376
|
return { hermesHome, hermesWorkspace };
|
|
369
377
|
}
|
|
370
378
|
|
|
371
379
|
/**
|
|
372
|
-
* Bundled
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
380
|
+
* Bundled BotCord skills shipped inside `@botcord/cli/skills/`. Skill
|
|
381
|
+
* content (SKILL.md + helper scripts) is runtime-agnostic; only the
|
|
382
|
+
* discovery path differs:
|
|
383
|
+
* - Claude Code: `<workspace>/.claude/skills/<name>/`
|
|
384
|
+
* - Codex: `<codex-home>/skills/<name>/`
|
|
385
|
+
* - Hermes: `<hermes-home>/skills/<name>/`
|
|
386
|
+
* Seeded fresh per `ensureAgent*` call (force-overwrite) so daemon
|
|
387
|
+
* upgrades propagate.
|
|
376
388
|
*/
|
|
377
|
-
const
|
|
389
|
+
const BUNDLED_SKILLS = ["botcord", "botcord-user-guide"] as const;
|
|
378
390
|
|
|
379
391
|
function resolveBundledCliSkillsRoot(): string | null {
|
|
380
392
|
try {
|
|
@@ -387,21 +399,19 @@ function resolveBundledCliSkillsRoot(): string | null {
|
|
|
387
399
|
}
|
|
388
400
|
|
|
389
401
|
/**
|
|
390
|
-
* Copy
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
402
|
+
* Copy bundled skill directories into `destSkillsDir`, force-overwriting
|
|
403
|
+
* any prior copy of each named skill. Other entries in `destSkillsDir`
|
|
404
|
+
* are left alone so user-authored skills survive. Best-effort: silently
|
|
405
|
+
* skips on copy failure or when the bundled CLI isn't resolvable.
|
|
394
406
|
*/
|
|
395
|
-
function
|
|
407
|
+
function copyBundledSkills(destSkillsDir: string): void {
|
|
396
408
|
const sourceRoot = resolveBundledCliSkillsRoot();
|
|
397
409
|
if (!sourceRoot) return;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
mkdirTolerant(skillsDir);
|
|
401
|
-
for (const name of BUNDLED_CC_SKILLS) {
|
|
410
|
+
mkdirTolerant(destSkillsDir);
|
|
411
|
+
for (const name of BUNDLED_SKILLS) {
|
|
402
412
|
const src = path.join(sourceRoot, name);
|
|
403
413
|
if (!existsSync(src)) continue;
|
|
404
|
-
const dst = path.join(
|
|
414
|
+
const dst = path.join(destSkillsDir, name);
|
|
405
415
|
try {
|
|
406
416
|
cpSync(src, dst, { recursive: true, force: true, dereference: true });
|
|
407
417
|
} catch {
|
|
@@ -410,6 +420,37 @@ function seedClaudeCodeSkills(workspace: string): void {
|
|
|
410
420
|
}
|
|
411
421
|
}
|
|
412
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Seed Claude Code's `.claude/skills/` discovery dir under the agent
|
|
425
|
+
* workspace. The `claude` adapter spawns with `--setting-sources project`
|
|
426
|
+
* so this dir is auto-discovered.
|
|
427
|
+
*/
|
|
428
|
+
function seedClaudeCodeSkills(workspace: string): void {
|
|
429
|
+
mkdirTolerant(path.join(workspace, ".claude"));
|
|
430
|
+
copyBundledSkills(path.join(workspace, ".claude", "skills"));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Seed Codex's `<CODEX_HOME>/skills/` discovery dir. The codex adapter
|
|
435
|
+
* sets `CODEX_HOME=<agent>/codex-home/`, isolating per-agent skills from
|
|
436
|
+
* the user's global `~/.codex/skills/` — so skills must be seeded here
|
|
437
|
+
* for Codex agents to discover them.
|
|
438
|
+
*/
|
|
439
|
+
function seedCodexSkills(codexHome: string): void {
|
|
440
|
+
copyBundledSkills(path.join(codexHome, "skills"));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Seed Hermes's `<HERMES_HOME>/skills/` discovery dir. hermes-acp's
|
|
445
|
+
* skill loader scans `$HERMES_HOME/skills/` (primary) plus any
|
|
446
|
+
* `skills.external_dirs` from config; the daemon points hermes-acp at
|
|
447
|
+
* the per-agent `<hermes-home>/`, so the user's global
|
|
448
|
+
* `~/.hermes/skills/` is invisible — bundled skills must be seeded here.
|
|
449
|
+
*/
|
|
450
|
+
function seedHermesAgentSkills(hermesHome: string): void {
|
|
451
|
+
copyBundledSkills(path.join(hermesHome, "skills"));
|
|
452
|
+
}
|
|
453
|
+
|
|
413
454
|
/**
|
|
414
455
|
* Idempotently create the agent's home / workspace / state directories and
|
|
415
456
|
* seed the workspace Markdown files. Existing files are never overwritten —
|
package/src/daemon.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
adoptDiscoveredOpenclawAgents,
|
|
28
28
|
collectRuntimeSnapshot,
|
|
29
29
|
createProvisioner,
|
|
30
|
+
type OnAgentInstalledHook,
|
|
30
31
|
} from "./provision.js";
|
|
31
32
|
import { openclawAutoProvisionEnabled } from "./openclaw-discovery.js";
|
|
32
33
|
import { SnapshotWriter } from "./snapshot-writer.js";
|
|
@@ -382,6 +383,34 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
382
383
|
});
|
|
383
384
|
};
|
|
384
385
|
|
|
386
|
+
// Boot-seeded per-agent caches (`credentialPathByAgentId`,
|
|
387
|
+
// `hubUrlByAgentId`, `displayNameByAgent`, `scBuilders`) are scoped to
|
|
388
|
+
// the agents present at startup. Without this hook, agents added later
|
|
389
|
+
// via `provision_agent` or openclaw-adoption stay missing from those
|
|
390
|
+
// caches until the next daemon restart — `room-context-fetcher` then
|
|
391
|
+
// logs `daemon.room-context.no-credentials` on every turn for the new
|
|
392
|
+
// agent and the system context loses its `[BotCord Room]` block (member
|
|
393
|
+
// names, rule, role).
|
|
394
|
+
const onAgentInstalled: OnAgentInstalledHook = (info) => {
|
|
395
|
+
// Re-provision (e.g. credential rotation) overwrites in place so the
|
|
396
|
+
// next room-context fetch re-loads the BotCordClient against the new
|
|
397
|
+
// credential file.
|
|
398
|
+
credentialPathByAgentId.set(info.agentId, info.credentialsFile);
|
|
399
|
+
if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
|
|
400
|
+
if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
|
|
401
|
+
if (!scBuilders.has(info.agentId)) {
|
|
402
|
+
scBuilders.set(
|
|
403
|
+
info.agentId,
|
|
404
|
+
createDaemonSystemContextBuilder({
|
|
405
|
+
agentId: info.agentId,
|
|
406
|
+
activityTracker,
|
|
407
|
+
roomContextBuilder,
|
|
408
|
+
loopRiskBuilder,
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
385
414
|
const gateway = new Gateway({
|
|
386
415
|
config: gwConfig,
|
|
387
416
|
sessionStorePath: opts.sessionStorePath ?? SESSIONS_PATH,
|
|
@@ -437,6 +466,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
437
466
|
const adopted = await adoptDiscoveredOpenclawAgents({
|
|
438
467
|
gateway,
|
|
439
468
|
cfg: opts.config,
|
|
469
|
+
onAgentInstalled,
|
|
440
470
|
});
|
|
441
471
|
if (
|
|
442
472
|
adopted.adopted.length > 0 ||
|
|
@@ -465,7 +495,7 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
|
|
|
465
495
|
userId: userAuth.current.userId,
|
|
466
496
|
hubUrl: userAuth.current.hubUrl,
|
|
467
497
|
});
|
|
468
|
-
const provisioner = createProvisioner({ gateway, policyResolver });
|
|
498
|
+
const provisioner = createProvisioner({ gateway, policyResolver, onAgentInstalled });
|
|
469
499
|
controlChannel = new ControlChannel({
|
|
470
500
|
auth: userAuth,
|
|
471
501
|
handle: provisioner,
|
|
@@ -30,7 +30,11 @@ const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
|
30
30
|
const DM_ROOM_PREFIX = "rm_dm_";
|
|
31
31
|
const INBOX_POLL_LIMIT = 50;
|
|
32
32
|
|
|
33
|
-
type InboxDrainTrigger =
|
|
33
|
+
type InboxDrainTrigger =
|
|
34
|
+
| "ws_auth_ok"
|
|
35
|
+
| "ws_inbox_update"
|
|
36
|
+
| "coalesced_inbox_update"
|
|
37
|
+
| "has_more_continue";
|
|
34
38
|
|
|
35
39
|
/** Minimal surface the adapter needs from `BotCordClient`. Matches the subset used at runtime. */
|
|
36
40
|
export interface BotCordChannelClient {
|
|
@@ -309,7 +313,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
309
313
|
emit: (env: GatewayInboundEnvelope) => Promise<void>,
|
|
310
314
|
log: GatewayLogger,
|
|
311
315
|
trigger: InboxDrainTrigger,
|
|
312
|
-
): Promise<
|
|
316
|
+
): Promise<{ hasMore: boolean }> {
|
|
313
317
|
const startedAt = Date.now();
|
|
314
318
|
const resp = await client.pollInbox({ limit: INBOX_POLL_LIMIT, ack: false });
|
|
315
319
|
const msgs = resp.messages ?? [];
|
|
@@ -334,7 +338,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
334
338
|
const eligible: InboxMessage[] = [];
|
|
335
339
|
if (msgs.length === 0) {
|
|
336
340
|
logDrain();
|
|
337
|
-
|
|
341
|
+
// Defensive: if Hub returns 0 messages, refuse to honor has_more=true.
|
|
342
|
+
// A stuck cursor on the Hub side could otherwise produce an unbounded
|
|
343
|
+
// poll loop here (count=0 with has_more=true on every iteration).
|
|
344
|
+
return { hasMore: false };
|
|
338
345
|
}
|
|
339
346
|
|
|
340
347
|
// First pass: ack duplicates/skipped messages so Hub stops requeueing,
|
|
@@ -370,7 +377,7 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
370
377
|
|
|
371
378
|
if (eligible.length === 0) {
|
|
372
379
|
logDrain();
|
|
373
|
-
return;
|
|
380
|
+
return { hasMore: Boolean(resp.has_more) };
|
|
374
381
|
}
|
|
375
382
|
|
|
376
383
|
// Group by `(room_id, topic)`. Insertion order is the poll order, so
|
|
@@ -384,6 +391,13 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
384
391
|
else groups.set(key, [msg]);
|
|
385
392
|
}
|
|
386
393
|
|
|
394
|
+
// Emit groups in parallel: each `(room_id, topic)` group is an independent
|
|
395
|
+
// conversation thread, and the dispatcher already keys its per-turn queue
|
|
396
|
+
// by `(channel, accountId, roomId, threadId)` (see `buildQueueKey` in
|
|
397
|
+
// dispatcher.ts). Awaiting groups serially here forced a slow turn in
|
|
398
|
+
// room A to block room B's turn from starting; running them concurrently
|
|
399
|
+
// lets the dispatcher's per-room queues actually run in parallel.
|
|
400
|
+
const emitTasks: Promise<void>[] = [];
|
|
387
401
|
for (const group of groups.values()) {
|
|
388
402
|
const normalized = normalizeInboxBatch(group, {
|
|
389
403
|
channelId: options.id,
|
|
@@ -409,17 +423,23 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
409
423
|
},
|
|
410
424
|
},
|
|
411
425
|
};
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
426
|
+
emitTasks.push(
|
|
427
|
+
emit(envelope).then(
|
|
428
|
+
() => {
|
|
429
|
+
emittedGroups += 1;
|
|
430
|
+
},
|
|
431
|
+
(err) => {
|
|
432
|
+
log.error("botcord emit threw", {
|
|
433
|
+
hubMsgIds: hubIds,
|
|
434
|
+
err: String(err),
|
|
435
|
+
});
|
|
436
|
+
},
|
|
437
|
+
),
|
|
438
|
+
);
|
|
421
439
|
}
|
|
440
|
+
await Promise.all(emitTasks);
|
|
422
441
|
logDrain();
|
|
442
|
+
return { hasMore: Boolean(resp.has_more) };
|
|
423
443
|
}
|
|
424
444
|
|
|
425
445
|
function startWsLoop(
|
|
@@ -470,11 +490,16 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
470
490
|
processing = true;
|
|
471
491
|
try {
|
|
472
492
|
let currentTrigger = trigger;
|
|
493
|
+
let hasMore = false;
|
|
473
494
|
do {
|
|
474
495
|
pendingUpdate = false;
|
|
475
|
-
await drainInbox(client, emit, log, currentTrigger);
|
|
476
|
-
|
|
477
|
-
|
|
496
|
+
const result = await drainInbox(client, emit, log, currentTrigger);
|
|
497
|
+
hasMore = result.hasMore;
|
|
498
|
+
// Prefer `has_more_continue` when this iteration is chained because
|
|
499
|
+
// the previous poll capped at INBOX_POLL_LIMIT — distinguishes a
|
|
500
|
+
// backlog drain from a coalesced ws_inbox_update drain in logs.
|
|
501
|
+
currentTrigger = hasMore ? "has_more_continue" : "coalesced_inbox_update";
|
|
502
|
+
} while ((pendingUpdate || hasMore) && running);
|
|
478
503
|
} catch (err) {
|
|
479
504
|
log.error("botcord inbox drain failed", { err: String(err) });
|
|
480
505
|
} finally {
|
|
@@ -324,6 +324,18 @@ export class Dispatcher {
|
|
|
324
324
|
// grounded turnId for any downstream attention_skipped / dropped / etc.
|
|
325
325
|
this.emitInbound(turnId, msg);
|
|
326
326
|
|
|
327
|
+
this.log.info("dispatcher: inbound received", {
|
|
328
|
+
agentId: msg.accountId,
|
|
329
|
+
roomId: msg.conversation.id,
|
|
330
|
+
topicId: msg.conversation.threadId ?? null,
|
|
331
|
+
turnId,
|
|
332
|
+
messageId: msg.id,
|
|
333
|
+
senderId: msg.sender.id,
|
|
334
|
+
senderKind: msg.sender.kind,
|
|
335
|
+
mode,
|
|
336
|
+
textPreview: logPreview(rawText),
|
|
337
|
+
});
|
|
338
|
+
|
|
327
339
|
// Notify the optional observer (activity tracking, metrics, etc.) as soon
|
|
328
340
|
// as the dispatcher owns the message. Errors must not abort the turn.
|
|
329
341
|
if (this.onInbound) {
|
|
@@ -448,7 +460,14 @@ export class Dispatcher {
|
|
|
448
460
|
const myGen = q.cancelGen;
|
|
449
461
|
const prev = q.current;
|
|
450
462
|
if (prev) {
|
|
451
|
-
this.log.info("dispatcher: cancelling previous turn", {
|
|
463
|
+
this.log.info("dispatcher: cancelling previous turn", {
|
|
464
|
+
agentId: msg.accountId,
|
|
465
|
+
roomId: msg.conversation.id,
|
|
466
|
+
topicId: msg.conversation.threadId ?? null,
|
|
467
|
+
turnId,
|
|
468
|
+
prevTurnId: prev.turnId,
|
|
469
|
+
queueKey,
|
|
470
|
+
});
|
|
452
471
|
// Record the supersede BEFORE aborting so the prev turn's finalize sees
|
|
453
472
|
// the abort reason (TurnSupersededError) and skips writing turn_error.
|
|
454
473
|
this.transcript.write({
|
|
@@ -469,7 +488,13 @@ export class Dispatcher {
|
|
|
469
488
|
// already fired its own abort + runTurn, or be mid-await itself. If so,
|
|
470
489
|
// drop out silently — the newest turn is the only one that should run.
|
|
471
490
|
if (myGen !== q.cancelGen) {
|
|
472
|
-
this.log.info("dispatcher: cancel-previous superseded", {
|
|
491
|
+
this.log.info("dispatcher: cancel-previous superseded", {
|
|
492
|
+
agentId: msg.accountId,
|
|
493
|
+
roomId: msg.conversation.id,
|
|
494
|
+
topicId: msg.conversation.threadId ?? null,
|
|
495
|
+
turnId,
|
|
496
|
+
queueKey,
|
|
497
|
+
});
|
|
473
498
|
// We didn't run the turn; emit dropped so the caller's inbound has a
|
|
474
499
|
// matching path record. supersededBy is unknown at this layer (newer
|
|
475
500
|
// arrival owns its own bump) — leave null.
|
|
@@ -738,10 +763,25 @@ export class Dispatcher {
|
|
|
738
763
|
this.transcript.write(dispatched);
|
|
739
764
|
}
|
|
740
765
|
|
|
766
|
+
this.log.info("dispatcher: dispatched to runtime", {
|
|
767
|
+
agentId: msg.accountId,
|
|
768
|
+
roomId: msg.conversation.id,
|
|
769
|
+
topicId: msg.conversation.threadId ?? null,
|
|
770
|
+
turnId,
|
|
771
|
+
runtime: route.runtime,
|
|
772
|
+
cwd: route.cwd,
|
|
773
|
+
...(mergedFromTurnIds.length > 0 ? { mergedFromTurns: mergedFromTurnIds.length } : {}),
|
|
774
|
+
composedPreview: logPreview(text),
|
|
775
|
+
});
|
|
776
|
+
|
|
741
777
|
// Hard-cap turn with a timeout.
|
|
742
778
|
const timer = setTimeout(() => {
|
|
743
779
|
slot.timedOut = true;
|
|
744
780
|
this.log.warn("dispatcher: turn timed out", {
|
|
781
|
+
agentId: msg.accountId,
|
|
782
|
+
roomId: msg.conversation.id,
|
|
783
|
+
topicId: msg.conversation.threadId ?? null,
|
|
784
|
+
turnId,
|
|
745
785
|
queueKey,
|
|
746
786
|
timeoutMs: this.turnTimeoutMs,
|
|
747
787
|
});
|
|
@@ -1072,11 +1112,14 @@ export class Dispatcher {
|
|
|
1072
1112
|
text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
|
|
1073
1113
|
replyTo: msg.id,
|
|
1074
1114
|
traceId: msg.trace?.id ?? null,
|
|
1075
|
-
});
|
|
1115
|
+
}, turnId);
|
|
1076
1116
|
} else {
|
|
1077
1117
|
this.log.warn("dispatcher: timeout in non-owner-chat room — error reply suppressed", {
|
|
1118
|
+
agentId: msg.accountId,
|
|
1119
|
+
roomId: msg.conversation.id,
|
|
1120
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1121
|
+
turnId,
|
|
1078
1122
|
queueKey,
|
|
1079
|
-
conversationId: msg.conversation.id,
|
|
1080
1123
|
timeoutMs: this.turnTimeoutMs,
|
|
1081
1124
|
});
|
|
1082
1125
|
}
|
|
@@ -1086,6 +1129,10 @@ export class Dispatcher {
|
|
|
1086
1129
|
if (threw) {
|
|
1087
1130
|
const errMsg = threw instanceof Error ? threw.message : String(threw);
|
|
1088
1131
|
this.log.error("dispatcher: runtime threw", {
|
|
1132
|
+
agentId: msg.accountId,
|
|
1133
|
+
roomId: msg.conversation.id,
|
|
1134
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1135
|
+
turnId,
|
|
1089
1136
|
queueKey,
|
|
1090
1137
|
runtime: route.runtime,
|
|
1091
1138
|
error: errMsg,
|
|
@@ -1110,11 +1157,14 @@ export class Dispatcher {
|
|
|
1110
1157
|
text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
|
|
1111
1158
|
replyTo: msg.id,
|
|
1112
1159
|
traceId: msg.trace?.id ?? null,
|
|
1113
|
-
});
|
|
1160
|
+
}, turnId);
|
|
1114
1161
|
} else {
|
|
1115
1162
|
this.log.warn("dispatcher: runtime error in non-owner-chat room — error reply suppressed", {
|
|
1163
|
+
agentId: msg.accountId,
|
|
1164
|
+
roomId: msg.conversation.id,
|
|
1165
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1166
|
+
turnId,
|
|
1116
1167
|
queueKey,
|
|
1117
|
-
conversationId: msg.conversation.id,
|
|
1118
1168
|
});
|
|
1119
1169
|
}
|
|
1120
1170
|
return;
|
|
@@ -1201,8 +1251,11 @@ export class Dispatcher {
|
|
|
1201
1251
|
this.log.debug(
|
|
1202
1252
|
"dispatcher: non-owner-chat — discarding result.text (agent must use botcord_send)",
|
|
1203
1253
|
{
|
|
1254
|
+
agentId: msg.accountId,
|
|
1255
|
+
roomId: msg.conversation.id,
|
|
1256
|
+
topicId: msg.conversation.threadId ?? null,
|
|
1257
|
+
turnId,
|
|
1204
1258
|
queueKey,
|
|
1205
|
-
conversationId: msg.conversation.id,
|
|
1206
1259
|
replyTextLen: replyText.length,
|
|
1207
1260
|
},
|
|
1208
1261
|
);
|
|
@@ -1236,7 +1289,7 @@ export class Dispatcher {
|
|
|
1236
1289
|
text: replyText,
|
|
1237
1290
|
replyTo: msg.id,
|
|
1238
1291
|
traceId: msg.trace?.id ?? null,
|
|
1239
|
-
});
|
|
1292
|
+
}, turnId);
|
|
1240
1293
|
this.emitOutbound({
|
|
1241
1294
|
turnId,
|
|
1242
1295
|
msg,
|
|
@@ -1268,14 +1321,18 @@ export class Dispatcher {
|
|
|
1268
1321
|
private async sendReply(
|
|
1269
1322
|
channel: ChannelAdapter,
|
|
1270
1323
|
outbound: GatewayOutboundMessage,
|
|
1324
|
+
turnId?: string,
|
|
1271
1325
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
1272
1326
|
try {
|
|
1273
1327
|
await channel.send({ message: outbound, log: this.log });
|
|
1274
1328
|
} catch (err) {
|
|
1275
1329
|
const error = err instanceof Error ? err.message : String(err);
|
|
1276
1330
|
this.log.warn("dispatcher: channel.send failed", {
|
|
1331
|
+
agentId: outbound.accountId,
|
|
1332
|
+
roomId: outbound.conversationId,
|
|
1333
|
+
topicId: outbound.threadId ?? null,
|
|
1334
|
+
...(turnId ? { turnId } : {}),
|
|
1277
1335
|
channel: outbound.channel,
|
|
1278
|
-
conversationId: outbound.conversationId,
|
|
1279
1336
|
error,
|
|
1280
1337
|
});
|
|
1281
1338
|
return { ok: false, error };
|
|
@@ -1285,7 +1342,10 @@ export class Dispatcher {
|
|
|
1285
1342
|
await this.onOutbound(outbound);
|
|
1286
1343
|
} catch (err) {
|
|
1287
1344
|
this.log.warn("dispatcher: onOutbound threw — continuing", {
|
|
1288
|
-
|
|
1345
|
+
agentId: outbound.accountId,
|
|
1346
|
+
roomId: outbound.conversationId,
|
|
1347
|
+
topicId: outbound.threadId ?? null,
|
|
1348
|
+
...(turnId ? { turnId } : {}),
|
|
1289
1349
|
error: err instanceof Error ? err.message : String(err),
|
|
1290
1350
|
});
|
|
1291
1351
|
}
|
|
@@ -1333,6 +1393,19 @@ export class Dispatcher {
|
|
|
1333
1393
|
deliveryReason: string | null;
|
|
1334
1394
|
blocks: TranscriptBlockSummary[];
|
|
1335
1395
|
}): void {
|
|
1396
|
+
const durationMs = Date.now() - args.startedAt;
|
|
1397
|
+
this.log.info("dispatcher: outbound emitted", {
|
|
1398
|
+
agentId: args.msg.accountId,
|
|
1399
|
+
roomId: args.msg.conversation.id,
|
|
1400
|
+
topicId: args.msg.conversation.threadId ?? null,
|
|
1401
|
+
turnId: args.turnId,
|
|
1402
|
+
runtime: args.runtime,
|
|
1403
|
+
deliveryStatus: args.deliveryStatus,
|
|
1404
|
+
...(args.deliveryReason ? { deliveryReason: args.deliveryReason } : {}),
|
|
1405
|
+
durationMs,
|
|
1406
|
+
replyPreview: logPreview(args.finalText.text),
|
|
1407
|
+
...(typeof args.costUsd === "number" ? { costUsd: args.costUsd } : {}),
|
|
1408
|
+
});
|
|
1336
1409
|
if (!this.transcript.enabled) return;
|
|
1337
1410
|
const rec: import("./transcript.js").OutboundTranscriptRecord = {
|
|
1338
1411
|
ts: nowIso(),
|
|
@@ -1343,7 +1416,7 @@ export class Dispatcher {
|
|
|
1343
1416
|
topicId: args.msg.conversation.threadId ?? null,
|
|
1344
1417
|
runtime: args.runtime,
|
|
1345
1418
|
runtimeSessionId: args.runtimeSessionId,
|
|
1346
|
-
durationMs
|
|
1419
|
+
durationMs,
|
|
1347
1420
|
finalText: args.finalText.text,
|
|
1348
1421
|
deliveryStatus: args.deliveryStatus,
|
|
1349
1422
|
deliveryReason: args.deliveryReason,
|
|
@@ -1397,3 +1470,13 @@ function resolveQueueMode(
|
|
|
1397
1470
|
function truncate(s: string, max: number): string {
|
|
1398
1471
|
return s.length <= max ? s : s.slice(0, max) + "…";
|
|
1399
1472
|
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Single-line preview of a multi-line user/agent text, capped at `max` chars.
|
|
1476
|
+
* Used to embed message/reply previews in daemon.log lines without bloating
|
|
1477
|
+
* each line into multi-line JSON. Full text lives in transcripts.
|
|
1478
|
+
*/
|
|
1479
|
+
function logPreview(s: string, max: number = 120): string {
|
|
1480
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
1481
|
+
return flat.length <= max ? flat : flat.slice(0, max) + "…";
|
|
1482
|
+
}
|