@alignfirst/service-openclaw-plugin 0.3.1

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.
@@ -0,0 +1,239 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readSourceContext } from "./routing.js";
3
+ import { asRecord, nonempty } from "./values.js";
4
+ const RECEIPT_TTL_MS = 60 * 60 * 1_000;
5
+ const CONTEXT_LIMIT = 10_000;
6
+ const RECEIPT_WAIT_MS = 1_000;
7
+ const RECEIPT_POLL_MS = 25;
8
+ export function createReceiptCoordinator(params) {
9
+ const now = params.now ?? Date.now;
10
+ const contexts = new Map();
11
+ const observationErrors = new Map();
12
+ return {
13
+ captureContext(context) {
14
+ const source = readSourceContext(context, params.configuration);
15
+ if (!source)
16
+ return;
17
+ contexts.set(contextKey(source.sessionKey, source.sessionId), { source, capturedAt: now() });
18
+ pruneContexts(contexts, now());
19
+ },
20
+ observe(event, context) {
21
+ const source = readCachedSource(contexts, context, now());
22
+ if (!source || event.toolName !== "message" || event.error !== undefined)
23
+ return;
24
+ const surface = params.configuration.channelSurfaces[source.channelId];
25
+ const receipt = parseDeliveryReceipt({
26
+ event,
27
+ source,
28
+ surface,
29
+ now: now(),
30
+ });
31
+ if (!receipt)
32
+ return;
33
+ if (typeof receipt === "string") {
34
+ const threadId = readObservedThreadId(event, surface);
35
+ params.logger.debug?.(`thread-handoff receipt rejected: surface=${surface} session=${source.sessionKey} thread=${threadId ?? "-"} reason=${receipt}`);
36
+ return;
37
+ }
38
+ const key = lookupKey(receipt.sessionKey, receipt.sessionId, receipt.threadId);
39
+ try {
40
+ params.getStore().insertReceipt(receipt, now());
41
+ observationErrors.delete(key);
42
+ }
43
+ catch (error) {
44
+ const storedError = error instanceof Error ? error : new Error(String(error));
45
+ observationErrors.set(key, storedError);
46
+ params.logger.error(`thread-handoff receipt persistence failed: ${storedError.message}`);
47
+ }
48
+ },
49
+ async waitForReceipt(identity) {
50
+ const key = lookupKey(identity.sourceSessionKey, identity.sourceSessionId, identity.threadId);
51
+ const deadline = now() + RECEIPT_WAIT_MS;
52
+ while (true) {
53
+ const error = observationErrors.get(key);
54
+ if (error)
55
+ throw error;
56
+ const receipt = params.getStore().findReceipt(identity, now());
57
+ if (receipt || now() >= deadline)
58
+ return receipt;
59
+ await delay(RECEIPT_POLL_MS);
60
+ }
61
+ },
62
+ };
63
+ }
64
+ function readCachedSource(contexts, context, now) {
65
+ const sessionKey = nonempty(context.sessionKey);
66
+ const sessionId = nonempty(context.sessionId);
67
+ if (!sessionKey || !sessionId)
68
+ return;
69
+ pruneContexts(contexts, now);
70
+ return contexts.get(contextKey(sessionKey, sessionId))?.source;
71
+ }
72
+ function pruneContexts(contexts, now) {
73
+ for (const [key, value] of contexts) {
74
+ if (value.capturedAt + RECEIPT_TTL_MS <= now)
75
+ contexts.delete(key);
76
+ }
77
+ while (contexts.size > CONTEXT_LIMIT) {
78
+ const oldest = contexts.keys().next().value;
79
+ if (typeof oldest !== "string")
80
+ return;
81
+ contexts.delete(oldest);
82
+ }
83
+ }
84
+ function parseDeliveryReceipt(params) {
85
+ if (params.surface === "slack" && params.event.params.action === "send") {
86
+ return parseSlackReceipt(params);
87
+ }
88
+ if (params.surface === "discord" && params.event.params.action === "thread-create") {
89
+ return parseDiscordReceipt(params);
90
+ }
91
+ return;
92
+ }
93
+ function parseSlackReceipt(params) {
94
+ const { event, source } = params;
95
+ const threadId = nonempty(event.params.threadId);
96
+ if (!threadId)
97
+ return "missingThread";
98
+ const starterText = readStarter(event.params);
99
+ if (starterText === undefined)
100
+ return "missingStarter";
101
+ const destination = readDestination(event.params);
102
+ const details = readResultDetails(event.result);
103
+ const result = asRecord(details?.result);
104
+ const target = asRecord(result?.target);
105
+ const targetKind = nonempty(target?.kind);
106
+ const targetId = nonempty(target?.id);
107
+ if (targetKind === undefined || targetId === undefined)
108
+ return "unsupportedResultShape";
109
+ if (!matchesConversation(destination, source.parentConversationId) ||
110
+ targetKind !== "channel" ||
111
+ targetId.toLowerCase() !== source.parentConversationId.toLowerCase()) {
112
+ return "channelMismatch";
113
+ }
114
+ const messageDelivery = asRecord(details?.messageDelivery);
115
+ if (nonempty(details?.deliveryStatus) !== "sent" ||
116
+ nonempty(messageDelivery?.status) !== "settled") {
117
+ return "notSent";
118
+ }
119
+ if (messageDelivery?.partialDelivery !== false)
120
+ return "partialDelivery";
121
+ const starterMessageId = nonempty(result?.messageId);
122
+ if (!starterMessageId)
123
+ return "missingMessageId";
124
+ const deliveryReceipt = asRecord(result?.receipt);
125
+ const returnedThreadId = nonempty(deliveryReceipt?.threadId);
126
+ if (returnedThreadId !== undefined && returnedThreadId !== threadId)
127
+ return "threadMismatch";
128
+ if (!accountMatches(event.params, source.accountId))
129
+ return "accountMismatch";
130
+ return createReceipt({
131
+ source,
132
+ threadId,
133
+ starterText,
134
+ starterMessageId,
135
+ toolCallId: event.toolCallId,
136
+ now: params.now,
137
+ });
138
+ }
139
+ function parseDiscordReceipt(params) {
140
+ const { event, source } = params;
141
+ const starterText = readStarter(event.params);
142
+ const destination = readDestination(event.params);
143
+ const anchorMessageId = nonempty(event.params.messageId);
144
+ const details = readResultDetails(event.result);
145
+ const thread = asRecord(details?.thread);
146
+ const threadId = nonempty(thread?.id);
147
+ if (!threadId)
148
+ return "missingThread";
149
+ if (starterText === undefined)
150
+ return "missingStarter";
151
+ if (!anchorMessageId)
152
+ return "missingMessageId";
153
+ const returnedParent = nonempty(thread?.parent_id) ?? nonempty(thread?.parentId);
154
+ if (!matchesConversation(destination, source.parentConversationId) ||
155
+ (returnedParent !== undefined &&
156
+ returnedParent.toLowerCase() !== source.parentConversationId.toLowerCase())) {
157
+ return "channelMismatch";
158
+ }
159
+ if (details?.ok !== true)
160
+ return "notSent";
161
+ if (details.partial === true)
162
+ return "partialDelivery";
163
+ if (!accountMatches(event.params, source.accountId))
164
+ return "accountMismatch";
165
+ return createReceipt({
166
+ source,
167
+ threadId,
168
+ starterText,
169
+ toolCallId: event.toolCallId,
170
+ now: params.now,
171
+ });
172
+ }
173
+ function readObservedThreadId(event, surface) {
174
+ if (surface === "slack")
175
+ return nonempty(event.params.threadId);
176
+ if (surface !== "discord")
177
+ return;
178
+ const details = readResultDetails(event.result);
179
+ const thread = asRecord(details?.thread);
180
+ return nonempty(thread?.id);
181
+ }
182
+ function createReceipt(params) {
183
+ const identity = JSON.stringify([
184
+ params.source.sessionKey,
185
+ params.source.sessionId,
186
+ params.source.channelId,
187
+ params.source.accountId ?? null,
188
+ params.source.parentConversationId,
189
+ params.threadId,
190
+ params.starterMessageId ?? null,
191
+ params.starterText,
192
+ params.toolCallId ?? null,
193
+ ]);
194
+ return {
195
+ schemaVersion: 1,
196
+ receiptKey: createHash("sha256").update(identity).digest("hex"),
197
+ ...params.source,
198
+ threadId: params.threadId,
199
+ ...(params.starterMessageId ? { starterMessageId: params.starterMessageId } : {}),
200
+ starterText: params.starterText,
201
+ ...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
202
+ createdAt: params.now,
203
+ expiresAt: params.now + RECEIPT_TTL_MS,
204
+ };
205
+ }
206
+ function readResultDetails(value) {
207
+ const result = asRecord(value);
208
+ return asRecord(result?.details) ?? result;
209
+ }
210
+ function readStarter(params) {
211
+ for (const key of ["message", "text", "content"]) {
212
+ const value = params[key];
213
+ if (typeof value === "string" && value.trim().length > 0)
214
+ return value;
215
+ }
216
+ return;
217
+ }
218
+ function readDestination(params) {
219
+ return nonempty(params.to) ?? nonempty(params.target) ?? nonempty(params.channelId);
220
+ }
221
+ function matchesConversation(destination, expected) {
222
+ if (!destination)
223
+ return false;
224
+ const normalized = destination.replace(/^channel:/i, "");
225
+ return normalized.toLowerCase() === expected.toLowerCase();
226
+ }
227
+ function accountMatches(params, accountId) {
228
+ const supplied = nonempty(params.accountId);
229
+ return supplied === undefined || supplied === accountId;
230
+ }
231
+ function contextKey(sessionKey, sessionId) {
232
+ return `${sessionKey}\u0000${sessionId}`;
233
+ }
234
+ function lookupKey(sessionKey, sessionId, threadId) {
235
+ return `${contextKey(sessionKey, sessionId)}\u0000${threadId}`;
236
+ }
237
+ async function delay(milliseconds) {
238
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
239
+ }
@@ -0,0 +1,18 @@
1
+ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { DeliveryReceipt, DeliveryRoute, HandoffRecord, PluginConfiguration, SourceContext } from "./types.js";
3
+ export interface ResolvedHandoffRoute {
4
+ routeKey: string;
5
+ targetSessionKey: string;
6
+ deliveryContext: DeliveryRoute;
7
+ }
8
+ export declare function readSourceContext(context: OpenClawPluginToolContext, configuration: PluginConfiguration): SourceContext | undefined;
9
+ export declare function readConversationId(target: string | undefined): string | undefined;
10
+ export declare function assertSupportedSource(source: SourceContext, configuration: PluginConfiguration): "slack" | "discord";
11
+ export declare function resolveHandoffRoute(source: SourceContext, threadId: string, surface: "slack" | "discord"): ResolvedHandoffRoute;
12
+ export declare function createHandoffRecord(params: {
13
+ receipt: DeliveryReceipt;
14
+ route: ResolvedHandoffRoute;
15
+ handoffId: string;
16
+ createdAt: number;
17
+ }): HandoffRecord;
18
+ export declare function evidenceMatches(record: HandoffRecord, receipt: DeliveryReceipt): boolean;
@@ -0,0 +1,147 @@
1
+ import { buildAgentSessionKey, isAcpSessionKey, isCronSessionKey, isSubagentSessionKey, parseAgentSessionKey, parseThreadSessionSuffix, resolveThreadSessionKeys, } from "openclaw/plugin-sdk/routing";
2
+ import { HandoffError } from "./errors.js";
3
+ import { nonempty } from "./values.js";
4
+ export function readSourceContext(context, configuration) {
5
+ const deliveryContext = readDeliveryContext(context.deliveryContext);
6
+ const agentId = nonempty(context.agentId);
7
+ const sessionKey = nonempty(context.sessionKey);
8
+ const sessionId = nonempty(context.sessionId);
9
+ const channelId = nonempty(context.messageChannel) ?? deliveryContext?.channel;
10
+ const parentConversationId = nonempty(context.nativeChannelId) ?? readConversationId(deliveryContext?.to);
11
+ const accountId = nonempty(context.agentAccountId) ?? deliveryContext?.accountId;
12
+ if (!agentId || !sessionKey || !sessionId || !channelId || !parentConversationId)
13
+ return;
14
+ if (!configuration.channelSurfaces[channelId])
15
+ return;
16
+ return {
17
+ agentId,
18
+ sessionKey,
19
+ sessionId,
20
+ channelId,
21
+ ...(accountId ? { accountId } : {}),
22
+ parentConversationId,
23
+ ...(deliveryContext ? { deliveryContext } : {}),
24
+ };
25
+ }
26
+ export function readConversationId(target) {
27
+ if (!target)
28
+ return;
29
+ const thread = /^thread:([^/]+)\/.+/u.exec(target);
30
+ if (thread)
31
+ return thread[1];
32
+ const routed = /^(?:channel|group|dm):(.+)$/u.exec(target);
33
+ return routed?.[1];
34
+ }
35
+ function readDeliveryContext(value) {
36
+ if (!value || typeof value !== "object" || Array.isArray(value))
37
+ return;
38
+ const channel = nonempty(Reflect.get(value, "channel"));
39
+ const to = nonempty(Reflect.get(value, "to"));
40
+ if (!channel || !to)
41
+ return;
42
+ const accountId = nonempty(Reflect.get(value, "accountId"));
43
+ const rawThreadId = Reflect.get(value, "threadId");
44
+ const threadId = typeof rawThreadId === "number" ? String(rawThreadId) : nonempty(rawThreadId);
45
+ return {
46
+ channel,
47
+ to,
48
+ ...(accountId ? { accountId } : {}),
49
+ ...(threadId ? { threadId } : {}),
50
+ };
51
+ }
52
+ export function assertSupportedSource(source, configuration) {
53
+ const surface = configuration.channelSurfaces[source.channelId];
54
+ if (!surface) {
55
+ throw new HandoffError("unsupportedContext", "This channel is not configured for handoff.");
56
+ }
57
+ if (isSubagentSessionKey(source.sessionKey) ||
58
+ isAcpSessionKey(source.sessionKey) ||
59
+ isCronSessionKey(source.sessionKey)) {
60
+ throw new HandoffError("unsupportedContext", "Thread handoff requires a regular channel session.");
61
+ }
62
+ const parsed = parseAgentSessionKey(source.sessionKey);
63
+ const thread = parseThreadSessionSuffix(source.sessionKey);
64
+ if (!parsed ||
65
+ parsed.agentId !== source.agentId.toLowerCase() ||
66
+ thread.threadId !== undefined ||
67
+ !matchesChannelRoute(parsed.rest, source.channelId, source.parentConversationId)) {
68
+ throw new HandoffError("unsupportedContext", "Thread handoff requires a distinct parent-channel session.");
69
+ }
70
+ return surface;
71
+ }
72
+ function matchesChannelRoute(rest, channelId, parentConversationId) {
73
+ const prefix = `${channelId.toLowerCase()}:channel:`;
74
+ if (!rest.startsWith(prefix))
75
+ return false;
76
+ const peerId = rest.slice(prefix.length);
77
+ const parent = parentConversationId.toLowerCase();
78
+ return peerId === parent || peerId.endsWith(`:channel:${parent}`);
79
+ }
80
+ export function resolveHandoffRoute(source, threadId, surface) {
81
+ const targetSessionKey = surface === "slack"
82
+ ? resolveThreadSessionKeys({ baseSessionKey: source.sessionKey, threadId }).sessionKey
83
+ : buildAgentSessionKey({
84
+ agentId: source.agentId,
85
+ channel: source.channelId,
86
+ accountId: source.accountId,
87
+ peer: { kind: "channel", id: threadId },
88
+ });
89
+ if (targetSessionKey === source.sessionKey) {
90
+ throw new HandoffError("invalidTarget", "The target must be a distinct thread session.");
91
+ }
92
+ const deliveryContext = surface === "slack"
93
+ ? {
94
+ channel: source.channelId,
95
+ to: `channel:${source.parentConversationId}`,
96
+ threadId,
97
+ ...(source.accountId ? { accountId: source.accountId } : {}),
98
+ }
99
+ : {
100
+ channel: source.channelId,
101
+ to: `channel:${threadId}`,
102
+ ...(source.accountId ? { accountId: source.accountId } : {}),
103
+ };
104
+ return {
105
+ routeKey: JSON.stringify([
106
+ source.agentId,
107
+ source.channelId,
108
+ source.accountId ?? null,
109
+ targetSessionKey,
110
+ ]),
111
+ targetSessionKey,
112
+ deliveryContext,
113
+ };
114
+ }
115
+ export function createHandoffRecord(params) {
116
+ return {
117
+ schemaVersion: 2,
118
+ routeKey: params.route.routeKey,
119
+ handoffId: params.handoffId,
120
+ targetSessionKey: params.route.targetSessionKey,
121
+ agentId: params.receipt.agentId,
122
+ sessionKey: params.receipt.sessionKey,
123
+ sessionId: params.receipt.sessionId,
124
+ channelId: params.receipt.channelId,
125
+ ...(params.receipt.accountId ? { accountId: params.receipt.accountId } : {}),
126
+ parentConversationId: params.receipt.parentConversationId,
127
+ threadId: params.receipt.threadId,
128
+ ...(params.receipt.starterMessageId
129
+ ? { starterMessageId: params.receipt.starterMessageId }
130
+ : {}),
131
+ starterText: params.receipt.starterText,
132
+ deliveryContext: params.route.deliveryContext,
133
+ createdAt: params.createdAt,
134
+ attemptCount: 0,
135
+ state: "pending",
136
+ };
137
+ }
138
+ export function evidenceMatches(record, receipt) {
139
+ return (record.sessionKey === receipt.sessionKey &&
140
+ record.sessionId === receipt.sessionId &&
141
+ record.channelId === receipt.channelId &&
142
+ record.accountId === receipt.accountId &&
143
+ record.parentConversationId === receipt.parentConversationId &&
144
+ record.threadId === receipt.threadId &&
145
+ record.starterMessageId === receipt.starterMessageId &&
146
+ record.starterText === receipt.starterText);
147
+ }
@@ -0,0 +1,9 @@
1
+ export interface RunIdCache {
2
+ remember(context: {
3
+ sessionKey?: string;
4
+ sessionId?: string;
5
+ runId?: string;
6
+ }): void;
7
+ read(sessionKey: string, sessionId: string): string | undefined;
8
+ }
9
+ export declare function createRunIdCache(now?: () => number): RunIdCache;
@@ -0,0 +1,39 @@
1
+ const RUN_ID_TTL_MS = 60 * 60 * 1_000;
2
+ const RUN_ID_LIMIT = 10_000;
3
+ export function createRunIdCache(now = Date.now) {
4
+ const entries = new Map();
5
+ return {
6
+ remember(context) {
7
+ if (context.sessionKey === undefined ||
8
+ context.sessionId === undefined ||
9
+ context.runId === undefined) {
10
+ return;
11
+ }
12
+ entries.set(contextKey(context.sessionKey, context.sessionId), {
13
+ runId: context.runId,
14
+ capturedAt: now(),
15
+ });
16
+ pruneEntries(entries, now());
17
+ },
18
+ read(sessionKey, sessionId) {
19
+ const capturedAt = now();
20
+ pruneEntries(entries, capturedAt);
21
+ return entries.get(contextKey(sessionKey, sessionId))?.runId;
22
+ },
23
+ };
24
+ }
25
+ function pruneEntries(entries, now) {
26
+ for (const [key, value] of entries) {
27
+ if (value.capturedAt + RUN_ID_TTL_MS <= now)
28
+ entries.delete(key);
29
+ }
30
+ while (entries.size > RUN_ID_LIMIT) {
31
+ const oldest = entries.keys().next().value;
32
+ if (typeof oldest !== "string")
33
+ return;
34
+ entries.delete(oldest);
35
+ }
36
+ }
37
+ function contextKey(sessionKey, sessionId) {
38
+ return `${sessionKey}\u0000${sessionId}`;
39
+ }
@@ -0,0 +1,21 @@
1
+ import type { OpenClawPluginApi, PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
2
+ import type { HandoffStore } from "./state.js";
3
+ import type { HandoffRecord, PluginConfiguration } from "./types.js";
4
+ export declare const MAX_ATTEMPTS = 10;
5
+ export declare const ATTEMPT_SPACING_MS = 60000;
6
+ export interface HandoffService {
7
+ startTurn(record: HandoffRecord): Promise<void>;
8
+ runForTarget<T>(targetSessionKey: string, operation: () => Promise<T>): Promise<T>;
9
+ start(): Promise<void>;
10
+ stop(): Promise<void>;
11
+ }
12
+ export interface HandoffServiceParams {
13
+ runtime: OpenClawPluginApi["runtime"];
14
+ configuration: PluginConfiguration;
15
+ getStore: () => HandoffStore;
16
+ logger: PluginLogger;
17
+ now?: () => number;
18
+ scanIntervalMs?: number;
19
+ attemptSpacingMs?: number;
20
+ }
21
+ export declare function createHandoffService(params: HandoffServiceParams): HandoffService;
@@ -0,0 +1,159 @@
1
+ import { AsyncResource } from "node:async_hooks";
2
+ import { dispatchTurn } from "./dispatch.js";
3
+ import { errorMessage } from "./values.js";
4
+ export const MAX_ATTEMPTS = 10;
5
+ export const ATTEMPT_SPACING_MS = 60_000;
6
+ const SCAN_INTERVAL_MS = 30_000;
7
+ export function createHandoffService(params) {
8
+ const now = params.now ?? Date.now;
9
+ const scanIntervalMs = params.scanIntervalMs ?? SCAN_INTERVAL_MS;
10
+ const attemptSpacingMs = params.attemptSpacingMs ?? ATTEMPT_SPACING_MS;
11
+ const dispatchResource = new AsyncResource("alignfirst.thread-handoff.dispatch");
12
+ const targetWork = new Map();
13
+ const inFlight = new Map();
14
+ let timer;
15
+ let scan;
16
+ let stopped = true;
17
+ // The host closes the store once `stop()` resolves. A dispatch that settles afterwards must not
18
+ // call `getStore()`, which would reopen the database and leak the connection.
19
+ let storeReleased = false;
20
+ const service = {
21
+ async startTurn(record) {
22
+ if (inFlight.has(record.handoffId))
23
+ return;
24
+ const starting = Promise.resolve();
25
+ inFlight.set(record.handoffId, starting);
26
+ try {
27
+ const updated = params.getStore().recordAttempt(record.routeKey, now());
28
+ if (updated?.state !== "pending")
29
+ return;
30
+ const surface = params.configuration.channelSurfaces[updated.channelId];
31
+ const turn = surface
32
+ ? dispatchResource.runInAsyncScope(() => dispatchTurn({
33
+ runtime: params.runtime,
34
+ logger: params.logger,
35
+ request: buildSeedRequest(updated, surface),
36
+ }))
37
+ : Promise.reject(new Error(`Channel ${updated.channelId} is not configured for handoff.`));
38
+ const completion = finishAttempt(params, updated, turn, now, () => storeReleased)
39
+ .finally(() => {
40
+ if (inFlight.get(updated.handoffId) === completion) {
41
+ inFlight.delete(updated.handoffId);
42
+ }
43
+ })
44
+ .catch((error) => {
45
+ reportCompletionFailure(params.logger, updated.handoffId, error);
46
+ });
47
+ inFlight.set(updated.handoffId, completion);
48
+ }
49
+ finally {
50
+ if (inFlight.get(record.handoffId) === starting)
51
+ inFlight.delete(record.handoffId);
52
+ }
53
+ },
54
+ runForTarget: (targetSessionKey, operation) => serializeTarget(targetWork, targetSessionKey, operation),
55
+ async start() {
56
+ if (!stopped)
57
+ return;
58
+ stopped = false;
59
+ storeReleased = false;
60
+ params.getStore();
61
+ await recoverPending(service, params, inFlight, now(), attemptSpacingMs);
62
+ timer = setInterval(() => {
63
+ if (scan || stopped)
64
+ return;
65
+ scan = recoverPending(service, params, inFlight, now(), attemptSpacingMs)
66
+ .catch((error) => params.logger.error(`thread-handoff recovery failed: ${errorMessage(error)}`))
67
+ .finally(() => {
68
+ scan = undefined;
69
+ });
70
+ }, scanIntervalMs);
71
+ timer.unref();
72
+ },
73
+ async stop() {
74
+ stopped = true;
75
+ storeReleased = true;
76
+ if (timer)
77
+ clearInterval(timer);
78
+ timer = undefined;
79
+ await scan;
80
+ },
81
+ };
82
+ return service;
83
+ }
84
+ async function recoverPending(service, params, inFlight, now, attemptSpacingMs) {
85
+ const records = params.getStore().listPending({
86
+ now,
87
+ spacingMs: attemptSpacingMs,
88
+ maxAttempts: MAX_ATTEMPTS,
89
+ });
90
+ await Promise.all(records
91
+ .filter((record) => !inFlight.has(record.handoffId))
92
+ .map((record) => service
93
+ .runForTarget(record.targetSessionKey, async () => {
94
+ const current = params.getStore().findHandoffByRoute(record.routeKey);
95
+ if (current?.state !== "pending")
96
+ return;
97
+ await service.startTurn(current);
98
+ })
99
+ .catch((error) => params.logger.error(`thread-handoff recovery failed for ${record.handoffId}: ${errorMessage(error)}`))));
100
+ }
101
+ function buildSeedRequest(record, surface) {
102
+ return {
103
+ sessionKey: record.targetSessionKey,
104
+ agentId: record.agentId,
105
+ channelId: record.channelId,
106
+ surface,
107
+ route: record.deliveryContext,
108
+ parentConversationId: record.parentConversationId,
109
+ message: "Take over this thread.",
110
+ messageId: `thread-handoff:${record.handoffId}:${record.attemptCount}`,
111
+ };
112
+ }
113
+ async function finishAttempt(params, record, turn, now, storeReleased) {
114
+ let failure;
115
+ try {
116
+ await turn;
117
+ }
118
+ catch (error) {
119
+ failure = error;
120
+ }
121
+ try {
122
+ if (failure === undefined) {
123
+ params.logger.debug?.(`thread-handoff ${record.handoffId} start attempt ${record.attemptCount} completed`);
124
+ }
125
+ else {
126
+ params.logger.warn(`thread-handoff ${record.handoffId} start attempt ${record.attemptCount} failed: ${errorMessage(failure)}`);
127
+ }
128
+ // A stale `lastAttemptedAt` only makes the record eligible for recovery sooner after a restart.
129
+ if (storeReleased())
130
+ return;
131
+ const current = params.getStore().recordAttemptEnd(record.routeKey, now());
132
+ if (current?.state === "pending" && current.attemptCount >= MAX_ATTEMPTS) {
133
+ params.logger.warn(`thread-handoff ${record.handoffId} stays pending after ${current.attemptCount} start attempts; it remains claimable by the next human message in the thread; inspect it with: openclaw thread-handoff list`);
134
+ }
135
+ }
136
+ catch (error) {
137
+ reportCompletionFailure(params.logger, record.handoffId, error);
138
+ }
139
+ }
140
+ function reportCompletionFailure(logger, handoffId, error) {
141
+ try {
142
+ logger.error(`thread-handoff ${handoffId} could not finish its start attempt: ${errorMessage(error)}`);
143
+ }
144
+ catch {
145
+ // A detached completion chain must not reject when its final error report fails.
146
+ }
147
+ }
148
+ async function serializeTarget(work, targetSessionKey, operation) {
149
+ const previous = work.get(targetSessionKey) ?? Promise.resolve();
150
+ const current = previous.catch(() => undefined).then(operation);
151
+ work.set(targetSessionKey, current);
152
+ try {
153
+ return await current;
154
+ }
155
+ finally {
156
+ if (work.get(targetSessionKey) === current)
157
+ work.delete(targetSessionKey);
158
+ }
159
+ }
@@ -0,0 +1,42 @@
1
+ import type { DeliveryReceipt, HandoffRecord, ReceiptIdentity } from "./types.js";
2
+ export interface HandoffStore {
3
+ insertReceipt(receipt: DeliveryReceipt, now: number): void;
4
+ findReceipt(identity: ReceiptIdentity, now: number): DeliveryReceipt | undefined;
5
+ listReceipts(now: number): DeliveryReceipt[];
6
+ findHandoffByRoute(routeKey: string): HandoffRecord | undefined;
7
+ insertHandoff(record: HandoffRecord): {
8
+ inserted: boolean;
9
+ record: HandoffRecord;
10
+ };
11
+ claimHandoff(identity: ClaimIdentity, now: number): ClaimResult;
12
+ recordAttempt(routeKey: string, startedAt: number): HandoffRecord | undefined;
13
+ recordAttemptEnd(routeKey: string, endedAt: number): HandoffRecord | undefined;
14
+ listPending(query: PendingQuery): HandoffRecord[];
15
+ listHandoffs(): HandoffRecord[];
16
+ retireHandoff(handoffId: string, options: RetireOptions): boolean;
17
+ close(): void;
18
+ }
19
+ export interface ClaimIdentity {
20
+ targetSessionKey: string;
21
+ agentId: string;
22
+ sessionId: string;
23
+ runId?: string;
24
+ accountId?: string;
25
+ handoffId?: string;
26
+ }
27
+ export interface ClaimResult {
28
+ status: "claimed" | "alreadyClaimed" | "none";
29
+ record?: HandoffRecord;
30
+ }
31
+ /** Pending records last attempted before `now - spacingMs`, with fewer than `maxAttempts`. */
32
+ export interface PendingQuery {
33
+ now: number;
34
+ spacingMs: number;
35
+ maxAttempts: number;
36
+ }
37
+ export interface RetireOptions {
38
+ /** Also retire a pending record. */
39
+ force: boolean;
40
+ }
41
+ export declare function createHandoffStore(stateDir: string): HandoffStore;
42
+ export declare function resolveDatabasePath(stateDir: string): string;