blob: 9100e4e67154ab94c6e0411fe8eeda83f47c3586 [file]
// Session-scoped task ledger primitive for the main agent. The model manages a
// flat task list via TaskCreate/TaskUpdate; each turn tail re-injects the
// current list. The durable contract is intentionally narrow: task status,
// compact evidence/reason fields, append-only task events, and conservative
// resume trust diagnostics. Priority, dependencies, and assignee fields remain
// out of scope.
import { redactSecrets } from './redaction.js';
export const TASK_SUBJECT_MAX_CHARS = 200;
export const TASK_EVIDENCE_MAX_CHARS = 1000;
/**
* Hard cap on total tasks per session ledger (any status). The full ledger is
* re-injected into every turn tail, so an unbounded ledger burns context on
* every turn; this is a runaway guard on the total count, not a workflow quota
* — completing or cancelling tasks does not free capacity.
*/
export const TASK_LEDGER_MAX_TASKS = 200;
export const TASK_ARCHIVE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Max length of a task id accepted on both the write and read paths. The write
* path generates randomUUID (36 chars); the bound leaves headroom for a future
* id format while keeping the turn-tail `id=` fielded render bounded.
*/
export const TASK_ID_MAX_CHARS = 64;
export const TASK_KEY_MAX_CHARS = 64;
export const TASK_LEDGER_PROMPT_MAX_CHARS = 8_000;
export const TASK_LEDGER_PROMPT_RECENT_TERMINAL = 3;
export const TASK_STATUSES = [
'pending',
'in_progress',
'blocked',
'completed',
'failed',
'cancelled',
] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const TASK_TERMINAL_STATUSES = ['completed', 'failed', 'cancelled'] as const;
export function isTerminalTaskStatus(status: TaskStatus): boolean {
return (TASK_TERMINAL_STATUSES as readonly TaskStatus[]).includes(status);
}
export const TASK_RESUME_TRUST_LEVELS = [
'trusted',
'needs_revalidation',
'stale',
'repaired',
'untrusted',
] as const;
export type ResumeTrust = (typeof TASK_RESUME_TRUST_LEVELS)[number];
export interface TaskOwner {
actor: 'main_agent' | 'child_agent';
/** Owning child Session for durable subagent work; absent on legacy child AgentRuns. */
sessionId?: string;
agentId?: string;
runId?: string;
turnId?: string;
}
export interface Task {
id: string;
key: string;
subject: string;
status: TaskStatus;
createdAt: number;
updatedAt: number;
parentId?: string;
owner?: TaskOwner;
endedAt?: number;
blockedReason?: string;
failureReason?: string;
completionEvidence?: string;
resumeTrust?: ResumeTrust;
}
export interface TaskLedgerMutationContext {
runId?: string;
turnId?: string;
toolCallId?: string;
source?: 'tool' | 'system' | 'recovery' | 'import';
actor?: 'main_agent' | 'child_agent' | 'user' | 'system';
reason?: string;
}
export interface TaskLedgerChangedEvent {
sessionId: string;
taskIds: string[];
at: number;
}
export interface TaskAgentOutcome {
status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user';
owner: TaskOwner;
reason?: string;
}
export interface TaskAvailableClaimScope {
/** Main AgentRun that made this task available to its child team. */
parentRunId: string;
}
/**
* Store contract shared by the storage implementation and the runtime tools.
* Mutations return the changed task(s) and the new total, computed inside the
* store's serialized write section, so callers render exactly the state their
* mutation produced instead of re-reading outside the write queue. The full
* ledger never leaves the store through the mutation result.
*/
export interface TaskLedgerStore {
list(sessionId: string, options?: TaskLedgerListOptions): Promise<Task[]>;
get(sessionId: string, id: string, options?: TaskLedgerListOptions): Promise<Task | undefined>;
create(
sessionId: string,
drafts: unknown,
context?: TaskLedgerMutationContext,
): Promise<{ created: Task[]; total: number }>;
update(
sessionId: string,
id: string,
patch: unknown,
context?: TaskLedgerMutationContext,
): Promise<{ updated: Task; total: number }>;
claim(
sessionId: string,
id: string,
owner: TaskOwner,
context?: TaskLedgerMutationContext,
): Promise<{ updated: Task; total: number }>;
claimAvailable(
sessionId: string,
id: string,
owner: TaskOwner,
scope: TaskAvailableClaimScope,
context?: TaskLedgerMutationContext,
): Promise<{ updated: Task; total: number }>;
settleAgentOutcome(
sessionId: string,
id: string,
outcome: TaskAgentOutcome,
context?: TaskLedgerMutationContext,
): Promise<{ updated: Task; total: number }>;
subscribe(listener: (event: TaskLedgerChangedEvent) => void): () => void;
}
export interface TaskLedgerListOptions {
classifyResumeTrust?: boolean;
status?: TaskStatus;
includeTerminal?: boolean;
includeArchived?: boolean;
now?: number;
}
export interface CreateTaskInput {
subject: unknown;
parentId?: unknown;
}
export interface UpdateTaskInput {
subject?: unknown;
status?: unknown;
blockedReason?: unknown;
failureReason?: unknown;
completionEvidence?: unknown;
explicitReopen?: unknown;
}
export type TaskLedgerNormalizeResult<T> =
| { ok: true; value: T }
| {
ok: false;
reason:
| 'invalid_subject'
| 'invalid_status'
| 'invalid_blocked_reason'
| 'invalid_failure_reason'
| 'invalid_completion_evidence'
| 'invalid_resume_trust'
| 'invalid_transition'
| 'empty_patch';
message: string;
};
type TaskLedgerNormalizeErrorReason = Extract<
TaskLedgerNormalizeResult<never>,
{ ok: false }
>['reason'];
export function isTaskStatus(value: unknown): value is TaskStatus {
return typeof value === 'string' && (TASK_STATUSES as readonly string[]).includes(value);
}
export function isResumeTrust(value: unknown): value is ResumeTrust {
return (
typeof value === 'string' && (TASK_RESUME_TRUST_LEVELS as readonly string[]).includes(value)
);
}
/**
* Stable-token id contract shared by the runtime tool schema (front-door) and
* the storage read path. The id is rendered verbatim (see
* renderSafeTaskLedgerText), so it must not be deformable by any face that has
* ever rendered it: no angle brackets/slashes/quotes/parens/equals (a past
* whole-string tag strip would have eaten them; even the fielded renderer
* emits the id bare), no whitespace (would break the list-line structure), no
* huge length (would bloat every turn tail), and redaction-stable (a renderer
* that runs redactSecrets must not turn the id into [redacted] while the store
* keeps the real id -- a later TaskUpdate would miss). The whitelist
* (alphanumeric plus . _ : -, 1-64 chars) plus redactSecrets(id) === id enforces
* these constraints without coupling to the UUID format.
*/
export function isSafeTaskId(value: unknown): value is string {
// Stable token (alphanumeric plus . _ : -, 1-64 chars) AND redaction-stable:
// the id is rendered verbatim, so a secret-shaped id (ghp_..., sk-..., a
// 40-char hex, AIza...) must be rejected -- otherwise a renderer that does
// run redactSecrets would turn it into [redacted] while the store keeps the
// real id, and a later TaskUpdate would miss.
return (
typeof value === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(value) &&
redactSecrets(value) === value
);
}
export function isTaskKey(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length <= TASK_KEY_MAX_CHARS &&
/^T[1-9]\d*(?:\.[1-9]\d*)*$/.test(value)
);
}
export function compareTaskKeys(left: string, right: string): number {
const a = left.slice(1).split('.').map(Number);
const b = right.slice(1).split('.').map(Number);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if (a[index] === undefined) return -1;
if (b[index] === undefined) return 1;
if (a[index] !== b[index]) return a[index]! - b[index]!;
}
return 0;
}
export function findTaskByRef(tasks: readonly Task[], ref: string): Task | undefined {
return tasks.find((task) => task.id === ref || task.key === ref);
}
export function normalizeTaskSubject(input: unknown): TaskLedgerNormalizeResult<string> {
if (typeof input !== 'string') {
return invalid('invalid_subject', 'Task subject must be a string');
}
const subject = input.normalize('NFC').replace(/\s+/g, ' ').trim();
if (subject.length === 0) {
return invalid('invalid_subject', 'Task subject cannot be empty');
}
if (Array.from(subject).length > TASK_SUBJECT_MAX_CHARS) {
return invalid(
'invalid_subject',
`Task subject must be ${TASK_SUBJECT_MAX_CHARS} characters or fewer`,
);
}
return { ok: true, value: subject };
}
export function normalizeTaskStatus(input: unknown): TaskLedgerNormalizeResult<TaskStatus> {
if (!isTaskStatus(input)) {
return invalid('invalid_status', `Task status must be one of ${TASK_STATUSES.join(', ')}`);
}
return { ok: true, value: input };
}
export function normalizeResumeTrust(input: unknown): TaskLedgerNormalizeResult<ResumeTrust> {
if (!isResumeTrust(input)) {
return invalid(
'invalid_resume_trust',
`Task resumeTrust must be one of ${TASK_RESUME_TRUST_LEVELS.join(', ')}`,
);
}
return { ok: true, value: input };
}
export function normalizeTaskEvidenceText(
input: unknown,
field: 'blockedReason' | 'failureReason' | 'completionEvidence',
): TaskLedgerNormalizeResult<string> {
if (typeof input !== 'string') {
return invalid(evidenceReason(field), `${field} must be a string`);
}
const value = input.normalize('NFC').replace(/\s+/g, ' ').trim();
if (value.length === 0) {
return invalid(evidenceReason(field), `${field} cannot be empty`);
}
if (Array.from(value).length > TASK_EVIDENCE_MAX_CHARS) {
return invalid(
evidenceReason(field),
`${field} must be ${TASK_EVIDENCE_MAX_CHARS} characters or fewer`,
);
}
return { ok: true, value };
}
export function normalizeCreateTaskInput(
input: unknown,
): TaskLedgerNormalizeResult<{ subject: string; parentId?: string }> {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return invalid('invalid_subject', 'Task input must be an object');
}
const record = input as CreateTaskInput;
const subject = normalizeTaskSubject(record.subject);
if (!subject.ok) return subject;
if (record.parentId !== undefined && !isSafeTaskId(record.parentId)) {
return invalid('invalid_subject', 'Task parentId must be a stable task id or key');
}
return {
ok: true,
value: { subject: subject.value, ...(record.parentId ? { parentId: record.parentId } : {}) },
};
}
export function normalizeUpdateTaskInput(input: unknown): TaskLedgerNormalizeResult<{
subject?: string;
status?: TaskStatus;
blockedReason?: string;
failureReason?: string;
completionEvidence?: string;
explicitReopen?: boolean;
}> {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
return invalid('empty_patch', 'Task update must be an object');
}
const record = input as UpdateTaskInput;
const patch: {
subject?: string;
status?: TaskStatus;
blockedReason?: string;
failureReason?: string;
completionEvidence?: string;
explicitReopen?: boolean;
} = {};
if (record.subject !== undefined) {
const subject = normalizeTaskSubject(record.subject);
if (!subject.ok) return subject;
patch.subject = subject.value;
}
if (record.status !== undefined) {
const status = normalizeTaskStatus(record.status);
if (!status.ok) return status;
patch.status = status.value;
}
if (record.blockedReason !== undefined) {
const blockedReason = normalizeTaskEvidenceText(record.blockedReason, 'blockedReason');
if (!blockedReason.ok) return blockedReason;
patch.blockedReason = blockedReason.value;
}
if (record.failureReason !== undefined) {
const failureReason = normalizeTaskEvidenceText(record.failureReason, 'failureReason');
if (!failureReason.ok) return failureReason;
patch.failureReason = failureReason.value;
}
if (record.completionEvidence !== undefined) {
const completionEvidence = normalizeTaskEvidenceText(
record.completionEvidence,
'completionEvidence',
);
if (!completionEvidence.ok) return completionEvidence;
patch.completionEvidence = completionEvidence.value;
}
if (record.explicitReopen !== undefined) {
patch.explicitReopen = record.explicitReopen === true;
}
if (
patch.subject === undefined &&
patch.status === undefined &&
patch.blockedReason === undefined &&
patch.failureReason === undefined &&
patch.completionEvidence === undefined &&
patch.explicitReopen === undefined
) {
return invalid('empty_patch', 'Task update must change at least one field');
}
return { ok: true, value: patch };
}
export interface TaskStatusTransitionOptions {
explicitReopen?: boolean;
}
export function canTransitionTaskStatus(
from: TaskStatus,
to: TaskStatus,
options: TaskStatusTransitionOptions = {},
): boolean {
if (from === to) return true;
switch (from) {
case 'pending':
return to === 'in_progress' || to === 'cancelled';
case 'in_progress':
return to === 'blocked' || to === 'completed' || to === 'failed' || to === 'cancelled';
case 'blocked':
return to === 'in_progress' || to === 'cancelled' || to === 'failed';
case 'failed':
return to === 'pending' || to === 'cancelled';
case 'completed':
return options.explicitReopen === true && to === 'in_progress';
case 'cancelled':
return options.explicitReopen === true && to === 'pending';
}
}
export function validateTaskEvidence(
task: Pick<Task, 'status' | 'blockedReason' | 'failureReason' | 'completionEvidence'>,
): TaskLedgerNormalizeResult<void> {
if (task.status === 'blocked' && !task.blockedReason) {
return invalid('invalid_blocked_reason', 'Blocked tasks require blockedReason');
}
if (task.status === 'failed' && !task.failureReason) {
return invalid('invalid_failure_reason', 'Failed tasks require failureReason');
}
if (task.status === 'completed' && !task.completionEvidence) {
return invalid('invalid_completion_evidence', 'Completed tasks require completionEvidence');
}
return { ok: true, value: undefined };
}
export interface ValidateTaskUpdateOptions extends TaskStatusTransitionOptions {}
export function validateTaskUpdate(
previousTask: Task,
input: unknown,
options: ValidateTaskUpdateOptions = {},
): TaskLedgerNormalizeResult<{
subject?: string;
status?: TaskStatus;
blockedReason?: string;
failureReason?: string;
completionEvidence?: string;
explicitReopen?: boolean;
}> {
const normalized = normalizeUpdateTaskInput(input);
if (!normalized.ok) return normalized;
const { explicitReopen: patchExplicitReopen, ...taskPatch } = normalized.value;
const nextTask: Task = {
...previousTask,
...taskPatch,
};
const explicitReopen = options.explicitReopen === true || patchExplicitReopen === true;
if (
normalized.value.status !== undefined &&
!canTransitionTaskStatus(previousTask.status, normalized.value.status, {
...options,
explicitReopen,
})
) {
return invalid(
'invalid_transition',
`Invalid task status transition from ${previousTask.status} to ${normalized.value.status}`,
);
}
const evidence = validateTaskEvidence(nextTask);
if (!evidence.ok) return evidence;
return normalized;
}
export interface TaskResumeTrustRefs {
corruptLedger?: boolean;
missingReferences?: boolean;
interrupted?: boolean;
repaired?: boolean;
needsRevalidation?: boolean;
}
export function classifyTaskResumeTrust(
task: Pick<Task, 'status' | 'blockedReason' | 'failureReason' | 'completionEvidence'>,
refs: TaskResumeTrustRefs = {},
): ResumeTrust {
if (refs.corruptLedger || refs.missingReferences) return 'untrusted';
if (refs.repaired) return 'repaired';
if (refs.interrupted || task.status === 'in_progress') return 'stale';
if (refs.needsRevalidation) return 'needs_revalidation';
if (!validateTaskEvidence(task).ok) return 'needs_revalidation';
return 'trusted';
}
export const TASK_LEDGER_EVENT_TYPES = [
'task_created',
'task_updated',
'task_started',
'task_blocked',
'task_completed',
'task_failed',
'task_cancelled',
'task_reopened',
'task_resume_classified',
'task_repaired',
'task_imported',
] as const;
export type TaskLedgerEventType = (typeof TASK_LEDGER_EVENT_TYPES)[number];
export interface TaskLedgerEventRefs {
runId?: string;
turnId?: string;
toolCallId?: string;
}
/** Persisted snapshots from before task-ledger v2 legitimately lack these fields. */
export type TaskLedgerEventTaskSnapshot = Omit<Task, 'key'> & {
key?: string;
};
export interface TaskLedgerEvent {
eventId: string;
type: TaskLedgerEventType;
ts: number;
sessionId: string;
taskId: string;
previousStatus?: TaskStatus;
nextStatus: TaskStatus;
task: TaskLedgerEventTaskSnapshot;
reason?: string;
evidence?: string;
refs?: TaskLedgerEventRefs;
source?: TaskLedgerMutationContext['source'];
actor?: TaskLedgerMutationContext['actor'];
}
export interface TaskLedgerProjection {
tasks: Task[];
diagnostics: string[];
backfilledTaskIds: string[];
}
export function taskLedgerEventTypeForCreate(task: Task): TaskLedgerEventType {
return taskLedgerEventTypeForStatus(task.status, true);
}
export function taskLedgerEventTypeForUpdate(previous: Task, next: Task): TaskLedgerEventType {
if (previous.status === next.status) return 'task_updated';
if (
(previous.status === 'completed' && next.status === 'in_progress') ||
(previous.status === 'cancelled' && next.status === 'pending') ||
(previous.status === 'failed' && next.status === 'pending')
) {
return 'task_reopened';
}
return taskLedgerEventTypeForStatus(next.status, false);
}
export function projectTaskLedgerEvents(events: readonly TaskLedgerEvent[]): TaskLedgerProjection {
const tasks = new Map<string, TaskLedgerEventTaskSnapshot>();
const firstSeen = new Map<string, number>();
const diagnostics: string[] = [];
for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) {
const event = events[eventIndex]!;
if (!isTaskLedgerEvent(event)) {
diagnostics.push('invalid task ledger event shape');
continue;
}
const current = tasks.get(event.taskId);
const typeDiagnostic = validateTaskLedgerEventType(event, current);
if (typeDiagnostic) diagnostics.push(typeDiagnostic);
if (event.type === 'task_created' || event.type === 'task_imported') {
if (current) diagnostics.push(`duplicate ${event.type} for ${event.taskId}`);
if (!firstSeen.has(event.taskId)) firstSeen.set(event.taskId, eventIndex);
tasks.set(event.taskId, { ...event.task });
continue;
}
if (!current) {
diagnostics.push(`task event ${event.type} references unknown task ${event.taskId}`);
if (!firstSeen.has(event.taskId)) firstSeen.set(event.taskId, eventIndex);
tasks.set(event.taskId, { ...event.task });
continue;
}
if (event.previousStatus !== undefined && current.status !== event.previousStatus) {
diagnostics.push(
`task event ${event.type} for ${event.taskId} expected previous status ${event.previousStatus} but saw ${current.status}`,
);
}
if (
!canTransitionTaskStatus(current.status, event.nextStatus, {
explicitReopen: event.type === 'task_reopened',
})
) {
diagnostics.push(
`invalid task transition ${current.status} -> ${event.nextStatus} for ${event.taskId}`,
);
}
tasks.set(event.taskId, { ...event.task });
}
const hydrated = hydrateTaskLedger([...tasks.values()], firstSeen, diagnostics);
return { tasks: hydrated.tasks, diagnostics, backfilledTaskIds: hydrated.backfilledTaskIds };
}
function validateTaskLedgerEventType(
event: TaskLedgerEvent,
current: TaskLedgerEventTaskSnapshot | undefined,
): string | undefined {
switch (event.type) {
case 'task_created':
return event.nextStatus === 'pending'
? undefined
: `task_created for ${event.taskId} must create a pending task, saw ${event.nextStatus}`;
case 'task_updated':
if (!current) return undefined;
return current.status === event.nextStatus
? undefined
: `task_updated for ${event.taskId} changed status ${current.status} -> ${event.nextStatus}`;
case 'task_started':
return event.nextStatus === 'in_progress'
? undefined
: `task_started for ${event.taskId} must set status in_progress, saw ${event.nextStatus}`;
case 'task_blocked':
return event.nextStatus === 'blocked'
? undefined
: `task_blocked for ${event.taskId} must set status blocked, saw ${event.nextStatus}`;
case 'task_completed':
return event.nextStatus === 'completed'
? undefined
: `task_completed for ${event.taskId} must set status completed, saw ${event.nextStatus}`;
case 'task_failed':
return event.nextStatus === 'failed'
? undefined
: `task_failed for ${event.taskId} must set status failed, saw ${event.nextStatus}`;
case 'task_cancelled':
return event.nextStatus === 'cancelled'
? undefined
: `task_cancelled for ${event.taskId} must set status cancelled, saw ${event.nextStatus}`;
case 'task_reopened':
if (!current) return undefined;
return (current.status === 'completed' && event.nextStatus === 'in_progress') ||
(current.status === 'cancelled' && event.nextStatus === 'pending') ||
(current.status === 'failed' && event.nextStatus === 'pending')
? undefined
: `task_reopened for ${event.taskId} must reopen completed -> in_progress, cancelled -> pending, or failed -> pending, saw ${current.status} -> ${event.nextStatus}`;
case 'task_resume_classified':
case 'task_repaired':
case 'task_imported':
return undefined;
}
}
/**
* Safe-render the task ledger for any face that persists into history or is
* re-injected into a prompt (tool results, turn-tail fragment). Two invariants:
* - the canonical id is rendered verbatim, and the subject is a safe
* (redacted, tag-stripped) rendered payload of what the store holds; and
* - the model can unambiguously recover each task's id from what it sees, so
* a later TaskUpdate hits the right task.
*
* Rendering is per-task and fielded, not a free-text bullet: each line is
* `id=<id> status=<status> subject=<JSON-stringified safe subject>`. The
* canonical id is a distinct leading field, so a subject cannot smuggle a fake
* `id=` field or any other id-like span past it -- any id-like text in the subject stays
* inside the quoted JSON payload. The id is emitted verbatim: it is a
* redaction-stable stable token validated on write and read, so scrubbing it
* could only deform it (and break TaskUpdate); it must not be redacted or
* tag-stripped. Each subject is redacted (secrets) and tag-stripped (complete
* `<task-ledger ...>` / `</task-ledger ...>` tags on a single line, so a
* model-authored subject cannot open or close the <task-ledger> data envelope)
* independently -- a subject on one task can never eat or deform text on
* another task's line. Other angle brackets (e.g. `a < b`) are left intact.
* Returns '' for an empty ledger.
*/
export function renderSafeTaskLedgerText(tasks: readonly Task[]): string {
if (tasks.length === 0) return '';
return tasks
.map((rawTask) => {
const task = sanitizeTaskLedgerTask(rawTask);
const fields = [
`key=${task.key}`,
`id=${task.id}`,
`status=${task.status}`,
`subject=${JSON.stringify(task.subject)}`,
];
if (task.parentId) fields.push(`parentId=${task.parentId}`);
if (task.owner) fields.push(`owner=${JSON.stringify(task.owner)}`);
if (task.blockedReason)
fields.push(`blockedReason=${JSON.stringify(safeTaskLedgerField(task.blockedReason))}`);
if (task.failureReason)
fields.push(`failureReason=${JSON.stringify(safeTaskLedgerField(task.failureReason))}`);
if (task.completionEvidence)
fields.push(
`completionEvidence=${JSON.stringify(safeTaskLedgerField(task.completionEvidence))}`,
);
return fields.join(' ');
})
.join('\n');
}
/** Safe structured DTO for renderer and diagnostic faces. */
export function sanitizeTaskLedgerTask(task: Task): Task {
return {
...task,
subject: safeTaskLedgerField(task.subject),
...(task.blockedReason ? { blockedReason: safeTaskLedgerField(task.blockedReason) } : {}),
...(task.failureReason ? { failureReason: safeTaskLedgerField(task.failureReason) } : {}),
...(task.completionEvidence
? { completionEvidence: safeTaskLedgerField(task.completionEvidence) }
: {}),
};
}
export interface TaskLedgerPromptRender {
text: string;
included: Task[];
omittedCount: number;
}
export function renderTaskLedgerPromptText(
tasks: readonly Task[],
maxChars = TASK_LEDGER_PROMPT_MAX_CHARS,
): TaskLedgerPromptRender {
const byId = new Map(tasks.map((task) => [task.id, task]));
const selected = new Set<string>();
const addWithAncestors = (task: Task): void => {
const chain: Task[] = [];
let current: Task | undefined = task;
const seen = new Set<string>();
while (current && !seen.has(current.id)) {
seen.add(current.id);
chain.unshift(current);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
for (const item of chain) selected.add(item.id);
};
const active = tasks
.filter((task) => !isTerminalTaskStatus(task.status))
.sort(compareTaskPromptPriority);
for (const task of active) addWithAncestors(task);
const recentTerminal = tasks
.filter((task) => isTerminalTaskStatus(task.status) && task.status !== 'cancelled')
.sort(
(a, b) =>
(b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt) || compareTaskKeys(a.key, b.key),
)
.slice(0, TASK_LEDGER_PROMPT_RECENT_TERMINAL);
for (const task of recentTerminal) addWithAncestors(task);
const chosen = tasks.filter((task) => selected.has(task.id));
const ordered = orderTaskTree(chosen);
const lines: string[] = [];
const included: Task[] = [];
const includedIds = new Set<string>();
for (const task of ordered) {
if (task.parentId && !includedIds.has(task.parentId)) continue;
const depth = task.key.split('.').length - 1;
const fields = [
`key=${task.key}`,
`status=${task.status}`,
`subject=${JSON.stringify(safeTaskLedgerField(task.subject))}`,
];
if (task.blockedReason)
fields.push(`blockedReason=${JSON.stringify(safeTaskLedgerField(task.blockedReason))}`);
if (task.failureReason)
fields.push(`failureReason=${JSON.stringify(safeTaskLedgerField(task.failureReason))}`);
if (task.completionEvidence)
fields.push(
`completionEvidence=${JSON.stringify(safeTaskLedgerField(task.completionEvidence))}`,
);
if (task.owner) fields.push(`owner=${JSON.stringify(task.owner)}`);
const line = `${' '.repeat(depth)}${fields.join(' ')}`;
const nextLength = lines.length === 0 ? line.length : lines.join('\n').length + 1 + line.length;
if (nextLength > maxChars) continue;
lines.push(line);
included.push(task);
includedIds.add(task.id);
}
return {
text: lines.join('\n'),
included,
omittedCount: tasks.length - included.length,
};
}
function compareTaskPromptPriority(left: Task, right: Task): number {
return (
taskStatusRank(left.status) - taskStatusRank(right.status) ||
compareTaskKeys(left.key, right.key)
);
}
function orderTaskTree(tasks: readonly Task[]): Task[] {
const byParent = new Map<string | undefined, Task[]>();
for (const task of tasks) {
const bucket = byParent.get(task.parentId) ?? [];
bucket.push(task);
byParent.set(task.parentId, bucket);
}
const branchRanks = new Map<string, number>();
const branchRank = (task: Task): number => {
const cached = branchRanks.get(task.id);
if (cached !== undefined) return cached;
const rank = Math.min(
taskStatusRank(task.status),
...(byParent.get(task.id) ?? []).map(branchRank),
);
branchRanks.set(task.id, rank);
return rank;
};
const out: Task[] = [];
const visit = (parentId: string | undefined): void => {
for (const task of (byParent.get(parentId) ?? []).sort(
(left, right) => branchRank(left) - branchRank(right) || compareTaskKeys(left.key, right.key),
)) {
out.push(task);
visit(task.id);
}
};
visit(undefined);
return out;
}
function taskStatusRank(status: TaskStatus): number {
switch (status) {
case 'in_progress':
return 0;
case 'pending':
return 1;
case 'blocked':
return 2;
case 'completed':
return 3;
case 'failed':
return 4;
case 'cancelled':
return 5;
}
}
export function renderTaskLedgerDebugText(tasks: readonly Task[]): string {
if (tasks.length === 0) return '';
return tasks
.map((task) => {
const fields = [renderSafeTaskLedgerText([task])];
if (task.resumeTrust) fields.push(`resumeTrust=${task.resumeTrust}`);
return fields.join(' ');
})
.join('\n');
}
export function filterModelVisibleTaskLedgerTasks(tasks: readonly Task[]): Task[] {
return tasks.filter((task) => task.resumeTrust !== 'untrusted');
}
export function isTaskLedgerEvent(value: unknown): value is TaskLedgerEvent {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const record = value as Partial<TaskLedgerEvent>;
if (
!isTaskLedgerEventTask(record.task, {
allowLegacyMissingEvidence: record.type === 'task_imported',
})
)
return false;
return (
typeof record.eventId === 'string' &&
(TASK_LEDGER_EVENT_TYPES as readonly string[]).includes(String(record.type)) &&
typeof record.ts === 'number' &&
Number.isFinite(record.ts) &&
typeof record.sessionId === 'string' &&
typeof record.taskId === 'string' &&
record.taskId === record.task.id &&
isTaskStatus(record.nextStatus) &&
record.nextStatus === record.task.status &&
(record.previousStatus === undefined || isTaskStatus(record.previousStatus)) &&
(record.reason === undefined || typeof record.reason === 'string') &&
(record.evidence === undefined || typeof record.evidence === 'string') &&
(record.refs === undefined || isTaskLedgerEventRefs(record.refs)) &&
(record.source === undefined ||
record.source === 'tool' ||
record.source === 'system' ||
record.source === 'recovery' ||
record.source === 'import') &&
(record.actor === undefined ||
record.actor === 'main_agent' ||
record.actor === 'child_agent' ||
record.actor === 'user' ||
record.actor === 'system')
);
}
function taskLedgerEventTypeForStatus(status: TaskStatus, create: boolean): TaskLedgerEventType {
if (create) return 'task_created';
switch (status) {
case 'pending':
return 'task_updated';
case 'in_progress':
return 'task_started';
case 'blocked':
return 'task_blocked';
case 'completed':
return 'task_completed';
case 'failed':
return 'task_failed';
case 'cancelled':
return 'task_cancelled';
}
}
function isTaskLedgerEventTask(
value: unknown,
options: { allowLegacyMissingEvidence?: boolean } = {},
): value is TaskLedgerEventTaskSnapshot {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const task = value as Partial<Task>;
if (
!isSafeTaskId(task.id) ||
!normalizeTaskSubject(task.subject).ok ||
!isTaskStatus(task.status) ||
typeof task.createdAt !== 'number' ||
!Number.isFinite(task.createdAt) ||
typeof task.updatedAt !== 'number' ||
!Number.isFinite(task.updatedAt) ||
(task.blockedReason !== undefined &&
!normalizeTaskEvidenceText(task.blockedReason, 'blockedReason').ok) ||
(task.failureReason !== undefined &&
!normalizeTaskEvidenceText(task.failureReason, 'failureReason').ok) ||
(task.completionEvidence !== undefined &&
!normalizeTaskEvidenceText(task.completionEvidence, 'completionEvidence').ok) ||
(task.resumeTrust !== undefined && !isResumeTrust(task.resumeTrust)) ||
(task.key !== undefined && !isTaskKey(task.key)) ||
(task.parentId !== undefined && !isSafeTaskId(task.parentId)) ||
(task.owner !== undefined && !isTaskOwner(task.owner)) ||
(task.endedAt !== undefined &&
(typeof task.endedAt !== 'number' || !Number.isFinite(task.endedAt)))
) {
return false;
}
if (options.allowLegacyMissingEvidence === true) return true;
return validateTaskEvidence({
status: task.status,
blockedReason: task.blockedReason,
failureReason: task.failureReason,
completionEvidence: task.completionEvidence,
}).ok;
}
export function isTaskOwner(value: unknown): value is TaskOwner {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const owner = value as Partial<TaskOwner>;
return (
(owner.actor === 'main_agent' || owner.actor === 'child_agent') &&
(owner.sessionId === undefined || isSafeTaskId(owner.sessionId)) &&
(owner.agentId === undefined || isSafeTaskId(owner.agentId)) &&
(owner.runId === undefined || isSafeTaskId(owner.runId)) &&
(owner.turnId === undefined || isSafeTaskId(owner.turnId))
);
}
function hydrateTaskLedger(
snapshots: readonly TaskLedgerEventTaskSnapshot[],
firstSeen: ReadonlyMap<string, number>,
diagnostics: string[],
): { tasks: Task[]; backfilledTaskIds: string[] } {
const byId = new Map(snapshots.map((task) => [task.id, task]));
const used = new Set<string>();
const backfilled = new Set<string>();
for (const task of snapshots) {
if (!task.key) continue;
if (used.has(task.key)) diagnostics.push(`duplicate task key ${task.key}`);
used.add(task.key);
}
for (const task of snapshots) {
if (task.parentId && !byId.has(task.parentId))
diagnostics.push(`task ${task.id} references missing parent ${task.parentId}`);
if (task.parentId && task.key) {
const parent = byId.get(task.parentId);
if (parent?.key && !isDirectChildTaskKey(parent.key, task.key)) {
diagnostics.push(
`task ${task.id} key ${task.key} does not belong under parent key ${parent.key}`,
);
}
} else if (!task.parentId && task.key && task.key.includes('.')) {
diagnostics.push(`root task ${task.id} cannot use child key ${task.key}`);
}
}
const visiting = new Set<string>();
const visited = new Set<string>();
const visit = (task: TaskLedgerEventTaskSnapshot): void => {
if (visited.has(task.id)) return;
if (visiting.has(task.id)) {
diagnostics.push(`task hierarchy cycle includes ${task.id}`);
return;
}
visiting.add(task.id);
if (task.parentId) {
const parent = byId.get(task.parentId);
if (parent) visit(parent);
}
visiting.delete(task.id);
visited.add(task.id);
};
for (const task of snapshots) visit(task);
const stable = [...snapshots].sort(
(a, b) =>
(firstSeen.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
(firstSeen.get(b.id) ?? Number.MAX_SAFE_INTEGER) ||
a.createdAt - b.createdAt ||
a.id.localeCompare(b.id),
);
const children = new Map<string | undefined, TaskLedgerEventTaskSnapshot[]>();
for (const task of stable) {
const bucket = children.get(task.parentId) ?? [];
bucket.push(task);
children.set(task.parentId, bucket);
}
const assign = (parentId: string | undefined, parentKey: string | undefined): void => {
const siblings = children.get(parentId) ?? [];
let next = 1;
for (const task of siblings) {
if (!task.key) {
let candidate = parentKey ? `${parentKey}.${next}` : `T${next}`;
while (used.has(candidate)) {
next += 1;
candidate = parentKey ? `${parentKey}.${next}` : `T${next}`;
}
task.key = candidate;
used.add(candidate);
backfilled.add(task.id);
}
if (isTerminalTaskStatus(task.status) && task.endedAt === undefined) {
task.endedAt = task.updatedAt;
backfilled.add(task.id);
}
const tail = Number(task.key.split('.').at(-1)?.replace(/^T/, ''));
if (Number.isFinite(tail)) next = Math.max(next, tail + 1);
assign(task.id, task.key);
}
};
assign(undefined, undefined);
for (const task of snapshots) {
if (!task.parentId || !task.key) continue;
const parent = byId.get(task.parentId);
if (parent?.key && !isDirectChildTaskKey(parent.key, task.key)) {
const diagnostic = `task ${task.id} key ${task.key} does not belong under parent key ${parent.key}`;
if (!diagnostics.includes(diagnostic)) diagnostics.push(diagnostic);
}
}
const tasks = stable.flatMap((task): Task[] => (task.key ? [{ ...task, key: task.key }] : []));
if (tasks.length !== snapshots.length)
diagnostics.push('task hierarchy could not assign keys to every task');
return { tasks, backfilledTaskIds: [...backfilled] };
}
function isDirectChildTaskKey(parentKey: string, childKey: string): boolean {
return (
childKey.startsWith(`${parentKey}.`) &&
childKey.split('.').length === parentKey.split('.').length + 1
);
}
function isTaskLedgerEventRefs(value: unknown): value is TaskLedgerEventRefs {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const refs = value as Partial<TaskLedgerEventRefs>;
return (
(refs.runId === undefined || typeof refs.runId === 'string') &&
(refs.turnId === undefined || typeof refs.turnId === 'string') &&
(refs.toolCallId === undefined || typeof refs.toolCallId === 'string')
);
}
function safeTaskLedgerField(value: string): string {
return redactSecrets(value).replace(/<\/?task-ledger[^\n>]*>/gi, '');
}
function evidenceReason(
field: 'blockedReason' | 'failureReason' | 'completionEvidence',
): TaskLedgerNormalizeErrorReason {
switch (field) {
case 'blockedReason':
return 'invalid_blocked_reason';
case 'failureReason':
return 'invalid_failure_reason';
case 'completionEvidence':
return 'invalid_completion_evidence';
}
}
function invalid<T extends TaskLedgerNormalizeErrorReason>(
reason: T,
message: string,
): Extract<TaskLedgerNormalizeResult<never>, { ok: false }> {
return { ok: false, reason, message };
}