Skip to content

Commit ae54d81

Browse files
committed
fix(gateway): bound traced channel startup handoff
1 parent 1bd10cf commit ae54d81

3 files changed

Lines changed: 60 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717

1818
### Fixes
1919

20+
- Gateway/channels: hand off traced channel account startup outside the startup diagnostic phase so long-lived channel tasks do not keep liveness warnings pinned to channel startup. Refs #82398.
2021
- Gateway/WebChat: route image attachments through a configured vision-capable `imageModel` plan before inlining images, and carry that image-model fallback chain through runtime retries. (#82524) Thanks @frankekn.
2122
- WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott.
2223
- Codex app-server: limit canonical OpenAI Codex app-server attribution rewrites to local transcript and trajectory records, leaving runtime/tool routing on the selected OpenAI model metadata so OpenAI API-key backup profiles keep their billing path.

src/gateway/server-channels.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,12 +733,53 @@ describe("server-channels auto restart", () => {
733733
const manager = createManager({ startupTrace });
734734

735735
await manager.startChannels();
736+
expect(startAccount).not.toHaveBeenCalled();
737+
738+
await vi.advanceTimersByTimeAsync(0);
739+
await flushMicrotasks();
736740

737741
const names = measureMock.mock.calls.map(([name]) => name);
738742
expect(names).toContain("channels.discord.start");
739743
expect(names).toContain("channels.discord.list-accounts");
740744
expect(names).toContain("channels.discord.runtime");
741745
expect(names).toContain("channels.discord.approval-bootstrap");
746+
expect(names).toContain("channels.discord.start-account-handoff");
747+
expect(startAccount).toHaveBeenCalledTimes(1);
748+
});
749+
750+
it("ends startup trace spans before long-lived channel account tasks settle", async () => {
751+
const activeNames = new Set<string>();
752+
const measuredNames: string[] = [];
753+
const startupTrace = {
754+
measure: async <T>(name: string, run: () => T | Promise<T>) => {
755+
activeNames.add(name);
756+
measuredNames.push(name);
757+
try {
758+
return await run();
759+
} finally {
760+
activeNames.delete(name);
761+
}
762+
},
763+
};
764+
const channelTask = createDeferred();
765+
const startAccount = vi.fn(() => channelTask.promise);
766+
767+
installTestRegistry(createTestPlugin({ startAccount }));
768+
const manager = createManager({ startupTrace });
769+
770+
await manager.startChannels();
771+
await vi.advanceTimersByTimeAsync(0);
772+
await flushMicrotasks();
773+
774+
expect(startAccount).toHaveBeenCalledTimes(1);
775+
expect(measuredNames).toContain("channels.discord.start-account-handoff");
776+
expect(activeNames.has("channels.discord.start-account-handoff")).toBe(false);
777+
expect(
778+
manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]?.running,
779+
).toBe(true);
780+
781+
channelTask.resolve();
782+
await flushMicrotasks();
742783
});
743784

744785
it("limits whole-channel account startup fanout to four", async () => {

src/gateway/server-channels.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ const MAX_RESTART_ATTEMPTS = 10;
3939
const CHANNEL_STOP_ABORT_TIMEOUT_MS = 5_000;
4040
const CHANNEL_STARTUP_CONCURRENCY = 4;
4141

42+
function waitForChannelStartupHandoff(): Promise<void> {
43+
return new Promise((resolve) => {
44+
const handle = setImmediate(resolve);
45+
handle.unref?.();
46+
});
47+
}
48+
4249
type ChannelRuntimeStore = {
4350
aborts: Map<string, AbortController>;
4451
starting: Map<string, Promise<void>>;
@@ -512,9 +519,13 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
512519
lastError: null,
513520
reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0,
514521
});
515-
const task = Promise.resolve().then(() =>
516-
measureStartup(`channels.${channelId}.start-account`, () =>
517-
startAccount({
522+
const task = Promise.resolve().then(async () => {
523+
if (startupTrace) {
524+
await waitForChannelStartupHandoff();
525+
}
526+
let startAccountTask: ReturnType<typeof startAccount> | undefined;
527+
await measureStartup(`channels.${channelId}.start-account-handoff`, () => {
528+
startAccountTask = startAccount({
518529
cfg,
519530
accountId: id,
520531
account,
@@ -524,9 +535,10 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
524535
getStatus: () => getRuntime(channelId, id),
525536
setStatus: (next) => setRuntime(channelId, id, next),
526537
...(channelRuntimeForTask ? { channelRuntime: channelRuntimeForTask } : {}),
527-
}),
528-
),
529-
);
538+
});
539+
});
540+
await startAccountTask;
541+
});
530542
const trackedPromise = task
531543
.then(() => {
532544
if (abort.signal.aborted || manuallyStopped.has(rKey)) {

0 commit comments

Comments
 (0)