diff --git a/.changeset/session-ui-audit-oscillation.md b/.changeset/session-ui-audit-oscillation.md
new file mode 100644
index 000000000..0bcf242ab
--- /dev/null
+++ b/.changeset/session-ui-audit-oscillation.md
@@ -0,0 +1,7 @@
+---
+"@aoagents/ao-core": patch
+"@aoagents/ao-cli": patch
+"@aoagents/ao-web": patch
+---
+
+Split orchestrator-only detail views from worker detail views, add an auditable history for `ao acknowledge` / `ao report`, and preserve canonical `needs_input` / `stuck` lifecycle states when polling only has weak or unchanged evidence.
diff --git a/changelog/session-ui-audit-oscillation.md b/changelog/session-ui-audit-oscillation.md
deleted file mode 100644
index 973350836..000000000
--- a/changelog/session-ui-audit-oscillation.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Session UI, Report Audit, and Lifecycle Stability
-
-## What Changed
-
-- Split worker session detail from orchestrator detail so orchestrator pages no longer show worker-only PR and lifecycle detail panels.
-- Added a persistent audit trail for `ao acknowledge` and `ao report`, including actor, command, note, acceptance/rejection, and before/after lifecycle state.
-- Exposed that audit trail on worker session detail pages in the dashboard.
-- Fixed lifecycle fallback so `needs_input` and `stuck` do not bounce back to `working` when the poll cycle only has weak or unchanged evidence.
-- Removed the web app's runtime dependency on Google Fonts during `next build` by using local CSS font variables instead of `next/font/google`.
diff --git a/packages/core/src/agent-report.ts b/packages/core/src/agent-report.ts
index 3358e8c73..28272bd3c 100644
--- a/packages/core/src/agent-report.ts
+++ b/packages/core/src/agent-report.ts
@@ -17,6 +17,7 @@
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
+import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type {
CanonicalSessionLifecycle,
@@ -26,7 +27,7 @@ import type {
SessionStatus,
} from "./types.js";
import { updateCanonicalLifecycle, updateMetadata, readMetadataRaw } from "./metadata.js";
-import { deriveLegacyStatus, parseCanonicalLifecycle } from "./lifecycle-state.js";
+import { deriveLegacyStatus } from "./lifecycle-state.js";
import { validateStatus } from "./utils/validation.js";
/**
@@ -215,7 +216,16 @@ function buildAuditDir(dataDir: string): string {
return join(dataDir, ".agent-report-audit");
}
+const VALID_AUDIT_SESSION_ID = /^[a-zA-Z0-9_-]+$/;
+
+function validateAuditSessionId(sessionId: SessionId): void {
+ if (!VALID_AUDIT_SESSION_ID.test(sessionId)) {
+ throw new Error(`Invalid session ID: ${sessionId}`);
+ }
+}
+
function buildAuditFilePath(dataDir: string, sessionId: SessionId): string {
+ validateAuditSessionId(sessionId);
return join(buildAuditDir(dataDir), `${sessionId}.ndjson`);
}
@@ -256,7 +266,23 @@ export function readAgentReportAuditTrail(
return [];
}
- return readFileSync(auditFilePath, "utf8")
+ return parseAgentReportAuditTrail(readFileSync(auditFilePath, "utf8"));
+}
+
+export async function readAgentReportAuditTrailAsync(
+ dataDir: string,
+ sessionId: SessionId,
+): Promise {
+ const auditFilePath = buildAuditFilePath(dataDir, sessionId);
+ if (!existsSync(auditFilePath)) {
+ return [];
+ }
+
+ return parseAgentReportAuditTrail(await readFile(auditFilePath, "utf8"));
+}
+
+function parseAgentReportAuditTrail(content: string): AgentReportAuditEntry[] {
+ return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
@@ -299,40 +325,38 @@ export function applyAgentReport(
throw new Error(`Session not found: ${sessionId}`);
}
+ validateAuditSessionId(sessionId);
const now = (input.now ?? new Date()).toISOString();
const source = input.source ?? "report";
const actor = normalizeActor(input.actor);
const trimmedNote = input.note?.trim() || undefined;
- const currentLifecycle = parseCanonicalLifecycle(raw, {
- sessionId,
- status: validateStatus(raw["status"]),
- });
- const previousLegacyStatus = validateStatus(raw["status"]);
- const before = buildAuditSnapshot(currentLifecycle, previousLegacyStatus);
- const validation = validateAgentReportTransition(currentLifecycle, input.state);
- if (!validation.ok) {
- appendAgentReportAuditEntry(dataDir, sessionId, {
- timestamp: now,
- actor,
- source,
- reportState: input.state,
- note: trimmedNote,
- accepted: false,
- rejectionReason: validation.reason ?? "transition rejected",
- before,
- after: before,
- });
- throw new Error(validation.reason ?? "transition rejected");
- }
-
+ let before: AgentReportAuditSnapshot | null = null;
let previousState: CanonicalSessionState | null = null;
let nextState: CanonicalSessionState | null = null;
let legacyStatus: SessionStatus | null = null;
+ let previousLegacyStatus: SessionStatus | null = null;
const nextLifecycle = updateCanonicalLifecycle(
dataDir,
sessionId,
(current) => {
+ previousLegacyStatus = deriveLegacyStatus(current, validateStatus(raw["status"]));
+ before = buildAuditSnapshot(current, previousLegacyStatus);
+ const validation = validateAgentReportTransition(current, input.state);
+ if (!validation.ok) {
+ appendAgentReportAuditEntry(dataDir, sessionId, {
+ timestamp: now,
+ actor,
+ source,
+ reportState: input.state,
+ note: trimmedNote,
+ accepted: false,
+ rejectionReason: validation.reason ?? "transition rejected",
+ before,
+ after: before,
+ });
+ throw new Error(validation.reason ?? "transition rejected");
+ }
const mapped = mapAgentReportToLifecycle(input.state);
previousState = current.session.state;
nextState = mapped.sessionState;
@@ -347,7 +371,7 @@ export function applyAgentReport(
},
);
- if (!nextLifecycle || !previousState || !nextState || !legacyStatus) {
+ if (!nextLifecycle || !before || !previousState || !nextState || !legacyStatus) {
throw new Error(`Failed to apply agent report for session ${sessionId}`);
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1c5efa344..a02ea4365 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -49,6 +49,7 @@ export {
applyAgentReport,
readAgentReport,
readAgentReportAuditTrail,
+ readAgentReportAuditTrailAsync,
isAgentReportFresh,
mapAgentReportToLifecycle,
normalizeAgentReportedState,
diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts
index 4bdbe547f..9349d66f4 100644
--- a/packages/web/src/app/api/sessions/[id]/route.ts
+++ b/packages/web/src/app/api/sessions/[id]/route.ts
@@ -1,5 +1,5 @@
import { type NextRequest } from "next/server";
-import { getSessionsDir, readAgentReportAuditTrail } from "@aoagents/ao-core";
+import { getSessionsDir, readAgentReportAuditTrailAsync } from "@aoagents/ao-core";
import { getServices, getSCM } from "@/lib/services";
import {
sessionToDashboard,
@@ -25,7 +25,10 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const project = resolveProject(coreSession, config.projects);
if (project) {
const sessionsDir = getSessionsDir(config.configPath, project.path);
- dashboardSession.agentReportAudit = readAgentReportAuditTrail(sessionsDir, coreSession.id);
+ dashboardSession.agentReportAudit = await readAgentReportAuditTrailAsync(
+ sessionsDir,
+ coreSession.id,
+ );
}
// Enrich metadata (issue labels, agent summaries, issue titles)
diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx
index 26dccc503..d675b8ae3 100644
--- a/packages/web/src/components/SessionDetail.tsx
+++ b/packages/web/src/components/SessionDetail.tsx
@@ -246,9 +246,9 @@ function SessionReportAuditPanel({
Agent Report Audit
- {entries.map((entry) => (
+ {entries.map((entry, index) => (
diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts
index a03b56119..10ef7e9ce 100644
--- a/packages/web/src/lib/types.ts
+++ b/packages/web/src/lib/types.ts
@@ -47,6 +47,7 @@ import {
type CanonicalRuntimeState,
type CanonicalRuntimeReason,
} from "@aoagents/ao-core/types";
+import type { AgentReportedState } from "@aoagents/ao-core";
// Re-export for use in client components
export { CI_STATUS, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, NON_RESTORABLE_STATUSES };
@@ -106,7 +107,7 @@ export interface DashboardAgentReportAuditEntry {
timestamp: string;
actor: string;
source: "acknowledge" | "report";
- reportState: string;
+ reportState: AgentReportedState;
note?: string;
accepted: boolean;
rejectionReason?: string;