feat(heartbeat): ADR-0044 session lifecycle T1/T2/T3/T4 for claude_local

Implements ADR-0044 «Heartbeat session lifecycle» (SOL-1629).

- SessionCompactionPolicy gains maxCachedInputTokens (T1),
  rotateOnZeroOpenIssues (T3) and rotateOnNewIssueWake (T4).
- claude_local adapter ships with T1=500_000, T2=6h, T3+T4 enabled by
  default. Per-agent overrides (CEO/CoS/Analyst T1=1M / T2=12h)
  continue to flow through runtimeConfig.heartbeat.sessionCompaction.
- evaluateSessionCompaction now considers cache_read_input_tokens
  (the actual cost signal under Anthropic prompt caching) and the
  state-triggered rotations described in ADR-0044.
- Extracted a pure decideSessionCompactionTrigger helper so the
  decision rule is unit-testable without a database.
- usageJson now records freshSessionTriggeredBy for retrospective
  tuning per ADR-0044 §Acceptance criteria.
- countOpenIssuesForAgent helper added inline in heartbeat service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Solved Bot 2026-05-27 23:58:51 +02:00 committed by Paperclip Engineer
parent eb9f954bae
commit cb6294ec02
4 changed files with 404 additions and 23 deletions

View File

@ -3,6 +3,10 @@ export interface SessionCompactionPolicy {
maxSessionRuns: number;
maxRawInputTokens: number;
maxSessionAgeHours: number;
// ADR-0044 additions
maxCachedInputTokens: number; // T1: threshold on cache_read_input_tokens (0 = disabled)
rotateOnZeroOpenIssues: boolean; // T3: rotate when openIssuesCount == 0
rotateOnNewIssueWake: boolean; // T4: rotate when wakeReason == "issue_assigned"
}
export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none";
@ -25,6 +29,9 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = {
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
};
// Adapters with native context management still participate in session resume,
@ -34,6 +41,21 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
};
// ADR-0044 «Heartbeat session lifecycle» — fresh-session policy applied to claude_local by default.
// Per-agent overrides (e.g. CEO/CoS/Analyst T1=1M / T2=12h) are configured via runtimeConfig.heartbeat.sessionCompaction.
const CLAUDE_LOCAL_ADR_0044_POLICY: SessionCompactionPolicy = {
enabled: true,
maxSessionRuns: 0, // not used (variant D rejected by ADR-0044)
maxRawInputTokens: 0, // not used (cached_input is the meaningful signal)
maxSessionAgeHours: 6, // T2 default for execution agents
maxCachedInputTokens: 500_000, // T1 default for execution agents
rotateOnZeroOpenIssues: true, // T3
rotateOnNewIssueWake: true, // T4
};
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
@ -52,7 +74,7 @@ export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement
claude_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
defaultSessionCompaction: CLAUDE_LOCAL_ADR_0044_POLICY,
},
codex_local: {
supportsSessionResume: true,
@ -146,11 +168,17 @@ export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<S
const maxSessionRuns = readNumber(compaction.maxSessionRuns);
const maxRawInputTokens = readNumber(compaction.maxRawInputTokens);
const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours);
const maxCachedInputTokens = readNumber(compaction.maxCachedInputTokens);
const rotateOnZeroOpenIssues = readBoolean(compaction.rotateOnZeroOpenIssues);
const rotateOnNewIssueWake = readBoolean(compaction.rotateOnNewIssueWake);
if (enabled !== undefined) explicit.enabled = enabled;
if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns;
if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens;
if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours;
if (maxCachedInputTokens !== undefined) explicit.maxCachedInputTokens = maxCachedInputTokens;
if (rotateOnZeroOpenIssues !== undefined) explicit.rotateOnZeroOpenIssues = rotateOnZeroOpenIssues;
if (rotateOnNewIssueWake !== undefined) explicit.rotateOnNewIssueWake = rotateOnNewIssueWake;
return explicit;
}
@ -174,6 +202,9 @@ export function resolveSessionCompactionPolicy(
maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns,
maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens,
maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours,
maxCachedInputTokens: explicitOverride.maxCachedInputTokens ?? basePolicy.maxCachedInputTokens,
rotateOnZeroOpenIssues: explicitOverride.rotateOnZeroOpenIssues ?? basePolicy.rotateOnZeroOpenIssues,
rotateOnNewIssueWake: explicitOverride.rotateOnNewIssueWake ?? basePolicy.rotateOnNewIssueWake,
},
adapterSessionManagement,
explicitOverride,
@ -187,7 +218,19 @@ export function resolveSessionCompactionPolicy(
export function hasSessionCompactionThresholds(policy: Pick<
SessionCompactionPolicy,
"maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours"
| "maxSessionRuns"
| "maxRawInputTokens"
| "maxSessionAgeHours"
| "maxCachedInputTokens"
| "rotateOnZeroOpenIssues"
| "rotateOnNewIssueWake"
>) {
return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0;
return (
policy.maxSessionRuns > 0 ||
policy.maxRawInputTokens > 0 ||
policy.maxSessionAgeHours > 0 ||
policy.maxCachedInputTokens > 0 ||
policy.rotateOnZeroOpenIssues === true ||
policy.rotateOnNewIssueWake === true
);
}

View File

@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import {
decideSessionCompactionTrigger,
type SessionCompactionTriggerInput,
} from "../services/heartbeat.ts";
import type { SessionCompactionPolicy } from "@paperclipai/adapter-utils";
const DISABLED_POLICY: SessionCompactionPolicy = {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
};
const ADR_0044_DEFAULT_POLICY: SessionCompactionPolicy = {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 6,
maxCachedInputTokens: 500_000,
rotateOnZeroOpenIssues: true,
rotateOnNewIssueWake: true,
};
function buildInput(overrides: Partial<SessionCompactionTriggerInput> = {}): SessionCompactionTriggerInput {
return {
policy: DISABLED_POLICY,
runsCount: 1,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
sessionAgeHours: 0,
openIssuesCount: 1,
wakeReason: null,
...overrides,
};
}
describe("decideSessionCompactionTrigger", () => {
it("returns null when no thresholds are crossed", () => {
expect(decideSessionCompactionTrigger(buildInput({ policy: ADR_0044_DEFAULT_POLICY }))).toBeNull();
});
it("T1 triggers when cached_input >= maxCachedInputTokens", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 600_000, outputTokens: 0 },
}),
);
expect(result?.triggeredBy).toBe("t1");
expect(result?.reason).toMatch(/cache_read reached 600,000 tokens/);
});
it("T2 triggers when sessionAgeHours >= maxSessionAgeHours", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionAgeHours: 7,
}),
);
expect(result?.triggeredBy).toBe("t2");
expect(result?.reason).toBe("session age reached 7 hours");
});
it("T3 triggers when openIssuesCount is 0 and rotateOnZeroOpenIssues is true", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
openIssuesCount: 0,
}),
);
expect(result?.triggeredBy).toBe("t3");
expect(result?.reason).toBe("no open issues for agent");
});
it("T3 does NOT trigger when openIssuesCount is null (caller skipped the count)", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
openIssuesCount: null,
}),
);
expect(result).toBeNull();
});
it("T4 triggers when wakeReason is issue_assigned and rotateOnNewIssueWake is true", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
wakeReason: "issue_assigned",
}),
);
expect(result?.triggeredBy).toBe("t4");
expect(result?.reason).toBe("wake triggered by new issue assignment");
});
it("T4 does NOT trigger for other wake reasons", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
wakeReason: "heartbeat_timer",
}),
);
expect(result).toBeNull();
});
it("legacy_runs triggers when runsCount > maxSessionRuns", () => {
const policy: SessionCompactionPolicy = { ...DISABLED_POLICY, maxSessionRuns: 5 };
const result = decideSessionCompactionTrigger(buildInput({ policy, runsCount: 6 }));
expect(result?.triggeredBy).toBe("legacy_runs");
expect(result?.reason).toBe("session exceeded 5 runs");
});
it("legacy_raw_input triggers when raw inputTokens >= maxRawInputTokens", () => {
const policy: SessionCompactionPolicy = { ...DISABLED_POLICY, maxRawInputTokens: 1_000_000 };
const result = decideSessionCompactionTrigger(
buildInput({
policy,
latestRawUsage: { inputTokens: 1_500_000, cachedInputTokens: 0, outputTokens: 0 },
}),
);
expect(result?.triggeredBy).toBe("legacy_raw_input");
expect(result?.reason).toMatch(/raw input reached 1,500,000 tokens/);
});
it("priority: legacy_runs wins over T1 when both conditions hold", () => {
const policy: SessionCompactionPolicy = {
...ADR_0044_DEFAULT_POLICY,
maxSessionRuns: 5,
};
const result = decideSessionCompactionTrigger(
buildInput({
policy,
runsCount: 6,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 },
}),
);
expect(result?.triggeredBy).toBe("legacy_runs");
});
it("priority: T1 wins over T2 when both conditions hold", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 },
sessionAgeHours: 10,
}),
);
expect(result?.triggeredBy).toBe("t1");
});
it("priority: T2 wins over T3 when both conditions hold", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionAgeHours: 10,
openIssuesCount: 0,
}),
);
expect(result?.triggeredBy).toBe("t2");
});
it("priority: T3 wins over T4 when both conditions hold", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
openIssuesCount: 0,
wakeReason: "issue_assigned",
}),
);
expect(result?.triggeredBy).toBe("t3");
});
it("ignores T3 when rotateOnZeroOpenIssues is false even if count is 0", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, rotateOnZeroOpenIssues: false };
const result = decideSessionCompactionTrigger(buildInput({ policy, openIssuesCount: 0 }));
expect(result).toBeNull();
});
it("ignores T4 when rotateOnNewIssueWake is false even if wakeReason is issue_assigned", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, rotateOnNewIssueWake: false };
const result = decideSessionCompactionTrigger(buildInput({ policy, wakeReason: "issue_assigned" }));
expect(result).toBeNull();
});
it("ignores T1 when maxCachedInputTokens is 0 (disabled)", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxCachedInputTokens: 0 };
const result = decideSessionCompactionTrigger(
buildInput({
policy,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 10_000_000, outputTokens: 0 },
}),
);
expect(result).toBeNull();
});
it("ignores T2 when maxSessionAgeHours is 0 (disabled)", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxSessionAgeHours: 0 };
const result = decideSessionCompactionTrigger(buildInput({ policy, sessionAgeHours: 100 }));
expect(result).toBeNull();
});
});

View File

@ -3032,18 +3032,27 @@ describe("prioritizeProjectWorkspaceCandidatesForRun", () => {
});
describe("parseSessionCompactionPolicy", () => {
it("disables Paperclip-managed rotation by default for codex and claude local", () => {
it("disables Paperclip-managed rotation by default for codex local (native context management)", () => {
expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
});
});
it("applies ADR-0044 fresh-session policy by default for claude_local", () => {
expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
maxSessionAgeHours: 6,
maxCachedInputTokens: 500_000,
rotateOnZeroOpenIssues: true,
rotateOnNewIssueWake: true,
});
});
@ -3053,12 +3062,18 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
});
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
enabled: true,
maxSessionRuns: 200,
maxRawInputTokens: 2_000_000,
maxSessionAgeHours: 72,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
});
});
@ -3079,6 +3094,34 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionRuns: 25,
maxRawInputTokens: 500_000,
maxSessionAgeHours: 0,
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
});
});
it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4)", () => {
expect(
parseSessionCompactionPolicy(
buildAgent("claude_local", {
heartbeat: {
sessionCompaction: {
maxCachedInputTokens: 1_000_000,
maxSessionAgeHours: 12,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
},
},
}),
),
).toEqual({
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 12,
maxCachedInputTokens: 1_000_000,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
});
});
});

View File

@ -3514,19 +3514,88 @@ interface WakeupOptions {
allowRunCoalescing?: boolean;
}
type UsageTotals = {
export type UsageTotals = {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
};
export type SessionCompactionTrigger =
| "t1"
| "t2"
| "t3"
| "t4"
| "legacy_runs"
| "legacy_raw_input";
type SessionCompactionDecision = {
rotate: boolean;
reason: string | null;
triggeredBy: SessionCompactionTrigger | null;
handoffMarkdown: string | null;
previousRunId: string | null;
};
export interface SessionCompactionTriggerInput {
policy: SessionCompactionPolicy;
runsCount: number;
latestRawUsage: UsageTotals | null;
sessionAgeHours: number;
openIssuesCount: number | null;
wakeReason: string | null;
}
// Pure decision rule for session rotation. Priority order:
// legacy_runs > legacy_raw_input > T1 (cached input) > T2 (age) > T3 (zero open issues) > T4 (new issue wake).
// First match wins so triggeredBy is deterministic for retrospective tuning (ADR-0044 §Acceptance criteria).
export function decideSessionCompactionTrigger(
input: SessionCompactionTriggerInput,
): { reason: string; triggeredBy: SessionCompactionTrigger } | null {
const { policy, runsCount, latestRawUsage, sessionAgeHours, openIssuesCount, wakeReason } = input;
if (policy.maxSessionRuns > 0 && runsCount > policy.maxSessionRuns) {
return { reason: `session exceeded ${policy.maxSessionRuns} runs`, triggeredBy: "legacy_runs" };
}
if (
policy.maxRawInputTokens > 0 &&
latestRawUsage &&
latestRawUsage.inputTokens >= policy.maxRawInputTokens
) {
return {
reason:
`session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` +
`(threshold ${formatCount(policy.maxRawInputTokens)})`,
triggeredBy: "legacy_raw_input",
};
}
if (
policy.maxCachedInputTokens > 0 &&
latestRawUsage &&
latestRawUsage.cachedInputTokens >= policy.maxCachedInputTokens
) {
return {
reason:
`session cache_read reached ${formatCount(latestRawUsage.cachedInputTokens)} tokens ` +
`(threshold ${formatCount(policy.maxCachedInputTokens)})`,
triggeredBy: "t1",
};
}
if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) {
return { reason: `session age reached ${Math.floor(sessionAgeHours)} hours`, triggeredBy: "t2" };
}
if (
policy.rotateOnZeroOpenIssues &&
typeof openIssuesCount === "number" &&
openIssuesCount === 0
) {
return { reason: "no open issues for agent", triggeredBy: "t3" };
}
if (policy.rotateOnNewIssueWake && wakeReason === "issue_assigned") {
return { reason: "wake triggered by new issue assignment", triggeredBy: "t4" };
}
return null;
}
interface ParsedIssueAssigneeAdapterOverrides {
adapterConfig: Record<string, unknown> | null;
useProjectWorkspace: boolean | null;
@ -11542,12 +11611,15 @@ export function heartbeatService(
sessionId: string | null;
issueId: string | null;
continuationSummaryBody?: string | null;
wakeReason?: string | null;
openIssuesCount?: number | null;
}): Promise<SessionCompactionDecision> {
const { agent, sessionId, issueId } = input;
if (!sessionId) {
return {
rotate: false,
reason: null,
triggeredBy: null,
handoffMarkdown: null,
previousRunId: null,
};
@ -11558,6 +11630,7 @@ export function heartbeatService(
return {
rotate: false,
reason: null,
triggeredBy: null,
handoffMarkdown: null,
previousRunId: null,
};
@ -11589,6 +11662,7 @@ export function heartbeatService(
return {
rotate: false,
reason: null,
triggeredBy: null,
handoffMarkdown: null,
previousRunId: null,
};
@ -11610,28 +11684,22 @@ export function heartbeatService(
)
: 0;
let reason: string | null = null;
if (policy.maxSessionRuns > 0 && runs.length > policy.maxSessionRuns) {
reason = `session exceeded ${policy.maxSessionRuns} runs`;
} else if (
policy.maxRawInputTokens > 0 &&
latestRawUsage &&
latestRawUsage.inputTokens >= policy.maxRawInputTokens
) {
reason =
`session raw input reached ${formatCount(latestRawUsage.inputTokens)} tokens ` +
`(threshold ${formatCount(policy.maxRawInputTokens)})`;
} else if (
policy.maxSessionAgeHours > 0 &&
sessionAgeHours >= policy.maxSessionAgeHours
) {
reason = `session age reached ${Math.floor(sessionAgeHours)} hours`;
}
const trigger = decideSessionCompactionTrigger({
policy,
runsCount: runs.length,
latestRawUsage,
sessionAgeHours,
openIssuesCount: typeof input.openIssuesCount === "number" ? input.openIssuesCount : null,
wakeReason: input.wakeReason ?? null,
});
const reason: string | null = trigger?.reason ?? null;
const triggeredBy: SessionCompactionTrigger | null = trigger?.triggeredBy ?? null;
if (!reason || !latestRun) {
return {
rotate: false,
reason: null,
triggeredBy: null,
handoffMarkdown: null,
previousRunId: latestRun?.id ?? null,
};
@ -11669,6 +11737,7 @@ export function heartbeatService(
return {
rotate: true,
reason,
triggeredBy,
handoffMarkdown,
previousRunId: latestRun.id,
};
@ -16360,6 +16429,19 @@ export function heartbeatService(
return Number(count ?? 0);
}
async function countOpenIssuesForAgent(agentId: string) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(issues)
.where(
and(
eq(issues.assigneeAgentId, agentId),
notInArray(issues.status, ["done", "cancelled"]),
),
);
return Number(count ?? 0);
}
async function withChatControlRecoveryGate(
run: typeof heartbeatRuns.$inferSelect,
stage: "claim" | "dispatch",
@ -21578,11 +21660,19 @@ export function heartbeatService(
stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams),
);
const wakeReasonForCompaction = readNonEmptyString(context?.wakeReason) ?? null;
const policyForCompaction = parseSessionCompactionPolicy(agent);
const openIssuesCountForCompaction = policyForCompaction.rotateOnZeroOpenIssues
? await countOpenIssuesForAgent(agent.id)
: null;
const sessionCompaction = await evaluateSessionCompaction({
agent,
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
issueId,
continuationSummaryBody: continuationSummary?.body ?? null,
policy: policyForCompaction,
wakeReason: wakeReasonForCompaction,
openIssuesCount: openIssuesCountForCompaction,
});
if (sessionCompaction.rotate) {
context.paperclipSessionHandoffMarkdown =
@ -23749,6 +23839,7 @@ export function heartbeatService(
runtimeForAdapter.sessionDisplayId == null,
sessionRotated: sessionCompaction.rotate,
sessionRotationReason: sessionCompaction.reason,
freshSessionTriggeredBy: sessionCompaction.rotate ? sessionCompaction.triggeredBy : null,
configFreshness: configFreshnessResultMetadata,
provider:
readNonEmptyString(adapterResult.provider) ?? "unknown",