Merge pull request #117 from harshitsinghbhandari/feat/stage3-explicit-agent-reporting

feat(core): Stage 3 — explicit agent reporting and workflow coordination
This commit is contained in:
Harshit Singh Bhandari 2026-04-17 10:18:07 +05:30 committed by GitHub
commit f2b74a8fc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 813 additions and 0 deletions

View File

@ -0,0 +1,116 @@
/**
* `ao acknowledge` and `ao report` explicit agent reporting commands (Stage 3).
*
* These commands are invoked by the worker agent from inside its managed
* session to declare workflow transitions (started / waiting / needs-input /
* fixing-ci / addressing-reviews / completed).
*
* Both commands resolve the session from:
* 1. Explicit `--session` / positional argument, OR
* 2. the `AO_SESSION_ID` environment variable set by every agent plugin.
*
* The lifecycle manager prefers fresh reports over weak inference but runtime
* evidence (process death, merged PR) still overrides see
* `packages/core/src/agent-report.ts` for the fallback matrix.
*/
import chalk from "chalk";
import type { Command } from "commander";
import {
AGENT_REPORTED_STATES,
applyAgentReport,
getSessionsDir,
loadConfig,
normalizeAgentReportedState,
type AgentReportedState,
} from "@aoagents/ao-core";
import { getSessionManager } from "../lib/create-session-manager.js";
function resolveSessionId(explicit: string | undefined): string {
const fromArg = explicit?.trim();
if (fromArg) return fromArg;
const fromEnv = process.env["AO_SESSION_ID"]?.trim();
if (fromEnv) return fromEnv;
console.error(
chalk.red(
"No session provided. Pass a session name or set AO_SESSION_ID (set automatically inside managed sessions).",
),
);
process.exit(1);
}
async function writeReport(
sessionName: string,
state: AgentReportedState,
note: string | undefined,
): Promise<void> {
const config = loadConfig();
const sm = await getSessionManager(config);
const session = await sm.get(sessionName);
if (!session) {
console.error(chalk.red(`Session not found: ${sessionName}`));
process.exit(1);
}
const project = config.projects[session.projectId];
if (!project) {
console.error(chalk.red(`Project not found for session: ${sessionName}`));
process.exit(1);
}
const sessionsDir = getSessionsDir(config.configPath, project.path);
try {
const result = applyAgentReport(sessionsDir, sessionName, { state, note });
const label =
result.previousState === result.nextState
? chalk.dim(`(${result.nextState})`)
: chalk.dim(`(${result.previousState}${result.nextState})`);
console.log(
`${chalk.green("✓")} ${chalk.bold(sessionName)} reported ${chalk.cyan(state)} ${label}`,
);
if (note) {
console.log(chalk.dim(` note: ${note}`));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(chalk.red(`Report rejected: ${message}`));
process.exit(1);
}
}
export function registerAcknowledge(program: Command): void {
program
.command("acknowledge")
.description(
"Acknowledge session pickup — agents run this once after reading the initial prompt (Stage 3).",
)
.argument("[session]", "Session ID (defaults to AO_SESSION_ID)")
.option("--note <text>", "Optional brief note to include with the acknowledgment")
.action(async (session: string | undefined, opts: { note?: string }) => {
const sessionId = resolveSessionId(session);
await writeReport(sessionId, "started", opts.note);
});
}
export function registerReport(program: Command): void {
const allowed = AGENT_REPORTED_STATES.join(", ");
program
.command("report")
.description(
`Declare a workflow transition (Stage 3). Allowed states: ${allowed} (hyphenated aliases accepted).`,
)
.argument("<state>", `One of: ${allowed} (aliases: fixing-ci, addressing-reviews, needs-input, ...)`)
.option("-s, --session <id>", "Session ID (defaults to AO_SESSION_ID)")
.option("--note <text>", "Optional brief note to include with the report")
.action(async (state: string, opts: { session?: string; note?: string }) => {
const canonical = normalizeAgentReportedState(state);
if (!canonical) {
console.error(
chalk.red(
`Unknown state: ${state}. Allowed: ${allowed} (or aliases: fixing-ci, addressing-reviews, needs-input).`,
),
);
process.exit(1);
}
const sessionId = resolveSessionId(opts.session);
await writeReport(sessionId, canonical, opts.note);
});
}

View File

@ -4,6 +4,7 @@ import { registerStatus } from "./commands/status.js";
import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js";
import { registerSession } from "./commands/session.js";
import { registerSend } from "./commands/send.js";
import { registerAcknowledge, registerReport } from "./commands/report.js";
import { registerReviewCheck } from "./commands/review-check.js";
import { registerDashboard } from "./commands/dashboard.js";
import { registerOpen } from "./commands/open.js";
@ -32,6 +33,8 @@ export function createProgram(): Command {
registerBatchSpawn(program);
registerSession(program);
registerSend(program);
registerAcknowledge(program);
registerReport(program);
registerReviewCheck(program);
registerDashboard(program);
registerOpen(program);

View File

@ -0,0 +1,330 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import {
AGENT_REPORTED_STATES,
AGENT_REPORT_FRESHNESS_MS,
AGENT_REPORT_METADATA_KEYS,
applyAgentReport,
isAgentReportFresh,
mapAgentReportToLifecycle,
normalizeAgentReportedState,
readAgentReport,
validateAgentReportTransition,
} from "../agent-report.js";
import { writeMetadata, writeCanonicalLifecycle, readMetadataRaw } from "../metadata.js";
import { createInitialCanonicalLifecycle } from "../lifecycle-state.js";
import type { CanonicalSessionLifecycle } from "../types.js";
let dataDir: string;
beforeEach(() => {
dataDir = join(tmpdir(), `ao-test-agent-report-${randomUUID()}`);
mkdirSync(dataDir, { recursive: true });
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});
function seedWorkerSession(
sessionId: string,
init?: Partial<CanonicalSessionLifecycle["session"]>,
): CanonicalSessionLifecycle {
const lifecycle = createInitialCanonicalLifecycle("worker");
// Default the seeded lifecycle to "working" with an existing startedAt so
// tests exercise the transition-applied path (not the first-start path).
lifecycle.session.state = "working";
lifecycle.session.reason = "task_in_progress";
lifecycle.session.startedAt = "2024-12-01T00:00:00.000Z";
if (init) {
Object.assign(lifecycle.session, init);
}
lifecycle.runtime.state = "alive";
lifecycle.runtime.reason = "process_running";
writeMetadata(dataDir, sessionId, {
worktree: "/tmp/worktree",
branch: "feat/x",
status: "working",
project: "demo",
});
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
return lifecycle;
}
describe("normalizeAgentReportedState", () => {
it("accepts canonical values", () => {
for (const state of AGENT_REPORTED_STATES) {
expect(normalizeAgentReportedState(state)).toBe(state);
}
});
it("accepts hyphen and short aliases", () => {
expect(normalizeAgentReportedState("needs-input")).toBe("needs_input");
expect(normalizeAgentReportedState("fixing-ci")).toBe("fixing_ci");
expect(normalizeAgentReportedState("addressing-reviews")).toBe("addressing_reviews");
expect(normalizeAgentReportedState("ci")).toBe("fixing_ci");
expect(normalizeAgentReportedState("reviews")).toBe("addressing_reviews");
expect(normalizeAgentReportedState("complete")).toBe("completed");
expect(normalizeAgentReportedState("input")).toBe("needs_input");
expect(normalizeAgentReportedState("start")).toBe("started");
expect(normalizeAgentReportedState("work")).toBe("working");
expect(normalizeAgentReportedState("wait")).toBe("waiting");
});
it("does not alias `done` (agents cannot self-report terminal done)", () => {
expect(normalizeAgentReportedState("done")).toBeNull();
});
it("is case-insensitive and trims whitespace", () => {
expect(normalizeAgentReportedState(" WAITING ")).toBe("waiting");
expect(normalizeAgentReportedState("Needs-Input")).toBe("needs_input");
});
it("returns null for unknown values", () => {
expect(normalizeAgentReportedState("foo")).toBeNull();
expect(normalizeAgentReportedState("")).toBeNull();
});
});
describe("mapAgentReportToLifecycle", () => {
it("maps every reportable state to a canonical pair", () => {
for (const state of AGENT_REPORTED_STATES) {
const mapped = mapAgentReportToLifecycle(state);
expect(mapped.sessionState).toBeTypeOf("string");
expect(mapped.sessionReason).toBeTypeOf("string");
}
});
it("maps needs_input to the canonical needs_input state", () => {
expect(mapAgentReportToLifecycle("needs_input")).toEqual({
sessionState: "needs_input",
sessionReason: "awaiting_user_input",
});
});
it("maps waiting and completed to idle (non-terminal)", () => {
expect(mapAgentReportToLifecycle("waiting").sessionState).toBe("idle");
expect(mapAgentReportToLifecycle("completed").sessionState).toBe("idle");
});
it("maps fixing_ci and addressing_reviews to working with the right reason", () => {
expect(mapAgentReportToLifecycle("fixing_ci")).toEqual({
sessionState: "working",
sessionReason: "fixing_ci",
});
expect(mapAgentReportToLifecycle("addressing_reviews")).toEqual({
sessionState: "working",
sessionReason: "resolving_review_comments",
});
});
});
describe("validateAgentReportTransition", () => {
it("rejects orchestrator sessions", () => {
const lifecycle = createInitialCanonicalLifecycle("orchestrator");
const result = validateAgentReportTransition(lifecycle, "working");
expect(result.ok).toBe(false);
expect(result.reason).toMatch(/orchestrator/);
});
it("rejects terminated sessions", () => {
const lifecycle = createInitialCanonicalLifecycle("worker");
lifecycle.session.state = "terminated";
const result = validateAgentReportTransition(lifecycle, "working");
expect(result.ok).toBe(false);
});
it("rejects all reports when session is done (terminal state cannot reopen)", () => {
const lifecycle = createInitialCanonicalLifecycle("worker");
lifecycle.session.state = "done";
// `completed` maps back to `idle` and would reanimate a `done` session, so
// it must also be rejected — not just the obvious working/needs_input ones.
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
expect(validateAgentReportTransition(lifecycle, "completed").ok).toBe(false);
expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(false);
});
it("rejects reports on merged PRs", () => {
const lifecycle = createInitialCanonicalLifecycle("worker");
lifecycle.pr.state = "merged";
expect(validateAgentReportTransition(lifecycle, "working").ok).toBe(false);
});
it("rejects reports when runtime is missing or exited", () => {
const missing = createInitialCanonicalLifecycle("worker");
missing.runtime.state = "missing";
expect(validateAgentReportTransition(missing, "working").ok).toBe(false);
const exited = createInitialCanonicalLifecycle("worker");
exited.runtime.state = "exited";
expect(validateAgentReportTransition(exited, "working").ok).toBe(false);
});
it("accepts valid worker transitions", () => {
const lifecycle = createInitialCanonicalLifecycle("worker");
lifecycle.session.state = "working";
lifecycle.runtime.state = "alive";
expect(validateAgentReportTransition(lifecycle, "fixing_ci").ok).toBe(true);
expect(validateAgentReportTransition(lifecycle, "needs_input").ok).toBe(true);
});
});
describe("applyAgentReport", () => {
const sessionId = "demo-1";
beforeEach(() => {
seedWorkerSession(sessionId);
});
it("writes canonical lifecycle and metadata keys", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
const result = applyAgentReport(dataDir, sessionId, {
state: "needs_input",
note: " please clarify the spec ",
now,
});
expect(result.report.state).toBe("needs_input");
expect(result.report.timestamp).toBe(now.toISOString());
expect(result.report.note).toBe("please clarify the spec");
expect(result.nextState).toBe("needs_input");
const meta = readMetadataRaw(dataDir, sessionId);
expect(meta).not.toBeNull();
expect(meta![AGENT_REPORT_METADATA_KEYS.STATE]).toBe("needs_input");
expect(meta![AGENT_REPORT_METADATA_KEYS.AT]).toBe(now.toISOString());
expect(meta![AGENT_REPORT_METADATA_KEYS.NOTE]).toBe("please clarify the spec");
});
it("sets startedAt on the first working transition", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
// Re-seed with startedAt explicitly null so we exercise the first-start
// branch of applyAgentReport.
seedWorkerSession(sessionId, {
state: "not_started",
reason: "spawn_requested",
startedAt: null,
});
applyAgentReport(dataDir, sessionId, { state: "started", now });
const meta = readMetadataRaw(dataDir, sessionId);
expect(meta).not.toBeNull();
// The canonical payload is stored in statePayload as JSON.
const payload = JSON.parse(meta!["statePayload"]);
expect(payload.session.state).toBe("working");
expect(payload.session.reason).toBe("agent_acknowledged");
expect(payload.session.startedAt).toBe(now.toISOString());
});
it("clears a previous note when none is supplied", () => {
applyAgentReport(dataDir, sessionId, {
state: "working",
note: "first note",
now: new Date("2025-01-01T11:00:00.000Z"),
});
applyAgentReport(dataDir, sessionId, {
state: "working",
now: new Date("2025-01-01T12:00:00.000Z"),
});
const meta = readMetadataRaw(dataDir, sessionId);
expect(meta).not.toBeNull();
expect(meta![AGENT_REPORT_METADATA_KEYS.NOTE] ?? "").toBe("");
});
it("throws when the transition is rejected", () => {
// Force lifecycle into a terminated state and try to re-report.
const lifecycle = createInitialCanonicalLifecycle("worker");
lifecycle.session.state = "terminated";
writeCanonicalLifecycle(dataDir, sessionId, lifecycle);
expect(() =>
applyAgentReport(dataDir, sessionId, {
state: "working",
now: new Date(),
}),
).toThrow(/terminated/);
});
it("throws when the session does not exist", () => {
expect(() =>
applyAgentReport(dataDir, "missing-session", {
state: "working",
now: new Date(),
}),
).toThrow(/not found/);
});
});
describe("readAgentReport + isAgentReportFresh", () => {
it("returns null when metadata lacks report keys", () => {
expect(readAgentReport({})).toBeNull();
expect(readAgentReport(null)).toBeNull();
expect(readAgentReport(undefined)).toBeNull();
});
it("returns null for unknown states or bad timestamps", () => {
expect(
readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "not-a-state",
[AGENT_REPORT_METADATA_KEYS.AT]: new Date().toISOString(),
}),
).toBeNull();
expect(
readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: "not-a-timestamp",
}),
).toBeNull();
});
it("parses a valid report with note", () => {
const at = "2025-01-01T00:00:00.000Z";
const report = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "fixing_ci",
[AGENT_REPORT_METADATA_KEYS.AT]: at,
[AGENT_REPORT_METADATA_KEYS.NOTE]: "still debugging",
});
expect(report).toEqual({ state: "fixing_ci", timestamp: at, note: "still debugging" });
});
it("treats an empty note as absent", () => {
const at = "2025-01-01T00:00:00.000Z";
const report = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: at,
[AGENT_REPORT_METADATA_KEYS.NOTE]: "",
});
expect(report?.note).toBeUndefined();
});
it("reports freshness against the default window", () => {
const now = new Date("2025-01-01T12:05:00.000Z");
const freshAt = "2025-01-01T12:04:00.000Z"; // 1m old
const staleAt = "2025-01-01T11:55:00.000Z"; // 10m old
const fresh = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: freshAt,
})!;
const stale = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: staleAt,
})!;
expect(isAgentReportFresh(fresh, now)).toBe(true);
expect(isAgentReportFresh(stale, now)).toBe(false);
});
it("rejects future timestamps (clock skew must not appear forever-fresh)", () => {
const now = new Date("2025-01-01T12:00:00.000Z");
const futureAt = "2025-01-01T12:10:00.000Z"; // 10m in the future
const future = readAgentReport({
[AGENT_REPORT_METADATA_KEYS.STATE]: "working",
[AGENT_REPORT_METADATA_KEYS.AT]: futureAt,
})!;
expect(isAgentReportFresh(future, now)).toBe(false);
});
it("exposes the default freshness window (5 minutes)", () => {
expect(AGENT_REPORT_FRESHNESS_MS).toBe(5 * 60 * 1000);
});
});

View File

@ -0,0 +1,288 @@
/**
* Agent Report explicit workflow transitions declared by the worker agent.
*
* Stage 3 of the state-machine redesign. Agents run `ao acknowledge` and
* `ao report <state>` from inside a managed session to declare the workflow
* phase they are entering. The lifecycle manager prefers fresh agent reports
* over weak inference, but runtime evidence (process death, merged PR) and
* SCM ground-truth (CI, review decisions) still take precedence.
*
* Fallback matrix (highest precedence first):
* 1. Runtime dead + no recent activity terminated/stuck
* 2. Agent activity plugin surfaces waiting_input/exited
* 3. SCM/PR ground truth (merged, closed, CI, reviews)
* 4. Fresh agent report (this module)
* 5. Idle-beyond-threshold promotion stuck
* 6. Default to working
*/
import type {
CanonicalSessionLifecycle,
CanonicalSessionReason,
CanonicalSessionState,
SessionId,
SessionStatus,
} from "./types.js";
import { updateCanonicalLifecycle, updateMetadata, readMetadataRaw } from "./metadata.js";
import { deriveLegacyStatus } from "./lifecycle-state.js";
/**
* Canonical set of states an agent can self-declare.
*
* - `started` agent has begun the task after planning
* - `working` generic working signal, useful after a pause
* - `waiting` blocked on an external dependency agent cannot unblock
* - `needs_input` blocked on human input
* - `fixing_ci` responding to a failing CI run
* - `addressing_reviews` responding to requested review changes
* - `completed` finished research/non-coding work (not "merged")
*
* Note: agents cannot self-report `done`, `terminated`, or PR-state transitions.
* Those remain owned by AO so ground-truth sources (SCM, runtime) stay
* authoritative.
*/
export const AGENT_REPORTED_STATES = [
"started",
"working",
"waiting",
"needs_input",
"fixing_ci",
"addressing_reviews",
"completed",
] as const;
export type AgentReportedState = (typeof AGENT_REPORTED_STATES)[number];
export interface AgentReport {
state: AgentReportedState;
/** ISO 8601 timestamp — when the agent issued the report. */
timestamp: string;
/** Optional free-text note the agent may include (e.g. brief status line). */
note?: string;
}
/** Metadata keys written by `applyAgentReport`. Keep in sync with CLI parsing. */
export const AGENT_REPORT_METADATA_KEYS = {
STATE: "agentReportedState",
AT: "agentReportedAt",
NOTE: "agentReportedNote",
} as const;
/** Freshness window — agent reports older than this are ignored. */
export const AGENT_REPORT_FRESHNESS_MS = 300_000; // 5 minutes
/**
* CLI surface accepts these hyphen/underscore aliases for convenience.
*
* Note: `done` is intentionally NOT an alias agents cannot self-report
* terminal `done` state (AO owns that transition via SCM ground truth). Use
* `completed` for finished non-coding research/analysis work.
*/
const INPUT_ALIASES: Record<string, AgentReportedState> = {
"start": "started",
"started": "started",
"working": "working",
"work": "working",
"wait": "waiting",
"waiting": "waiting",
"needs-input": "needs_input",
"needs_input": "needs_input",
"input": "needs_input",
"fixing-ci": "fixing_ci",
"fixing_ci": "fixing_ci",
"ci": "fixing_ci",
"addressing-reviews": "addressing_reviews",
"addressing_reviews": "addressing_reviews",
"reviews": "addressing_reviews",
"completed": "completed",
"complete": "completed",
};
/** Normalize a user-supplied report name into the canonical form. */
export function normalizeAgentReportedState(input: string): AgentReportedState | null {
if (!input) return null;
return INPUT_ALIASES[input.trim().toLowerCase()] ?? null;
}
/** Canonical mapping: AgentReportedState → (canonical session state, reason). */
export function mapAgentReportToLifecycle(state: AgentReportedState): {
sessionState: CanonicalSessionState;
sessionReason: CanonicalSessionReason;
} {
switch (state) {
case "started":
return { sessionState: "working", sessionReason: "agent_acknowledged" };
case "working":
return { sessionState: "working", sessionReason: "task_in_progress" };
case "waiting":
return { sessionState: "idle", sessionReason: "awaiting_external_review" };
case "needs_input":
return { sessionState: "needs_input", sessionReason: "awaiting_user_input" };
case "fixing_ci":
return { sessionState: "working", sessionReason: "fixing_ci" };
case "addressing_reviews":
return { sessionState: "working", sessionReason: "resolving_review_comments" };
case "completed":
return { sessionState: "idle", sessionReason: "research_complete" };
}
}
export interface AgentReportTransitionResult {
ok: boolean;
reason?: string;
}
/**
* Validate whether an agent-issued report is allowed given the current lifecycle.
*
* Rules:
* - Orchestrator sessions cannot accept agent reports (orchestrator sessions
* are read-only coordinators).
* - Terminal states (`done`, `terminated`) cannot be re-opened by an agent.
* - Merged PRs cannot be re-opened by an agent (`completed`/`working` etc.
* attempts are rejected).
* - Runtime state of `missing`/`exited` means the agent cannot possibly be
* reporting reject so we don't silently contradict runtime truth.
*/
export function validateAgentReportTransition(
lifecycle: CanonicalSessionLifecycle,
_next: AgentReportedState,
): AgentReportTransitionResult {
if (lifecycle.session.kind === "orchestrator") {
return { ok: false, reason: "orchestrator sessions cannot self-report" };
}
if (lifecycle.session.state === "terminated") {
return { ok: false, reason: "session is terminated" };
}
// Terminal states cannot be re-opened by an agent — including `completed`,
// which maps back to `idle` and would otherwise reanimate a `done` session.
if (lifecycle.session.state === "done") {
return { ok: false, reason: "session is already done" };
}
if (lifecycle.pr.state === "merged") {
return { ok: false, reason: "PR already merged" };
}
if (lifecycle.runtime.state === "missing" || lifecycle.runtime.state === "exited") {
return { ok: false, reason: "runtime is not alive" };
}
return { ok: true };
}
export interface ApplyAgentReportInput {
state: AgentReportedState;
note?: string;
/** Override the current clock — used by tests. */
now?: Date;
}
export interface ApplyAgentReportResult {
report: AgentReport;
legacyStatus: SessionStatus;
previousState: CanonicalSessionState;
nextState: CanonicalSessionState;
}
/**
* Apply an agent report to a session: update the canonical lifecycle on disk
* and persist the report metadata keys. Throws when the transition is rejected.
*
* The write is idempotent: applying the same report twice is safe (lifecycle
* fields are already set, metadata timestamp refreshes).
*/
export function applyAgentReport(
dataDir: string,
sessionId: SessionId,
input: ApplyAgentReportInput,
): ApplyAgentReportResult {
const raw = readMetadataRaw(dataDir, sessionId);
if (!raw) {
throw new Error(`Session not found: ${sessionId}`);
}
const now = (input.now ?? new Date()).toISOString();
let previousState: CanonicalSessionState | null = null;
let nextState: CanonicalSessionState | null = null;
let legacyStatus: SessionStatus | null = null;
const nextLifecycle = updateCanonicalLifecycle(
dataDir,
sessionId,
(current) => {
const validation = validateAgentReportTransition(current, input.state);
if (!validation.ok) {
throw new Error(validation.reason ?? "transition rejected");
}
const mapped = mapAgentReportToLifecycle(input.state);
previousState = current.session.state;
nextState = mapped.sessionState;
current.session.state = mapped.sessionState;
current.session.reason = mapped.sessionReason;
current.session.lastTransitionAt = now;
if (mapped.sessionState === "working" && current.session.startedAt === null) {
current.session.startedAt = now;
}
legacyStatus = deriveLegacyStatus(current);
return current;
},
);
if (!nextLifecycle || !previousState || !nextState || !legacyStatus) {
throw new Error(`Failed to apply agent report for session ${sessionId}`);
}
// Persist report metadata alongside the lifecycle patch.
const metadataUpdates: Record<string, string> = {
[AGENT_REPORT_METADATA_KEYS.STATE]: input.state,
[AGENT_REPORT_METADATA_KEYS.AT]: now,
};
if (input.note && input.note.trim()) {
metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = input.note.trim();
} else {
// Clear stale notes from previous reports so they don't mislead humans.
metadataUpdates[AGENT_REPORT_METADATA_KEYS.NOTE] = "";
}
updateMetadata(dataDir, sessionId, metadataUpdates);
return {
report: { state: input.state, timestamp: now, note: input.note?.trim() || undefined },
legacyStatus,
previousState,
nextState,
};
}
/** Read an agent report out of a session's raw metadata, or null if absent. */
export function readAgentReport(meta: Record<string, string> | null | undefined): AgentReport | null {
if (!meta) return null;
const state = meta[AGENT_REPORT_METADATA_KEYS.STATE];
const at = meta[AGENT_REPORT_METADATA_KEYS.AT];
if (!state || !at) return null;
if (!AGENT_REPORTED_STATES.includes(state as AgentReportedState)) return null;
const parsed = Date.parse(at);
if (Number.isNaN(parsed)) return null;
const note = meta[AGENT_REPORT_METADATA_KEYS.NOTE];
return {
state: state as AgentReportedState,
timestamp: new Date(parsed).toISOString(),
note: note && note.length > 0 ? note : undefined,
};
}
/**
* Check whether an agent report is fresh (within the freshness window).
*
* Future timestamps (clock skew, malformed input) are rejected otherwise a
* single skewed `agentReportedAt` could appear "fresh" indefinitely and
* override stronger inference signals.
*/
export function isAgentReportFresh(
report: AgentReport,
now: Date = new Date(),
windowMs: number = AGENT_REPORT_FRESHNESS_MS,
): boolean {
const reportedAt = Date.parse(report.timestamp);
if (Number.isNaN(reportedAt)) return false;
const currentTime = now.getTime();
if (reportedAt > currentTime) return false;
return currentTime - reportedAt <= windowMs;
}

View File

@ -41,6 +41,26 @@ export {
} from "./metadata.js";
export { createInitialCanonicalLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
// Agent reports — explicit workflow transitions declared by worker agents (Stage 3)
export {
AGENT_REPORTED_STATES,
AGENT_REPORT_METADATA_KEYS,
AGENT_REPORT_FRESHNESS_MS,
applyAgentReport,
readAgentReport,
isAgentReportFresh,
mapAgentReportToLifecycle,
normalizeAgentReportedState,
validateAgentReportTransition,
} from "./agent-report.js";
export type {
AgentReport,
AgentReportedState,
ApplyAgentReportInput,
ApplyAgentReportResult,
AgentReportTransitionResult,
} from "./agent-report.js";
// tmux — command wrappers
export {
isTmuxAvailable,

View File

@ -39,6 +39,11 @@ import {
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
import { updateMetadata } from "./metadata.js";
import { getSessionsDir } from "./paths.js";
import {
isAgentReportFresh,
mapAgentReportToLifecycle,
readAgentReport,
} from "./agent-report.js";
import { createCorrelationId, createProjectObserver } from "./observability.js";
import { resolveNotifierTarget } from "./notifier-resolution.js";
import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js";
@ -783,6 +788,26 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}
// Fresh agent reports outrank weak inference (idle-beyond-threshold /
// default-to-working) but runtime death, activity waiting_input, and SCM
// ground truth already short-circuited above. Orchestrator sessions and
// terminal states are skipped intentionally — `lifecycle.session.kind` is
// the authoritative source (string-matching role/id suffixes misses
// numbered orchestrator IDs like `${prefix}-orchestrator-1`).
const agentReport = readAgentReport(session.metadata);
if (
agentReport &&
isAgentReportFresh(agentReport) &&
lifecycle.session.kind !== "orchestrator" &&
lifecycle.session.state !== "terminated" &&
lifecycle.session.state !== "done"
) {
const mapped = mapAgentReportToLifecycle(agentReport.state);
setSessionState(mapped.sessionState, mapped.sessionReason);
const legacy = deriveLegacyStatus(lifecycle, session.status);
return commit(legacy, `agent_report:${agentReport.state}`, 0);
}
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure");
return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0);

View File

@ -27,6 +27,22 @@ export const BASE_AGENT_PROMPT = `You are an AI coding agent managed by the Agen
- If CI fails, the orchestrator will send you the failures fix them and push again.
- If reviewers request changes, the orchestrator will forward their comments address each one, push fixes, and reply to the comments.
## Reporting Progress to AO
The orchestrator infers your status from runtime signals, but explicit reports are always preferred they are accurate and fresh. Run these commands from the session shell (AO_SESSION_ID is pre-set for you):
- \`ao acknowledge\` — run once after reading the initial task so AO knows you picked it up.
- \`ao report working\` — declare you are actively making progress (useful after pauses or long thinking blocks).
- \`ao report waiting\` — you are blocked on something AO cannot unblock on its own (e.g. waiting for a human, external service).
- \`ao report needs-input\` — you need a decision or info from the human before proceeding.
- \`ao report fixing-ci\` — you are working specifically on making CI green again.
- \`ao report addressing-reviews\` — you are working on reviewer-requested changes.
- \`ao report completed\` — you finished non-coding research or analysis work that doesn't produce a PR.
Rules:
- Do NOT self-report \`done\`, \`terminated\`, or any PR-merge state — AO owns those transitions via SCM ground truth.
- A fresh report is trusted over weak inference but runtime death, activity-based waiting_input, and SCM events (merged/closed PR, CI failure, reviews) still take precedence.
- Use \`--note "<text>"\` to attach a short rationale when the state change is non-obvious.
## Git Workflow
- Always create a feature branch from the default branch (never commit directly to it).
- Use conventional commit messages (feat:, fix:, chore:, etc.).
@ -46,6 +62,13 @@ export const BASE_AGENT_PROMPT_NO_REPO = `You are an AI coding agent managed by
- You are running inside a managed session. Focus on the assigned task.
- No remote repository is configured work locally. PR, CI, and review features are unavailable.
## Reporting Progress to AO
Explicit reports help the orchestrator track your state accurately. Run these from the session shell (AO_SESSION_ID is pre-set):
- \`ao acknowledge\` — run once after reading the initial task.
- \`ao report working\` / \`waiting\` / \`needs-input\` — declare your current phase.
- \`ao report completed\` — finish non-coding research or analysis work.
Do NOT self-report \`done\` or \`terminated\` — AO owns those transitions.
## Git Workflow
- Always create a feature branch from the default branch (never commit directly to it).
- Use conventional commit messages (feat:, fix:, chore:, etc.).`;

View File

@ -101,6 +101,14 @@ Use `ao status` to see:
- Unresolved comments count
{{REPO_CONFIGURED_SECTION_END}}
### Explicit Agent Reports
Worker agents self-declare their workflow phase using `ao acknowledge` and `ao report <state>` (started, working, waiting, needs-input, fixing-ci, addressing-reviews, completed). These reports are persisted alongside the canonical lifecycle and may inform lifecycle inference, but do not replace runtime/activity/SCM-derived truth.
- Never run `ao acknowledge` or `ao report` from the orchestrator session - they are worker-only commands.
- Fresh reports (<5 min) are useful hints when inference is weak, but runtime death, activity-based waiting_input, and SCM truth (merged/closed PR, CI failure, review decisions) still take precedence.
- If an agent reports `waiting` but a PR actually merged, trust the PR state and follow up.
### Sending Messages
Send instructions to a running agent: