Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/completion-summary-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type CompletionSummarySession = Pick<
name?: string;
goalTaskId?: string;
sessionId?: string;
startedAt?: number;
createdAt?: number;
};

export type PersistedCompletionSummarySession = Pick<
Expand All @@ -38,8 +40,10 @@ export type PersistedCompletionSummarySession = Pick<
| "originThreadId"
| "originSessionKey"
| "goalTaskId"
| "createdAt"
> & {
id?: string;
startedAt?: number;
};

export interface CompletionSummaryDecision {
Expand Down Expand Up @@ -385,6 +389,12 @@ export class CompletionSummaryCoordinator {
aliasKind: "visible-summary" | "goal-summary",
): string[] {
const scope = this.buildVisibleScope(deliveryRef, session);
const lifecycleStartedAt = "startedAt" in session
? session.startedAt
: session.createdAt;
const lifecycleScope = Number.isFinite(lifecycleStartedAt)
? `${scope}:cycle:${this.digest(String(lifecycleStartedAt))}`
: scope;
const refs = [
"id" in session ? session.id : undefined,
"sessionId" in session ? session.sessionId : undefined,
Expand All @@ -396,7 +406,7 @@ export class CompletionSummaryCoordinator {
.map((ref) => ref?.trim())
.filter((ref): ref is string => Boolean(ref));
return [...new Set(refs)]
.map((ref) => `${scope}:session:${this.digest(ref)}:${aliasKind}`);
.map((ref) => `${lifecycleScope}:session:${this.digest(ref)}:${aliasKind}`);
}

private getDeliveryRef(session: CompletionSummarySession | PersistedCompletionSummarySession): string {
Expand Down
8 changes: 8 additions & 0 deletions src/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export class SessionStore {
taskFlowMirror: session.taskFlowMirror,
deliveryState: session.deliveryState,
notificationDedupe: this.getExistingNotificationDedupe(session),
completionSummaryDedupe: this.getExistingCompletionSummaryDedupe(session),
costUsd: 0,
originAgentId: routing.originAgentId,
originChannel: routing.originChannel,
Expand Down Expand Up @@ -364,6 +365,7 @@ export class SessionStore {
taskFlowMirror: session.taskFlowMirror,
deliveryState: session.deliveryState,
notificationDedupe: this.getExistingNotificationDedupe(session),
completionSummaryDedupe: this.getExistingCompletionSummaryDedupe(session),
killReason: session.killReason,
costUsd: session.costUsd,
originAgentId: routing.originAgentId,
Expand Down Expand Up @@ -419,6 +421,12 @@ export class SessionStore {
?? (getBackendConversationId(session) ? this.getPersistedSession(getBackendConversationId(session)!)?.notificationDedupe : undefined);
}

private getExistingCompletionSummaryDedupe(session: Session): PersistedSessionInfo["completionSummaryDedupe"] {
return this.getPersistedSession(session.id)?.completionSummaryDedupe
?? (session.harnessSessionId ? this.getPersistedSession(session.harnessSessionId)?.completionSummaryDedupe : undefined)
?? (getBackendConversationId(session) ? this.getPersistedSession(getBackendConversationId(session)!)?.completionSummaryDedupe : undefined);
}

/** Return newest persisted entry for a user-facing name, handling name collisions. */
getLatestPersistedByName(name: string): PersistedSessionInfo | undefined {
return this.queries.getLatestPersistedByName(name);
Expand Down
56 changes: 47 additions & 9 deletions tests/session-notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ describe("SessionNotificationService", () => {
assert.equal(persisted.notificationDedupe?.every((record: { status?: string }) => record.status === "delivered"), true);
});

it("dedupes one terminal completion notification but allows a later terminal cycle for the same session id", () => {
it("delivers one canonical completion and wake for each of two sequential resumed cycles on the same session id", () => {
const persisted = { notificationDedupe: undefined } as any;
const requests: Array<Record<string, unknown>> = [];
const fakeDispatcher = {
Expand All @@ -283,8 +283,9 @@ describe("SessionNotificationService", () => {
(_ref, patch) => Object.assign(persisted, patch),
{ getPersistedSession: () => persisted },
);
const session = {
const firstSession = {
id: "stable-terminal-session",
startedAt: 1780000001000,
route: { provider: "telegram", target: "topic", threadId: "13832", sessionKey: "session:terminal" },
} as any;
const firstCycleRequest = {
Expand All @@ -299,7 +300,10 @@ describe("SessionNotificationService", () => {
},
completionWakeSummaryRequired: true,
completionWakeOutcomeKey: "terminal:stable-terminal-session:completed:1780000001000:backend-thread:4:unknown",
wakeMessageOnNotifySuccess: "terminal follow-up wake",
wakeMessageOnNotifySuccess: [
"terminal follow-up wake",
'originRoute: {"provider":"telegram","target":"topic","threadId":"13832","sessionKey":"session:terminal"}',
].join("\n"),
};
const laterCycleRequest = {
...firstCycleRequest,
Expand All @@ -311,15 +315,49 @@ describe("SessionNotificationService", () => {
},
completionWakeOutcomeKey: "terminal:stable-terminal-session:completed:1780000002000:backend-thread:5:unknown",
};
const finalCycleRequest = {
...firstCycleRequest,
idempotencyKey: "terminal-completed:stable-terminal-session:completed:1780000003000:backend-thread:6:unknown",
completionSummary: {
required: true,
producer: "terminal" as const,
outcomeKey: "terminal:stable-terminal-session:completed:1780000003000:backend-thread:6:unknown",
},
completionWakeOutcomeKey: "terminal:stable-terminal-session:completed:1780000003000:backend-thread:6:unknown",
};

service.dispatch(session, firstCycleRequest);
service.dispatch(session, firstCycleRequest);
service.dispatch(session, laterCycleRequest);
const resumedSession = {
...firstSession,
startedAt: 1780000002000,
} as any;
const resumedAgainSession = {
...firstSession,
startedAt: 1780000003000,
} as any;

assert.equal(requests.length, 2);
assert.deepEqual(requests.map((request) => request.label), ["completed", "completed"]);
service.dispatch(firstSession, firstCycleRequest);
service.dispatch(firstSession, firstCycleRequest);
service.dispatch(resumedSession, laterCycleRequest);
service.dispatch(resumedSession, laterCycleRequest);
service.dispatch(resumedAgainSession, finalCycleRequest);
service.dispatch(resumedAgainSession, finalCycleRequest);

assert.equal(requests.length, 3);
assert.deepEqual(requests.map((request) => request.label), ["completed", "completed", "completed"]);
assert.notEqual(requests[0]?.idempotencyKey, requests[1]?.idempotencyKey);
assert.equal(persisted.notificationDedupe?.length, 2);
assert.notEqual(requests[1]?.idempotencyKey, requests[2]?.idempotencyKey);
assert.deepEqual(
requests.map((request) => request.completionWakeSummaryRequired),
[true, true, true],
);
assert.equal(
requests.every((request) => String(request.wakeMessageOnNotifySuccess).includes(
'originRoute: {"provider":"telegram","target":"topic","threadId":"13832","sessionKey":"session:terminal"}',
)),
true,
);
assert.equal(persisted.notificationDedupe?.length, 3);
assert.ok(persisted.completionSummaryDedupe?.length > 0);
});

it("dedupes one no-change terminal cycle but allows a later resumed cycle for the same session id", () => {
Expand Down
44 changes: 44 additions & 0 deletions tests/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,50 @@ describe("SessionStore path resolution", () => {
assert.deepEqual([...store.persisted.keys()], []);
});

it("preserves completion summary history when a stable session id starts a resumed lifecycle", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-store-resumed-summary-"));
const indexPath = join(dir, "sessions.json");
const recordedAt = new Date().toISOString();
writeStore(indexPath, [{
sessionId: "stable-resumed-session",
harnessSessionId: "h-stable-resumed-session",
name: "stable-resumed-session",
prompt: "first turn",
workdir: "/tmp",
status: "completed",
lifecycle: "terminal",
costUsd: 0,
completionSummaryDedupe: [{
key: "route:cycle-one:outcome",
recordedAt,
label: "completed",
}],
}]);
const store = new SessionStore({ indexPath, env: {} });

store.markRunning({
id: "stable-resumed-session",
name: "stable-resumed-session",
harnessSessionId: "h-stable-resumed-session",
prompt: "second turn",
workdir: "/tmp",
startedAt: Date.now(),
route: DEFAULT_ROUTE,
harnessName: "codex",
} as any);

assert.deepEqual(
store.getPersistedSession("stable-resumed-session")?.completionSummaryDedupe,
[{
key: "route:cycle-one:outcome",
linkedKeys: undefined,
recordedAt,
label: "completed",
skipReason: undefined,
}],
);
});

it("rebuilds short session ID lookup from persisted index after restart", () => {
const dir = mkdtempSync(join(tmpdir(), "openclaw-store-restart-"));
const indexPath = join(dir, "sessions.json");
Expand Down