refactor(tasks): add owner-key task access boundaries (#58516)

* refactor(tasks): add owner-key task access boundaries

* test(acp): update task owner-key assertion

* fix(tasks): align owner key checks and migration scope
This commit is contained in:
Vincent Koc 2026-04-01 03:12:33 +09:00 committed by GitHub
parent 69fe999373
commit 7cd0ff2d88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 732 additions and 188 deletions

View File

@ -1884,7 +1884,8 @@ export class AcpSessionManager {
createRunningTaskRun({
runtime: "acp",
sourceId: context.runId,
requesterSessionKey: context.requesterSessionKey,
ownerKey: context.requesterSessionKey,
scopeKind: "session",
requesterOrigin: context.requesterOrigin,
childSessionKey: context.childSessionKey,
runId: context.runId,

View File

@ -327,7 +327,8 @@ describe("AcpSessionManager", () => {
expect(findTaskByRunId("direct-parented-run")).toMatchObject({
runtime: "acp",
requesterSessionKey: "agent:quant:telegram:quant:direct:822430204",
ownerKey: "agent:quant:telegram:quant:direct:822430204",
scopeKind: "session",
childSessionKey: "agent:codex:acp:child-1",
label: "Quant patch",
task: "Implement the feature and report back",

View File

@ -997,7 +997,8 @@ export async function spawnAcpDirect(
createRunningTaskRun({
runtime: "acp",
sourceId: childRunId,
requesterSessionKey: requesterInternalKey,
ownerKey: requesterInternalKey,
scopeKind: "session",
requesterOrigin: requesterState.origin,
childSessionKey: sessionKey,
runId: childRunId,
@ -1028,7 +1029,8 @@ export async function spawnAcpDirect(
createRunningTaskRun({
runtime: "acp",
sourceId: childRunId,
requesterSessionKey: requesterInternalKey,
ownerKey: requesterInternalKey,
scopeKind: "session",
requesterOrigin: requesterState.origin,
childSessionKey: sessionKey,
runId: childRunId,

View File

@ -8,8 +8,11 @@ const callGatewayMock = vi.fn();
const loadCombinedSessionStoreForGatewayMock = vi.fn();
const buildStatusMessageMock = vi.hoisted(() => vi.fn(() => "OpenClaw\n🧠 Model: GPT-5.4"));
const resolveQueueSettingsMock = vi.hoisted(() => vi.fn(() => ({ mode: "interrupt" })));
const listTasksForSessionKeyMock = vi.hoisted(() =>
vi.fn((_: string) => [] as Array<Record<string, unknown>>),
const listTasksForRelatedSessionKeyForOwnerMock = vi.hoisted(() =>
vi.fn(
(_: { relatedSessionKey: string; callerOwnerKey: string }) =>
[] as Array<Record<string, unknown>>,
),
);
const createMockConfig = () => ({
@ -192,8 +195,11 @@ async function loadFreshOpenClawToolsForSessionStatusTest() {
vi.doMock("../auto-reply/status.js", () => ({
buildStatusMessage: buildStatusMessageMock,
}));
vi.doMock("../tasks/task-registry.js", () => ({
listTasksForSessionKey: (sessionKey: string) => listTasksForSessionKeyMock(sessionKey),
vi.doMock("../tasks/task-owner-access.js", () => ({
listTasksForRelatedSessionKeyForOwner: (params: {
relatedSessionKey: string;
callerOwnerKey: string;
}) => listTasksForRelatedSessionKeyForOwnerMock(params),
}));
({ createSessionStatusTool } = await import("./tools/session-status-tool.js"));
}
@ -206,8 +212,8 @@ function resetSessionStore(store: Record<string, SessionEntry>) {
updateSessionStoreMock.mockClear();
callGatewayMock.mockClear();
loadCombinedSessionStoreForGatewayMock.mockClear();
listTasksForSessionKeyMock.mockClear();
listTasksForSessionKeyMock.mockReturnValue([]);
listTasksForRelatedSessionKeyForOwnerMock.mockClear();
listTasksForRelatedSessionKeyForOwnerMock.mockReturnValue([]);
loadSessionStoreMock.mockReturnValue(store);
loadCombinedSessionStoreForGatewayMock.mockReturnValue({
storePath: "(multiple)",
@ -390,7 +396,7 @@ describe("session_status tool", () => {
updatedAt: Date.now(),
},
});
listTasksForSessionKeyMock.mockReturnValue([
listTasksForRelatedSessionKeyForOwnerMock.mockReturnValue([
{
taskId: "task-1",
runtime: "acp",

View File

@ -323,7 +323,8 @@ export function createSubagentRunManager(params: {
createRunningTaskRun({
runtime: "subagent",
sourceId: registerParams.runId,
requesterSessionKey: registerParams.requesterSessionKey,
ownerKey: registerParams.requesterSessionKey,
scopeKind: "session",
requesterOrigin,
childSessionKey: registerParams.childSessionKey,
runId: registerParams.runId,

View File

@ -23,7 +23,7 @@ import {
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { listTasksForSessionKey } from "../../tasks/task-registry.js";
import { listTasksForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js";
import { resolveAgentConfig, resolveAgentDir } from "../agent-scope.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { resolveModelAuthLabel } from "../model-auth-label.js";
@ -119,8 +119,14 @@ function resolveStoreScopedRequesterKey(params: {
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
}
function formatSessionTaskLine(sessionKey: string): string | undefined {
const tasks = listTasksForSessionKey(sessionKey);
function formatSessionTaskLine(params: {
relatedSessionKey: string;
callerOwnerKey: string;
}): string | undefined {
const tasks = listTasksForRelatedSessionKeyForOwner({
relatedSessionKey: params.relatedSessionKey,
callerOwnerKey: params.callerOwnerKey,
});
if (tasks.length === 0) {
return undefined;
}
@ -568,7 +574,10 @@ export function createSessionStatusTool(opts?: {
},
includeTranscriptUsage: true,
});
const taskLine = formatSessionTaskLine(resolved.key);
const taskLine = formatSessionTaskLine({
relatedSessionKey: resolved.key,
callerOwnerKey: visibilityRequesterKey,
});
const fullStatusText = taskLine ? `${statusText}\n${taskLine}` : statusText;
return {

View File

@ -1464,7 +1464,8 @@ describe("/acp command", () => {
});
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: defaultAcpSessionKey,
runId: "acp-run-1",
task: "Inspect ACP backlog",

View File

@ -8,7 +8,7 @@ import {
validateRuntimePermissionProfileInput,
} from "../../../acp/control-plane/runtime-options.js";
import { resolveAcpSessionIdentifierLinesFromIdentity } from "../../../acp/runtime/session-identifiers.js";
import { findLatestTaskForSessionKey } from "../../../tasks/task-registry.js";
import { findLatestTaskForRelatedSessionKeyForOwner } from "../../../tasks/task-owner-access.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import {
ACP_CWD_USAGE,
@ -123,7 +123,10 @@ export async function handleAcpStatusAction(
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP session status.",
onSuccess: (status) => {
const linkedTask = findLatestTaskForSessionKey(status.sessionKey);
const linkedTask = findLatestTaskForRelatedSessionKeyForOwner({
relatedSessionKey: status.sessionKey,
callerOwnerKey: params.sessionKey,
});
const sessionIdentifierLines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: status.backend,
identity: status.identity,

View File

@ -1,7 +1,7 @@
import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js";
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
import { formatDurationCompact } from "../../../shared/subagents-format.js";
import { findTaskByRunId } from "../../../tasks/task-registry.js";
import { findTaskByRunIdForOwner } from "../../../tasks/task-owner-access.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
@ -37,7 +37,10 @@ export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): Command
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
: "n/a";
const linkedTask = findTaskByRunId(run.runId);
const linkedTask = findTaskByRunIdForOwner({
runId: run.runId,
callerOwnerKey: params.sessionKey,
});
const lines = [
" Subagent info",

View File

@ -2633,7 +2633,8 @@ describe("handleCommands subagents", () => {
});
createTaskRecord({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:subagent:abc",
runId: "run-1",
task: "do thing",

View File

@ -1,6 +1,11 @@
import { loadConfig } from "../config/config.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import {
cancelTaskById,
getTaskById,
updateTaskNotifyPolicyById,
} from "../tasks/runtime-internal.js";
import {
listTaskAuditFindings,
summarizeTaskAuditFindings,
@ -8,7 +13,6 @@ import {
type TaskAuditFinding,
type TaskAuditSeverity,
} from "../tasks/task-registry.audit.js";
import { cancelTaskById, getTaskById, updateTaskNotifyPolicyById } from "../tasks/task-registry.js";
import {
getInspectableTaskAuditSummary,
getInspectableTaskRegistrySummary,
@ -231,7 +235,7 @@ export async function tasksShowCommand(
`result: ${task.terminalOutcome ?? "n/a"}`,
`delivery: ${task.deliveryStatus}`,
`notify: ${task.notifyPolicy}`,
`requesterSessionKey: ${task.requesterSessionKey}`,
`ownerKey: ${task.ownerKey}`,
`childSessionKey: ${task.childSessionKey ?? "n/a"}`,
`parentTaskId: ${task.parentTaskId ?? "n/a"}`,
`agentId: ${task.agentId ?? "n/a"}`,

View File

@ -400,7 +400,8 @@ function tryCreateManualTaskRun(params: {
createRunningTaskRun({
runtime: "cron",
sourceId: params.job.id,
requesterSessionKey: "",
ownerKey: `system:cron:${params.job.id}`,
scopeKind: "system",
childSessionKey: params.job.sessionKey,
agentId: params.job.agentId,
runId,

View File

@ -138,7 +138,8 @@ function tryCreateCronTaskRun(params: {
createRunningTaskRun({
runtime: "cron",
sourceId: params.job.id,
requesterSessionKey: "",
ownerKey: `system:cron:${params.job.id}`,
scopeKind: "system",
childSessionKey: params.job.sessionKey,
agentId: params.job.agentId,
runId,

View File

@ -473,7 +473,8 @@ describe("gateway agent handler", () => {
runId: "run-old",
childSessionKey,
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterDisplayKey: "main",
task: "initial task",
cleanup: "keep" as const,

View File

@ -197,7 +197,8 @@ function dispatchAgentRunFromGateway(params: {
createRunningTaskRun({
runtime: "cli",
sourceId: params.runId,
requesterSessionKey: params.ingressOpts.sessionKey,
ownerKey: params.ingressOpts.sessionKey,
scopeKind: "session",
requesterOrigin: normalizeDeliveryContext({
channel: params.ingressOpts.channel,
to: params.ingressOpts.to,

View File

@ -0,0 +1,28 @@
export {
cancelTaskById,
createTaskRecord,
deleteTaskRecordById,
ensureTaskRegistryReady,
findLatestTaskForOwnerKey,
findLatestTaskForRelatedSessionKey,
findTaskByRunId,
getTaskById,
getTaskRegistrySnapshot,
getTaskRegistrySummary,
listTaskRecords,
listTasksForOwnerKey,
listTasksForRelatedSessionKey,
markTaskLostById,
markTaskRunningByRunId,
markTaskTerminalById,
markTaskTerminalByRunId,
maybeDeliverTaskTerminalUpdate,
recordTaskProgressByRunId,
resolveTaskForLookupToken,
resetTaskRegistryForTests,
setTaskCleanupAfterById,
setTaskProgressById,
setTaskRunDeliveryStatusByRunId,
setTaskTimingById,
updateTaskNotifyPolicyById,
} from "./task-registry.js";

View File

@ -14,7 +14,8 @@ function createTask(partial: Partial<TaskRecord>): TaskRecord {
return {
taskId: partial.taskId ?? "task-1",
runtime: partial.runtime ?? "acp",
requesterSessionKey: partial.requesterSessionKey ?? "agent:main:main",
ownerKey: partial.ownerKey ?? "agent:main:main",
scopeKind: "session",
task: partial.task ?? "Investigate issue",
status: partial.status ?? "running",
deliveryStatus: partial.deliveryStatus ?? "pending",

View File

@ -67,7 +67,8 @@ describe("task-executor", () => {
await withTaskExecutorStateDir(async () => {
const created = createQueuedTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:child",
runId: "run-executor-queued",
task: "Investigate issue",
@ -103,7 +104,8 @@ describe("task-executor", () => {
await withTaskExecutorStateDir(async () => {
const created = createRunningTaskRun({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:subagent:child",
runId: "run-executor-fail",
task: "Write summary",
@ -143,7 +145,8 @@ describe("task-executor", () => {
await withTaskExecutorStateDir(async () => {
const created = createRunningTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -179,7 +182,8 @@ describe("task-executor", () => {
const child = createRunningTaskRun({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:child",
runId: "run-linear-cancel",
task: "Inspect a PR",
@ -217,7 +221,8 @@ describe("task-executor", () => {
const child = createRunningTaskRun({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:subagent:child",
runId: "run-subagent-cancel",
task: "Inspect a PR",

View File

@ -7,13 +7,14 @@ import {
markTaskTerminalByRunId,
recordTaskProgressByRunId,
setTaskRunDeliveryStatusByRunId,
} from "./task-registry.js";
} from "./runtime-internal.js";
import type {
TaskDeliveryState,
TaskDeliveryStatus,
TaskNotifyPolicy,
TaskRecord,
TaskRuntime,
TaskScopeKind,
TaskStatus,
TaskTerminalOutcome,
} from "./task-registry.types.js";
@ -21,7 +22,8 @@ import type {
export function createQueuedTaskRun(params: {
runtime: TaskRuntime;
sourceId?: string;
requesterSessionKey: string;
ownerKey: string;
scopeKind: TaskScopeKind;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
childSessionKey?: string;
parentTaskId?: string;
@ -42,7 +44,8 @@ export function createQueuedTaskRun(params: {
export function createRunningTaskRun(params: {
runtime: TaskRuntime;
sourceId?: string;
requesterSessionKey: string;
ownerKey: string;
scopeKind: TaskScopeKind;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
childSessionKey?: string;
parentTaskId?: string;

View File

@ -0,0 +1,114 @@
import { afterEach, describe, expect, it } from "vitest";
import {
findLatestTaskForRelatedSessionKeyForOwner,
findTaskByRunIdForOwner,
getTaskByIdForOwner,
resolveTaskForLookupTokenForOwner,
} from "./task-owner-access.js";
import { createTaskRecord, resetTaskRegistryForTests } from "./task-registry.js";
afterEach(() => {
resetTaskRegistryForTests({ persist: false });
});
describe("task owner access", () => {
it("returns owner-scoped tasks for owner and child-session lookups", () => {
const task = createTaskRecord({
runtime: "subagent",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:subagent:child-1",
runId: "owner-visible-run",
task: "Owner visible task",
status: "running",
});
expect(
findLatestTaskForRelatedSessionKeyForOwner({
relatedSessionKey: "agent:main:subagent:child-1",
callerOwnerKey: "agent:main:main",
})?.taskId,
).toBe(task.taskId);
expect(
findTaskByRunIdForOwner({
runId: "owner-visible-run",
callerOwnerKey: "agent:main:main",
})?.taskId,
).toBe(task.taskId);
});
it("denies cross-owner task reads", () => {
const task = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:child-1",
runId: "owner-hidden-run",
task: "Hidden task",
status: "queued",
});
expect(
getTaskByIdForOwner({
taskId: task.taskId,
callerOwnerKey: "agent:main:subagent:other-parent",
}),
).toBeUndefined();
expect(
findTaskByRunIdForOwner({
runId: "owner-hidden-run",
callerOwnerKey: "agent:main:subagent:other-parent",
}),
).toBeUndefined();
expect(
resolveTaskForLookupTokenForOwner({
token: "agent:main:acp:child-1",
callerOwnerKey: "agent:main:subagent:other-parent",
}),
).toBeUndefined();
});
it("requires an exact owner-key match", () => {
const task = createTaskRecord({
runtime: "acp",
ownerKey: "agent:main:MixedCase",
scopeKind: "session",
runId: "case-sensitive-owner-run",
task: "Case-sensitive owner",
status: "queued",
});
expect(
getTaskByIdForOwner({
taskId: task.taskId,
callerOwnerKey: "agent:main:mixedcase",
}),
).toBeUndefined();
});
it("does not expose system-owned tasks through owner-scoped readers", () => {
const task = createTaskRecord({
runtime: "cron",
ownerKey: "system:cron:nightly",
scopeKind: "system",
childSessionKey: "agent:main:cron:nightly",
runId: "system-task-run",
task: "Nightly cron",
status: "running",
deliveryStatus: "not_applicable",
});
expect(
getTaskByIdForOwner({
taskId: task.taskId,
callerOwnerKey: "agent:main:main",
}),
).toBeUndefined();
expect(
resolveTaskForLookupTokenForOwner({
token: "system-task-run",
callerOwnerKey: "agent:main:main",
}),
).toBeUndefined();
});
});

View File

@ -0,0 +1,80 @@
import {
findTaskByRunId,
getTaskById,
listTasksForRelatedSessionKey,
resolveTaskForLookupToken,
} from "./task-registry.js";
import type { TaskRecord } from "./task-registry.types.js";
function normalizeOwnerKey(ownerKey?: string): string | undefined {
const trimmed = ownerKey?.trim();
return trimmed ? trimmed : undefined;
}
function canOwnerAccessTask(task: TaskRecord, callerOwnerKey: string): boolean {
return (
task.scopeKind === "session" &&
normalizeOwnerKey(task.ownerKey) === normalizeOwnerKey(callerOwnerKey)
);
}
export function getTaskByIdForOwner(params: {
taskId: string;
callerOwnerKey: string;
}): TaskRecord | undefined {
const task = getTaskById(params.taskId);
return task && canOwnerAccessTask(task, params.callerOwnerKey) ? task : undefined;
}
export function findTaskByRunIdForOwner(params: {
runId: string;
callerOwnerKey: string;
}): TaskRecord | undefined {
const task = findTaskByRunId(params.runId);
return task && canOwnerAccessTask(task, params.callerOwnerKey) ? task : undefined;
}
export function listTasksForRelatedSessionKeyForOwner(params: {
relatedSessionKey: string;
callerOwnerKey: string;
}): TaskRecord[] {
return listTasksForRelatedSessionKey(params.relatedSessionKey).filter((task) =>
canOwnerAccessTask(task, params.callerOwnerKey),
);
}
export function findLatestTaskForRelatedSessionKeyForOwner(params: {
relatedSessionKey: string;
callerOwnerKey: string;
}): TaskRecord | undefined {
return listTasksForRelatedSessionKeyForOwner(params)[0];
}
export function resolveTaskForLookupTokenForOwner(params: {
token: string;
callerOwnerKey: string;
}): TaskRecord | undefined {
const direct = getTaskByIdForOwner({
taskId: params.token,
callerOwnerKey: params.callerOwnerKey,
});
if (direct) {
return direct;
}
const byRun = findTaskByRunIdForOwner({
runId: params.token,
callerOwnerKey: params.callerOwnerKey,
});
if (byRun) {
return byRun;
}
const related = findLatestTaskForRelatedSessionKeyForOwner({
relatedSessionKey: params.token,
callerOwnerKey: params.callerOwnerKey,
});
if (related) {
return related;
}
const raw = resolveTaskForLookupToken(params.token);
return raw && canOwnerAccessTask(raw, params.callerOwnerKey) ? raw : undefined;
}

View File

@ -5,14 +5,7 @@ import { describe, expect, it } from "vitest";
const TASK_ROOT = path.resolve(import.meta.dirname);
const SRC_ROOT = path.resolve(TASK_ROOT, "..");
const ALLOWED_IMPORTERS = new Set([
"agents/tools/session-status-tool.ts",
"auto-reply/reply/commands-acp/runtime-options.ts",
"auto-reply/reply/commands-subagents/action-info.ts",
"commands/tasks.ts",
"tasks/task-executor.ts",
"tasks/task-registry.maintenance.ts",
]);
const ALLOWED_IMPORTERS = new Set(["tasks/runtime-internal.ts", "tasks/task-owner-access.ts"]);
async function listSourceFiles(root: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true });
@ -32,7 +25,7 @@ async function listSourceFiles(root: string): Promise<string[]> {
}
describe("task registry import boundary", () => {
it("keeps direct task-registry imports on the approved read-model seam", async () => {
it("keeps direct task-registry imports behind the approved task access seams", async () => {
const importers: string[] = [];
for (const file of await listSourceFiles(SRC_ROOT)) {
const relative = path.relative(SRC_ROOT, file).replaceAll(path.sep, "/");

View File

@ -6,7 +6,8 @@ function createTask(partial: Partial<TaskRecord>): TaskRecord {
return {
taskId: partial.taskId ?? "task-1",
runtime: partial.runtime ?? "acp",
requesterSessionKey: partial.requesterSessionKey ?? "agent:main:main",
ownerKey: partial.ownerKey ?? "agent:main:main",
scopeKind: "session",
task: partial.task ?? "Background task",
status: partial.status ?? "queued",
deliveryStatus: partial.deliveryStatus ?? "pending",

View File

@ -1,8 +1,6 @@
import { readAcpSessionEntry } from "../acp/runtime/session-meta.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { listTaskAuditFindings, summarizeTaskAuditFindings } from "./task-registry.audit.js";
import type { TaskAuditSummary } from "./task-registry.audit.js";
import {
deleteTaskRecordById,
ensureTaskRegistryReady,
@ -12,7 +10,9 @@ import {
maybeDeliverTaskTerminalUpdate,
resolveTaskForLookupToken,
setTaskCleanupAfterById,
} from "./task-registry.js";
} from "./runtime-internal.js";
import { listTaskAuditFindings, summarizeTaskAuditFindings } from "./task-registry.audit.js";
import type { TaskAuditSummary } from "./task-registry.audit.js";
import { summarizeTaskRecords } from "./task-registry.summary.js";
import type { TaskRecord, TaskRegistrySummary } from "./task-registry.types.js";

View File

@ -10,7 +10,8 @@ type TaskRegistryRow = {
task_id: string;
runtime: TaskRecord["runtime"];
source_id: string | null;
requester_session_key: string;
owner_key: string;
scope_kind: TaskRecord["scopeKind"];
child_session_key: string | null;
parent_task_id: string | null;
agent_id: string | null;
@ -37,6 +38,10 @@ type TaskDeliveryStateRow = {
last_notified_event_at: number | bigint | null;
};
type TableInfoRow = {
name: string;
};
type TaskRegistryStatements = {
selectAll: StatementSync;
selectAllDeliveryStates: StatementSync;
@ -90,7 +95,8 @@ function rowToTaskRecord(row: TaskRegistryRow): TaskRecord {
taskId: row.task_id,
runtime: row.runtime,
...(row.source_id ? { sourceId: row.source_id } : {}),
requesterSessionKey: row.requester_session_key,
ownerKey: row.owner_key,
scopeKind: row.scope_kind,
...(row.child_session_key ? { childSessionKey: row.child_session_key } : {}),
...(row.parent_task_id ? { parentTaskId: row.parent_task_id } : {}),
...(row.agent_id ? { agentId: row.agent_id } : {}),
@ -127,7 +133,8 @@ function bindTaskRecord(record: TaskRecord) {
task_id: record.taskId,
runtime: record.runtime,
source_id: record.sourceId ?? null,
requester_session_key: record.requesterSessionKey,
owner_key: record.ownerKey,
scope_kind: record.scopeKind,
child_session_key: record.childSessionKey ?? null,
parent_task_id: record.parentTaskId ?? null,
agent_id: record.agentId ?? null,
@ -164,7 +171,8 @@ function createStatements(db: DatabaseSync): TaskRegistryStatements {
task_id,
runtime,
source_id,
requester_session_key,
owner_key,
scope_kind,
child_session_key,
parent_task_id,
agent_id,
@ -199,7 +207,8 @@ function createStatements(db: DatabaseSync): TaskRegistryStatements {
task_id,
runtime,
source_id,
requester_session_key,
owner_key,
scope_kind,
child_session_key,
parent_task_id,
agent_id,
@ -222,7 +231,8 @@ function createStatements(db: DatabaseSync): TaskRegistryStatements {
@task_id,
@runtime,
@source_id,
@requester_session_key,
@owner_key,
@scope_kind,
@child_session_key,
@parent_task_id,
@agent_id,
@ -245,7 +255,8 @@ function createStatements(db: DatabaseSync): TaskRegistryStatements {
ON CONFLICT(task_id) DO UPDATE SET
runtime = excluded.runtime,
source_id = excluded.source_id,
requester_session_key = excluded.requester_session_key,
owner_key = excluded.owner_key,
scope_kind = excluded.scope_kind,
child_session_key = excluded.child_session_key,
parent_task_id = excluded.parent_task_id,
agent_id = excluded.agent_id,
@ -283,13 +294,50 @@ function createStatements(db: DatabaseSync): TaskRegistryStatements {
};
}
function hasTaskRunsColumn(db: DatabaseSync, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(task_runs)`).all() as TableInfoRow[];
return rows.some((row) => row.name === columnName);
}
function migrateLegacyOwnerColumns(db: DatabaseSync) {
if (!hasTaskRunsColumn(db, "owner_key")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN owner_key TEXT;`);
}
if (!hasTaskRunsColumn(db, "scope_kind")) {
db.exec(`ALTER TABLE task_runs ADD COLUMN scope_kind TEXT NOT NULL DEFAULT 'session';`);
}
if (hasTaskRunsColumn(db, "requester_session_key")) {
db.exec(`
UPDATE task_runs
SET owner_key = requester_session_key
WHERE owner_key IS NULL
`);
}
db.exec(`
UPDATE task_runs
SET owner_key = CASE
WHEN trim(COALESCE(owner_key, '')) <> '' THEN trim(owner_key)
ELSE 'system:' || runtime || ':' || COALESCE(NULLIF(source_id, ''), task_id)
END
`);
db.exec(`
UPDATE task_runs
SET scope_kind = CASE
WHEN scope_kind = 'system' THEN 'system'
WHEN owner_key LIKE 'system:%' THEN 'system'
ELSE 'session'
END
`);
}
function ensureSchema(db: DatabaseSync) {
db.exec(`
CREATE TABLE IF NOT EXISTS task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
source_id TEXT,
requester_session_key TEXT NOT NULL,
owner_key TEXT NOT NULL,
scope_kind TEXT NOT NULL,
child_session_key TEXT,
parent_task_id TEXT,
agent_id TEXT,
@ -310,6 +358,7 @@ function ensureSchema(db: DatabaseSync) {
terminal_outcome TEXT
);
`);
migrateLegacyOwnerColumns(db);
db.exec(`
CREATE TABLE IF NOT EXISTS task_delivery_state (
task_id TEXT PRIMARY KEY,
@ -322,6 +371,7 @@ function ensureSchema(db: DatabaseSync) {
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_runtime_status ON task_runs(runtime, status);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_cleanup_after ON task_runs(cleanup_after);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_last_event_at ON task_runs(last_event_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_task_runs_owner_key ON task_runs(owner_key);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_task_runs_child_session_key ON task_runs(child_session_key);`,
);

View File

@ -1,7 +1,8 @@
import { mkdtempSync, rmSync, statSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import {
createTaskRecord,
deleteTaskRecordById,
@ -17,7 +18,8 @@ function createStoredTask(): TaskRecord {
taskId: "task-restored",
runtime: "acp",
sourceId: "run-restored",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:restored",
runId: "run-restored",
task: "Restored task",
@ -57,7 +59,8 @@ describe("task-registry store runtime", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:new",
runId: "run-new",
task: "New task",
@ -93,7 +96,8 @@ describe("task-registry store runtime", () => {
expect(findTaskByRunId("run-restored")).toBeTruthy();
const created = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:new",
runId: "run-new",
task: "New task",
@ -120,7 +124,8 @@ describe("task-registry store runtime", () => {
it("restores persisted tasks from the default sqlite store", () => {
const created = createTaskRecord({
runtime: "cron",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
sourceId: "job-123",
runId: "run-sqlite",
task: "Run nightly cron",
@ -147,7 +152,8 @@ describe("task-registry store runtime", () => {
createTaskRecord({
runtime: "cron",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
sourceId: "job-456",
runId: "run-perms",
task: "Run secured cron",
@ -164,4 +170,86 @@ describe("task-registry store runtime", () => {
resetTaskRegistryForTests();
rmSync(stateDir, { recursive: true, force: true });
});
it("migrates legacy ownerless cron rows to system scope", () => {
const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-task-store-legacy-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
const sqlitePath = resolveTaskRegistrySqlitePath(process.env);
mkdirSync(path.dirname(sqlitePath), { recursive: true });
const { DatabaseSync } = requireNodeSqlite();
const db = new DatabaseSync(sqlitePath);
db.exec(`
CREATE TABLE task_runs (
task_id TEXT PRIMARY KEY,
runtime TEXT NOT NULL,
source_id TEXT,
requester_session_key TEXT NOT NULL,
child_session_key TEXT,
parent_task_id TEXT,
agent_id TEXT,
run_id TEXT,
label TEXT,
task TEXT NOT NULL,
status TEXT NOT NULL,
delivery_status TEXT NOT NULL,
notify_policy TEXT NOT NULL,
created_at INTEGER NOT NULL,
started_at INTEGER,
ended_at INTEGER,
last_event_at INTEGER,
cleanup_after INTEGER,
error TEXT,
progress_summary TEXT,
terminal_summary TEXT,
terminal_outcome TEXT
);
`);
db.exec(`
CREATE TABLE task_delivery_state (
task_id TEXT PRIMARY KEY,
requester_origin_json TEXT,
last_notified_event_at INTEGER
);
`);
db.prepare(`
INSERT INTO task_runs (
task_id,
runtime,
source_id,
requester_session_key,
child_session_key,
run_id,
task,
status,
delivery_status,
notify_policy,
created_at,
last_event_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
"legacy-cron-task",
"cron",
"nightly-digest",
"",
"agent:main:cron:nightly-digest",
"legacy-cron-run",
"Nightly digest",
"running",
"not_applicable",
"silent",
100,
100,
);
db.close();
resetTaskRegistryForTests({ persist: false });
expect(findTaskByRunId("legacy-cron-run")).toMatchObject({
taskId: "legacy-cron-task",
ownerKey: "system:cron:nightly-digest",
scopeKind: "system",
deliveryStatus: "not_applicable",
notifyPolicy: "silent",
});
});
});

View File

@ -9,11 +9,12 @@ import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-even
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
createTaskRecord,
findLatestTaskForSessionKey,
findLatestTaskForOwnerKey,
findLatestTaskForRelatedSessionKey,
findTaskByRunId,
getTaskById,
getTaskRegistrySummary,
listTasksForSessionKey,
listTasksForOwnerKey,
listTaskRecords,
maybeDeliverTaskStateChangeUpdate,
maybeDeliverTaskTerminalUpdate,
@ -133,7 +134,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:child",
runId: "run-1",
task: "Do the thing",
@ -173,7 +175,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
runId: "run-summary-acp",
task: "Investigate issue",
status: "queued",
@ -181,7 +184,8 @@ describe("task-registry", () => {
});
createTaskRecord({
runtime: "cron",
requesterSessionKey: "",
ownerKey: "system:cron:run-summary-cron",
scopeKind: "system",
runId: "run-summary-cron",
task: "Daily digest",
status: "running",
@ -189,7 +193,8 @@ describe("task-registry", () => {
});
createTaskRecord({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
runId: "run-summary-subagent",
task: "Write patch",
status: "timed_out",
@ -232,7 +237,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -286,7 +292,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -332,7 +339,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -368,7 +376,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:child",
runId: "run-session-queued",
task: "Investigate issue",
@ -406,7 +415,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:child",
runId: "run-session-blocked",
task: "Port the repo changes",
@ -443,7 +453,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -494,7 +505,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -535,7 +547,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -569,7 +582,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "cli",
requesterSessionKey: "agent:codex:acp:child",
ownerKey: "agent:codex:acp:child",
scopeKind: "session",
childSessionKey: "agent:codex:acp:child",
runId: "run-shared",
task: "Child ACP execution",
@ -579,7 +593,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:codex:acp:child",
runId: "run-shared",
task: "Spawn ACP child",
@ -607,7 +622,8 @@ describe("task-registry", () => {
const directTask = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -620,7 +636,8 @@ describe("task-registry", () => {
});
const spawnedTask = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -655,7 +672,8 @@ describe("task-registry", () => {
const directTask = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -669,7 +687,8 @@ describe("task-registry", () => {
const spawnedTask = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -699,7 +718,8 @@ describe("task-registry", () => {
const spawnedTask = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -713,7 +733,8 @@ describe("task-registry", () => {
const directTask = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -744,7 +765,8 @@ describe("task-registry", () => {
const task = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -784,7 +806,8 @@ describe("task-registry", () => {
const task = createTaskRecord({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:subagent:child",
runId: "run-restore",
task: "Restore me",
@ -813,26 +836,30 @@ describe("task-registry", () => {
const older = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:subagent:child-1",
runId: "run-session-lookup-1",
task: "Older task",
});
const latest = createTaskRecord({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:subagent:child-2",
runId: "run-session-lookup-2",
task: "Latest task",
});
nowSpy.mockRestore();
expect(findLatestTaskForSessionKey("agent:main:main")?.taskId).toBe(latest.taskId);
expect(listTasksForSessionKey("agent:main:main").map((task) => task.taskId)).toEqual([
expect(findLatestTaskForOwnerKey("agent:main:main")?.taskId).toBe(latest.taskId);
expect(listTasksForOwnerKey("agent:main:main").map((task) => task.taskId)).toEqual([
latest.taskId,
older.taskId,
]);
expect(findLatestTaskForSessionKey("agent:main:subagent:child-1")?.taskId).toBe(older.taskId);
expect(findLatestTaskForRelatedSessionKey("agent:main:subagent:child-1")?.taskId).toBe(
older.taskId,
);
});
});
@ -843,7 +870,8 @@ describe("task-registry", () => {
const task = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:missing",
runId: "run-lost",
task: "Missing child",
@ -876,7 +904,8 @@ describe("task-registry", () => {
const task = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:acp:missing",
runId: "run-lost-maintenance",
task: "Missing child",
@ -908,7 +937,8 @@ describe("task-registry", () => {
const task = createTaskRecord({
runtime: "cli",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
childSessionKey: "agent:main:main",
runId: "run-prune",
task: "Old completed task",
@ -945,7 +975,8 @@ describe("task-registry", () => {
{
taskId: "task-missing-cleanup",
runtime: "cron",
requesterSessionKey: "",
ownerKey: "system:cron:run-maintenance-cleanup",
scopeKind: "system",
runId: "run-maintenance-cleanup",
task: "Finished cron",
status: "failed",
@ -992,7 +1023,8 @@ describe("task-registry", () => {
{
taskId: "task-audit-summary",
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
runId: "run-audit-summary",
task: "Hung task",
status: "running",
@ -1038,7 +1070,8 @@ describe("task-registry", () => {
const task = createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "discord",
to: "discord:123",
@ -1095,7 +1128,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "discord",
to: "discord:123",
@ -1167,7 +1201,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "discord",
to: "discord:123",
@ -1218,7 +1253,8 @@ describe("task-registry", () => {
createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "discord",
to: "discord:123",
@ -1276,7 +1312,8 @@ describe("task-registry", () => {
const task = registry.createTaskRecord({
runtime: "acp",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
@ -1337,7 +1374,8 @@ describe("task-registry", () => {
const task = registry.createTaskRecord({
runtime: "subagent",
requesterSessionKey: "agent:main:main",
ownerKey: "agent:main:main",
scopeKind: "session",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",

View File

@ -35,6 +35,7 @@ import type {
TaskRegistrySummary,
TaskRegistrySnapshot,
TaskRuntime,
TaskScopeKind,
TaskStatus,
TaskTerminalOutcome,
} from "./task-registry.types.js";
@ -45,7 +46,8 @@ const DEFAULT_TASK_RETENTION_MS = 7 * 24 * 60 * 60_000;
const tasks = new Map<string, TaskRecord>();
const taskDeliveryStates = new Map<string, TaskDeliveryState>();
const taskIdsByRunId = new Map<string, Set<string>>();
const taskIdsBySessionKey = new Map<string, Set<string>>();
const taskIdsByOwnerKey = new Map<string, Set<string>>();
const taskIdsByRelatedSessionKey = new Map<string, Set<string>>();
const tasksWithPendingDelivery = new Set<string>();
let listenerStarted = false;
let listenerStop: (() => void) | null = null;
@ -54,10 +56,17 @@ let deliveryRuntimePromise: Promise<typeof import("./task-registry-delivery-runt
null;
type TaskDeliveryOwner = {
sessionKey: string;
sessionKey?: string;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
};
function assertTaskOwner(params: { ownerKey: string; scopeKind: TaskScopeKind }) {
const ownerKey = params.ownerKey.trim();
if (!ownerKey) {
throw new Error("Task ownerKey is required.");
}
}
function cloneTaskRecord(record: TaskRecord): TaskRecord {
return { ...record };
}
@ -143,19 +152,31 @@ function persistTaskDeliveryStateDelete(taskId: string) {
});
}
function ensureDeliveryStatus(requesterSessionKey: string): TaskDeliveryStatus {
return requesterSessionKey.trim() ? "pending" : "parent_missing";
function ensureDeliveryStatus(params: {
ownerKey: string;
scopeKind: TaskScopeKind;
}): TaskDeliveryStatus {
if (params.scopeKind === "system") {
return "not_applicable";
}
return params.ownerKey.trim() ? "pending" : "parent_missing";
}
function ensureNotifyPolicy(params: {
notifyPolicy?: TaskNotifyPolicy;
deliveryStatus?: TaskDeliveryStatus;
requesterSessionKey: string;
ownerKey: string;
scopeKind: TaskScopeKind;
}): TaskNotifyPolicy {
if (params.notifyPolicy) {
return params.notifyPolicy;
}
const deliveryStatus = params.deliveryStatus ?? ensureDeliveryStatus(params.requesterSessionKey);
const deliveryStatus =
params.deliveryStatus ??
ensureDeliveryStatus({
ownerKey: params.ownerKey,
scopeKind: params.scopeKind,
});
return deliveryStatus === "not_applicable" ? "silent" : "done_only";
}
@ -229,46 +250,68 @@ function normalizeSessionIndexKey(sessionKey?: string): string | undefined {
return trimmed ? trimmed : undefined;
}
function getTaskSessionIndexKeys(
task: Pick<TaskRecord, "requesterSessionKey" | "childSessionKey">,
) {
function addIndexedKey(index: Map<string, Set<string>>, key: string, taskId: string) {
let ids = index.get(key);
if (!ids) {
ids = new Set<string>();
index.set(key, ids);
}
ids.add(taskId);
}
function deleteIndexedKey(index: Map<string, Set<string>>, key: string, taskId: string) {
const ids = index.get(key);
if (!ids) {
return;
}
ids.delete(taskId);
if (ids.size === 0) {
index.delete(key);
}
}
function getTaskRelatedSessionIndexKeys(task: Pick<TaskRecord, "ownerKey" | "childSessionKey">) {
return [
...new Set(
[
normalizeSessionIndexKey(task.requesterSessionKey),
normalizeSessionIndexKey(task.ownerKey),
normalizeSessionIndexKey(task.childSessionKey),
].filter(Boolean) as string[],
),
];
}
function addSessionKeyIndex(
function addOwnerKeyIndex(taskId: string, task: Pick<TaskRecord, "ownerKey">) {
const key = normalizeSessionIndexKey(task.ownerKey);
if (!key) {
return;
}
addIndexedKey(taskIdsByOwnerKey, key, taskId);
}
function deleteOwnerKeyIndex(taskId: string, task: Pick<TaskRecord, "ownerKey">) {
const key = normalizeSessionIndexKey(task.ownerKey);
if (!key) {
return;
}
deleteIndexedKey(taskIdsByOwnerKey, key, taskId);
}
function addRelatedSessionKeyIndex(
taskId: string,
task: Pick<TaskRecord, "requesterSessionKey" | "childSessionKey">,
task: Pick<TaskRecord, "ownerKey" | "childSessionKey">,
) {
for (const sessionKey of getTaskSessionIndexKeys(task)) {
let ids = taskIdsBySessionKey.get(sessionKey);
if (!ids) {
ids = new Set<string>();
taskIdsBySessionKey.set(sessionKey, ids);
}
ids.add(taskId);
for (const sessionKey of getTaskRelatedSessionIndexKeys(task)) {
addIndexedKey(taskIdsByRelatedSessionKey, sessionKey, taskId);
}
}
function deleteSessionKeyIndex(
function deleteRelatedSessionKeyIndex(
taskId: string,
task: Pick<TaskRecord, "requesterSessionKey" | "childSessionKey">,
task: Pick<TaskRecord, "ownerKey" | "childSessionKey">,
) {
for (const sessionKey of getTaskSessionIndexKeys(task)) {
const ids = taskIdsBySessionKey.get(sessionKey);
if (!ids) {
continue;
}
ids.delete(taskId);
if (ids.size === 0) {
taskIdsBySessionKey.delete(sessionKey);
}
for (const sessionKey of getTaskRelatedSessionIndexKeys(task)) {
deleteIndexedKey(taskIdsByRelatedSessionKey, sessionKey, taskId);
}
}
@ -279,10 +322,17 @@ function rebuildRunIdIndex() {
}
}
function rebuildSessionKeyIndex() {
taskIdsBySessionKey.clear();
function rebuildOwnerKeyIndex() {
taskIdsByOwnerKey.clear();
for (const [taskId, task] of tasks.entries()) {
addSessionKeyIndex(taskId, task);
addOwnerKeyIndex(taskId, task);
}
}
function rebuildRelatedSessionKeyIndex() {
taskIdsByRelatedSessionKey.clear();
for (const [taskId, task] of tasks.entries()) {
addRelatedSessionKeyIndex(taskId, task);
}
}
@ -328,7 +378,8 @@ function compareTasksNewestFirst(
function findExistingTaskForCreate(params: {
runtime: TaskRuntime;
requesterSessionKey: string;
ownerKey: string;
scopeKind: TaskScopeKind;
childSessionKey?: string;
runId?: string;
label?: string;
@ -339,8 +390,8 @@ function findExistingTaskForCreate(params: {
? getTasksByRunId(runId).find(
(task) =>
task.runtime === params.runtime &&
normalizeComparableText(task.requesterSessionKey) ===
normalizeComparableText(params.requesterSessionKey) &&
task.scopeKind === params.scopeKind &&
normalizeComparableText(task.ownerKey) === normalizeComparableText(params.ownerKey) &&
normalizeComparableText(task.childSessionKey) ===
normalizeComparableText(params.childSessionKey) &&
normalizeComparableText(task.label) === normalizeComparableText(params.label) &&
@ -356,8 +407,8 @@ function findExistingTaskForCreate(params: {
const siblingMatches = getTasksByRunId(runId).filter(
(task) =>
task.runtime === params.runtime &&
normalizeComparableText(task.requesterSessionKey) ===
normalizeComparableText(params.requesterSessionKey) &&
task.scopeKind === params.scopeKind &&
normalizeComparableText(task.ownerKey) === normalizeComparableText(params.ownerKey) &&
normalizeComparableText(task.childSessionKey) ===
normalizeComparableText(params.childSessionKey),
);
@ -418,7 +469,8 @@ function mergeExistingTaskForCreate(
const notifyPolicy = ensureNotifyPolicy({
notifyPolicy: params.notifyPolicy,
deliveryStatus: params.deliveryStatus,
requesterSessionKey: existing.requesterSessionKey,
ownerKey: existing.ownerKey,
scopeKind: existing.scopeKind,
});
if (notifyPolicy !== existing.notifyPolicy && existing.notifyPolicy === "silent") {
patch.notifyPolicy = notifyPolicy;
@ -447,8 +499,11 @@ function resolveTaskTerminalIdempotencyKey(task: TaskRecord): string {
}
function resolveTaskDeliveryOwner(task: TaskRecord): TaskDeliveryOwner {
if (task.scopeKind !== "session") {
return {};
}
return {
sessionKey: task.requesterSessionKey.trim(),
sessionKey: task.ownerKey.trim(),
requesterOrigin: normalizeDeliveryContext(taskDeliveryStates.get(task.taskId)?.requesterOrigin),
};
}
@ -470,7 +525,8 @@ function restoreTaskRegistryOnce() {
taskDeliveryStates.set(taskId, state);
}
rebuildRunIdIndex();
rebuildSessionKeyIndex();
rebuildOwnerKeyIndex();
rebuildRelatedSessionKeyIndex();
emitTaskRegistryHookEvent(() => ({
kind: "restored",
tasks: snapshotTaskRecords(tasks),
@ -496,8 +552,7 @@ function updateTask(taskId: string, patch: Partial<TaskRecord>): TaskRecord | nu
next.cleanupAfter = terminalAt + DEFAULT_TASK_RETENTION_MS;
}
const sessionIndexChanged =
normalizeSessionIndexKey(current.requesterSessionKey) !==
normalizeSessionIndexKey(next.requesterSessionKey) ||
normalizeSessionIndexKey(current.ownerKey) !== normalizeSessionIndexKey(next.ownerKey) ||
normalizeSessionIndexKey(current.childSessionKey) !==
normalizeSessionIndexKey(next.childSessionKey);
tasks.set(taskId, next);
@ -505,8 +560,10 @@ function updateTask(taskId: string, patch: Partial<TaskRecord>): TaskRecord | nu
rebuildRunIdIndex();
}
if (sessionIndexChanged) {
deleteSessionKeyIndex(taskId, current);
addSessionKeyIndex(taskId, next);
deleteOwnerKeyIndex(taskId, current);
addOwnerKeyIndex(taskId, next);
deleteRelatedSessionKeyIndex(taskId, current);
addRelatedSessionKeyIndex(taskId, next);
}
persistTaskUpsert(next);
emitTaskRegistryHookEvent(() => ({
@ -548,20 +605,24 @@ function canDeliverTaskToRequesterOrigin(task: TaskRecord): boolean {
return Boolean(channel && to && isDeliverableMessageChannel(channel));
}
function resolveMissingOwnerDeliveryStatus(task: TaskRecord): TaskDeliveryStatus {
return task.scopeKind === "system" ? "not_applicable" : "parent_missing";
}
function queueTaskSystemEvent(task: TaskRecord, text: string) {
const owner = resolveTaskDeliveryOwner(task);
const requesterSessionKey = owner.sessionKey.trim();
if (!requesterSessionKey) {
const ownerKey = owner.sessionKey?.trim();
if (!ownerKey) {
return false;
}
enqueueSystemEvent(text, {
sessionKey: requesterSessionKey,
sessionKey: ownerKey,
contextKey: `task:${task.taskId}`,
deliveryContext: owner.requesterOrigin,
});
requestHeartbeatNow({
reason: "background-task",
sessionKey: requesterSessionKey,
sessionKey: ownerKey,
});
return true;
}
@ -572,18 +633,18 @@ function queueBlockedTaskFollowup(task: TaskRecord) {
return false;
}
const owner = resolveTaskDeliveryOwner(task);
const requesterSessionKey = owner.sessionKey.trim();
if (!requesterSessionKey) {
const ownerKey = owner.sessionKey?.trim();
if (!ownerKey) {
return false;
}
enqueueSystemEvent(followupText, {
sessionKey: requesterSessionKey,
sessionKey: ownerKey,
contextKey: `task:${task.taskId}:blocked-followup`,
deliveryContext: owner.requesterOrigin,
});
requestHeartbeatNow({
reason: "background-task-blocked",
sessionKey: requesterSessionKey,
sessionKey: ownerKey,
});
return true;
}
@ -615,9 +676,10 @@ export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<Ta
});
}
const owner = resolveTaskDeliveryOwner(latest);
if (!owner.sessionKey.trim()) {
const ownerSessionKey = owner.sessionKey?.trim();
if (!ownerSessionKey) {
return updateTask(taskId, {
deliveryStatus: "parent_missing",
deliveryStatus: resolveMissingOwnerDeliveryStatus(latest),
lastEventAt: Date.now(),
});
}
@ -635,7 +697,7 @@ export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<Ta
} catch (error) {
log.warn("Failed to queue background task session delivery", {
taskId,
requesterSessionKey: latest.requesterSessionKey,
ownerKey: latest.ownerKey,
error,
});
return updateTask(taskId, {
@ -646,7 +708,7 @@ export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<Ta
}
try {
const { sendMessage } = await loadTaskRegistryDeliveryRuntime();
const requesterAgentId = parseAgentSessionKey(owner.sessionKey)?.agentId;
const requesterAgentId = parseAgentSessionKey(ownerSessionKey)?.agentId;
const idempotencyKey = resolveTaskTerminalIdempotencyKey(latest);
await sendMessage({
channel: owner.requesterOrigin?.channel,
@ -657,7 +719,7 @@ export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<Ta
agentId: requesterAgentId,
idempotencyKey,
mirror: {
sessionKey: owner.sessionKey,
sessionKey: ownerSessionKey,
agentId: requesterAgentId,
idempotencyKey,
},
@ -672,7 +734,7 @@ export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<Ta
} catch (error) {
log.warn("Failed to deliver background task update", {
taskId,
requesterSessionKey: owner.sessionKey,
ownerKey: ownerSessionKey,
requesterOrigin: owner.requesterOrigin,
error,
});
@ -684,7 +746,7 @@ export async function maybeDeliverTaskTerminalUpdate(taskId: string): Promise<Ta
} catch (fallbackError) {
log.warn("Failed to queue background task fallback event", {
taskId,
requesterSessionKey: latest.requesterSessionKey,
ownerKey: latest.ownerKey,
error: fallbackError,
});
}
@ -717,6 +779,13 @@ export async function maybeDeliverTaskStateChangeUpdate(
}
try {
const owner = resolveTaskDeliveryOwner(current);
const ownerSessionKey = owner.sessionKey?.trim();
if (!ownerSessionKey) {
return updateTask(taskId, {
deliveryStatus: resolveMissingOwnerDeliveryStatus(current),
lastEventAt: Date.now(),
});
}
if (!canDeliverTaskToRequesterOrigin(current)) {
queueTaskSystemEvent(current, eventText);
upsertTaskDeliveryState({
@ -729,7 +798,7 @@ export async function maybeDeliverTaskStateChangeUpdate(
});
}
const { sendMessage } = await loadTaskRegistryDeliveryRuntime();
const requesterAgentId = parseAgentSessionKey(owner.sessionKey)?.agentId;
const requesterAgentId = parseAgentSessionKey(ownerSessionKey)?.agentId;
const idempotencyKey = resolveTaskStateChangeIdempotencyKey({
task: current,
latestEvent,
@ -744,7 +813,7 @@ export async function maybeDeliverTaskStateChangeUpdate(
agentId: requesterAgentId,
idempotencyKey,
mirror: {
sessionKey: owner.sessionKey,
sessionKey: ownerSessionKey,
agentId: requesterAgentId,
idempotencyKey,
},
@ -760,7 +829,7 @@ export async function maybeDeliverTaskStateChangeUpdate(
} catch (error) {
log.warn("Failed to deliver background task state change", {
taskId,
requesterSessionKey: current.requesterSessionKey,
ownerKey: current.ownerKey,
error,
});
return cloneTaskRecord(current);
@ -940,7 +1009,8 @@ function ensureListener() {
export function createTaskRecord(params: {
runtime: TaskRuntime;
sourceId?: string;
requesterSessionKey: string;
ownerKey: string;
scopeKind: TaskScopeKind;
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
childSessionKey?: string;
parentTaskId?: string;
@ -960,6 +1030,10 @@ export function createTaskRecord(params: {
terminalOutcome?: TaskTerminalOutcome | null;
}): TaskRecord {
ensureTaskRegistryReady();
assertTaskOwner({
ownerKey: params.ownerKey,
scopeKind: params.scopeKind,
});
const existing = findExistingTaskForCreate(params);
if (existing) {
return mergeExistingTaskForCreate(existing, params);
@ -967,18 +1041,26 @@ export function createTaskRecord(params: {
const now = Date.now();
const taskId = crypto.randomUUID();
const status = normalizeTaskStatus(params.status);
const deliveryStatus = params.deliveryStatus ?? ensureDeliveryStatus(params.requesterSessionKey);
const ownerKey = params.ownerKey.trim();
const deliveryStatus =
params.deliveryStatus ??
ensureDeliveryStatus({
ownerKey,
scopeKind: params.scopeKind,
});
const notifyPolicy = ensureNotifyPolicy({
notifyPolicy: params.notifyPolicy,
deliveryStatus,
requesterSessionKey: params.requesterSessionKey,
ownerKey,
scopeKind: params.scopeKind,
});
const lastEventAt = params.lastEventAt ?? params.startedAt ?? now;
const record: TaskRecord = {
taskId,
runtime: params.runtime,
sourceId: params.sourceId?.trim() || undefined,
requesterSessionKey: params.requesterSessionKey,
ownerKey,
scopeKind: params.scopeKind,
childSessionKey: params.childSessionKey,
parentTaskId: params.parentTaskId?.trim() || undefined,
agentId: params.agentId?.trim() || undefined,
@ -1009,7 +1091,8 @@ export function createTaskRecord(params: {
requesterOrigin: normalizeDeliveryContext(params.requesterOrigin),
});
addRunIdIndex(taskId, record.runId);
addSessionKeyIndex(taskId, record);
addOwnerKeyIndex(taskId, record);
addRelatedSessionKeyIndex(taskId, record);
persistTaskUpsert(record);
emitTaskRegistryHookEvent(() => ({
kind: "upserted",
@ -1300,18 +1383,8 @@ export function findTaskByRunId(runId: string): TaskRecord | undefined {
return task ? cloneTaskRecord(task) : undefined;
}
export function findLatestTaskForSessionKey(sessionKey: string): TaskRecord | undefined {
const task = listTasksForSessionKey(sessionKey)[0];
return task ? cloneTaskRecord(task) : undefined;
}
export function listTasksForSessionKey(sessionKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeSessionIndexKey(sessionKey);
if (!key) {
return [];
}
const ids = taskIdsBySessionKey.get(key);
function listTasksFromIndex(index: Map<string, Set<string>>, key: string): TaskRecord[] {
const ids = index.get(key);
if (!ids || ids.size === 0) {
return [];
}
@ -1331,12 +1404,42 @@ export function listTasksForSessionKey(sessionKey: string): TaskRecord[] {
.map(({ insertionIndex: _, ...task }) => task);
}
export function findLatestTaskForOwnerKey(ownerKey: string): TaskRecord | undefined {
const task = listTasksForOwnerKey(ownerKey)[0];
return task ? cloneTaskRecord(task) : undefined;
}
export function listTasksForOwnerKey(ownerKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeSessionIndexKey(ownerKey);
if (!key) {
return [];
}
return listTasksFromIndex(taskIdsByOwnerKey, key);
}
export function findLatestTaskForRelatedSessionKey(sessionKey: string): TaskRecord | undefined {
const task = listTasksForRelatedSessionKey(sessionKey)[0];
return task ? cloneTaskRecord(task) : undefined;
}
export function listTasksForRelatedSessionKey(sessionKey: string): TaskRecord[] {
ensureTaskRegistryReady();
const key = normalizeSessionIndexKey(sessionKey);
if (!key) {
return [];
}
return listTasksFromIndex(taskIdsByRelatedSessionKey, key);
}
export function resolveTaskForLookupToken(token: string): TaskRecord | undefined {
const lookup = token.trim();
if (!lookup) {
return undefined;
}
return getTaskById(lookup) ?? findTaskByRunId(lookup) ?? findLatestTaskForSessionKey(lookup);
return (
getTaskById(lookup) ?? findTaskByRunId(lookup) ?? findLatestTaskForRelatedSessionKey(lookup)
);
}
export function deleteTaskRecordById(taskId: string): boolean {
@ -1345,7 +1448,8 @@ export function deleteTaskRecordById(taskId: string): boolean {
if (!current) {
return false;
}
deleteSessionKeyIndex(taskId, current);
deleteOwnerKeyIndex(taskId, current);
deleteRelatedSessionKeyIndex(taskId, current);
tasks.delete(taskId);
taskDeliveryStates.delete(taskId);
rebuildRunIdIndex();
@ -1363,7 +1467,8 @@ export function resetTaskRegistryForTests(opts?: { persist?: boolean }) {
tasks.clear();
taskDeliveryStates.clear();
taskIdsByRunId.clear();
taskIdsBySessionKey.clear();
taskIdsByOwnerKey.clear();
taskIdsByRelatedSessionKey.clear();
tasksWithPendingDelivery.clear();
restoreAttempted = false;
resetTaskRegistryRuntimeForTests();

View File

@ -22,6 +22,7 @@ export type TaskDeliveryStatus =
export type TaskNotifyPolicy = "done_only" | "state_changes" | "silent";
export type TaskTerminalOutcome = "succeeded" | "blocked";
export type TaskScopeKind = "session" | "system";
export type TaskStatusCounts = Record<TaskStatus, number>;
export type TaskRuntimeCounts = Record<TaskRuntime, number>;
@ -53,7 +54,8 @@ export type TaskRecord = {
taskId: string;
runtime: TaskRuntime;
sourceId?: string;
requesterSessionKey: string;
ownerKey: string;
scopeKind: TaskScopeKind;
childSessionKey?: string;
parentTaskId?: string;
agentId?: string;