fix(cron): clarify no-delivery previews

This commit is contained in:
Peter Steinberger
2026-05-03 22:49:03 +01:00
parent 545a7e5590
commit 0872b505b0
15 changed files with 105 additions and 13 deletions

View File

@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
- Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc.
- Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc.
- Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79.
- Cron/status: render explicit `delivery.mode: "none"` jobs as no-delivery previews and label cron session history distinctly instead of showing fallback delivery or direct-session rows. Fixes #76945.
- Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored.
- Plugins/hooks: let `plugins.entries.<id>.hooks.timeoutMs` and `plugins.entries.<id>.hooks.timeouts` bound plugin typed hooks from operator config, so slow hooks can be tuned without patching installed plugin code. Fixes #76778. Thanks @vincentkoc.
- Telegram: add `channels.telegram.mediaGroupFlushMs` at the top level and per account so operators can tune album buffering instead of being stuck with the hard-coded 500ms media-group flush window. Fixes #76149. Thanks @vincentkoc.

View File

@@ -103,10 +103,13 @@ struct SessionRow: Identifiable {
}
enum SessionKind {
case direct, group, global, unknown
case cron, direct, group, global, unknown
static func from(key: String) -> SessionKind {
if key == "global" { return .global }
let parts = key.lowercased().split(separator: ":").filter { !$0.isEmpty }
if parts.first == "cron" { return .cron }
if parts.count >= 3, parts[0] == "agent", parts[2] == "cron" { return .cron }
if key.hasPrefix("group:") { return .group }
if key.contains(":group:") { return .group }
if key.contains(":channel:") { return .group }
@@ -116,6 +119,7 @@ enum SessionKind {
var label: String {
switch self {
case .cron: "Cron"
case .direct: "Direct"
case .group: "Group"
case .global: "Global"
@@ -125,6 +129,7 @@ enum SessionKind {
var tint: Color {
switch self {
case .cron: .green
case .direct: .accentColor
case .group: .orange
case .global: .purple

View File

@@ -5,6 +5,8 @@ import Testing
struct SessionDataTests {
@Test func `session kind from key detects common kinds`() {
#expect(SessionKind.from(key: "global") == .global)
#expect(SessionKind.from(key: "cron:daily") == .cron)
#expect(SessionKind.from(key: "agent:main:cron:daily") == .cron)
#expect(SessionKind.from(key: "discord:group:engineering") == .group)
#expect(SessionKind.from(key: "unknown") == .unknown)
#expect(SessionKind.from(key: "user@example.com") == .direct)

View File

@@ -5,6 +5,7 @@ import { loadSessionStore, resolveSessionTotalTokens } from "../config/sessions.
import { info } from "../globals.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { isCronSessionKey } from "../sessions/session-key-utils.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { isRich, theme } from "../terminal/theme.js";
import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js";
@@ -27,7 +28,7 @@ import {
type SessionRow = SessionDisplayRow & {
agentId: string;
kind: "direct" | "group" | "global" | "unknown";
kind: "cron" | "direct" | "group" | "global" | "unknown";
agentRuntime: ReturnType<typeof resolveAgentRuntimeMetadata>;
};
@@ -84,6 +85,9 @@ function classifySessionKey(key: string, entry?: { chatType?: string | null }):
if (key === "unknown") {
return "unknown";
}
if (isCronSessionKey(key)) {
return "cron";
}
if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group";
}

View File

@@ -70,6 +70,19 @@ describe("status.command-sections", () => {
contextTokens: null,
flags: [],
},
{
key: "agent:main:cron:daily-digest",
kind: "cron",
updatedAt: 2,
age: 7_000,
model: "gpt-5.5",
totalTokens: null,
totalTokensFresh: false,
remainingTokens: null,
percentUsed: null,
contextTokens: null,
flags: [],
},
],
verbose: true,
shortenText: (value) => value.slice(0, 8),
@@ -88,6 +101,14 @@ describe("status.command-sections", () => {
Tokens: "12k",
Cache: "cache ok",
},
{
Key: "agent:ma",
Kind: "cron",
Age: "7000ms",
Model: "gpt-5.5",
Tokens: "12k",
Cache: "cache ok",
},
]);
const emptyRows = buildStatusSessionsRows({

View File

@@ -41,6 +41,15 @@ describe("statusSummaryRuntime.resolveContextTokensForModel", () => {
});
});
describe("statusSummaryRuntime.classifySessionKey", () => {
it("classifies cron history sessions distinctly", () => {
expect(statusSummaryRuntime.classifySessionKey("agent:main:cron:daily-digest")).toBe("cron");
expect(
statusSummaryRuntime.classifySessionKey("agent:avery:cron:daily-digest:run:abc123"),
).toBe("cron");
});
});
describe("statusSummaryRuntime.resolveSessionModelRef", () => {
const cfg = {
agents: {

View File

@@ -5,6 +5,7 @@ import { normalizeProviderId } from "../agents/provider-id.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.js";
import { isCronSessionKey } from "../sessions/session-key-utils.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -129,6 +130,9 @@ function classifySessionKey(key: string, entry?: SessionEntry) {
if (key === "unknown") {
return "unknown";
}
if (isCronSessionKey(key)) {
return "cron";
}
if (entry?.chatType === "group" || entry?.chatType === "channel") {
return "group";
}

View File

@@ -5,7 +5,7 @@ import type { TaskRegistrySummary } from "../tasks/task-registry.types.js";
export type SessionStatus = {
agentId?: string;
key: string;
kind: "direct" | "group" | "global" | "unknown";
kind: "cron" | "direct" | "group" | "global" | "unknown";
sessionId?: string;
updatedAt: number | null;
age: number | null;

View File

@@ -51,4 +51,19 @@ describe("resolveCronDeliveryPreview", () => {
"resolved from last, session agent:avery:telegram:direct:direct-123",
);
});
it("does not resolve routes for explicit no-delivery jobs", async () => {
const job = makeCronJob({
delivery: { mode: "none" },
sessionTarget: "isolated",
});
const preview = await resolveCronDeliveryPreview({
cfg: {} as never,
job,
});
expect(preview).toEqual({ label: "not requested", detail: "not requested" });
expect(mocks.resolveDeliveryTarget).not.toHaveBeenCalled();
});
});

View File

@@ -40,7 +40,7 @@ export async function resolveCronDeliveryPreview(params: {
job: CronJob;
}): Promise<CronDeliveryPreview> {
const plan = resolveCronDeliveryPlan(params.job);
if (!plan.requested && plan.mode === "none" && !params.job.delivery) {
if (plan.mode === "none") {
return { label: "not requested", detail: "not requested" };
}
if (plan.mode === "webhook") {

View File

@@ -2404,6 +2404,11 @@
background: rgba(59, 130, 246, 0.1);
}
.data-table-badge--cron {
color: var(--success);
background: rgba(34, 197, 94, 0.1);
}
.data-table-badge--global {
color: var(--warn);
background: var(--warn-subtle);

View File

@@ -118,7 +118,11 @@ function hasOwn(record: Record<string, unknown>, key: string): boolean {
}
function normalizeSessionKind(value: unknown): GatewaySessionRow["kind"] | undefined {
return value === "direct" || value === "group" || value === "global" || value === "unknown"
return value === "cron" ||
value === "direct" ||
value === "group" ||
value === "global" ||
value === "unknown"
? value
: undefined;
}

View File

@@ -417,7 +417,7 @@ export type SessionCompactionCheckpointPreview = Pick<
export type GatewaySessionRow = {
key: string;
spawnedBy?: string;
kind: "direct" | "group" | "global" | "unknown";
kind: "cron" | "direct" | "group" | "global" | "unknown";
label?: string;
displayName?: string;
surface?: string;

View File

@@ -220,6 +220,26 @@ describe("sessions view", () => {
expect(keyCell?.getAttribute("title")).toBe("agent:unknown-agent:telegram:abc123");
});
it("renders cron session kind distinctly", async () => {
const container = document.createElement("div");
render(
renderSessions(
buildProps(
buildResult({
key: "agent:main:cron:daily-digest",
kind: "cron",
updatedAt: Date.now(),
}),
),
),
container,
);
await Promise.resolve();
const badge = container.querySelector(".data-table-badge--cron");
expect(badge?.textContent?.trim()).toBe("cron");
});
it("keeps raw keys for inherited identity object properties", async () => {
const container = document.createElement("div");
render(

View File

@@ -519,13 +519,15 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) {
? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}`
: null;
const badgeClass =
row.kind === "direct"
? "data-table-badge--direct"
: row.kind === "group"
? "data-table-badge--group"
: row.kind === "global"
? "data-table-badge--global"
: "data-table-badge--unknown";
row.kind === "cron"
? "data-table-badge--cron"
: row.kind === "direct"
? "data-table-badge--direct"
: row.kind === "group"
? "data-table-badge--group"
: row.kind === "global"
? "data-table-badge--global"
: "data-table-badge--unknown";
return [
html`<tr>