Merge pull request #127 from harshitsinghbhandari/feat/122

fix: model missing activity evidence explicitly
This commit is contained in:
Harshit Singh Bhandari 2026-04-17 19:54:50 +05:30 committed by GitHub
commit 5fe243b39e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 868 additions and 76 deletions

View File

@ -0,0 +1,5 @@
"@aoagents/ao-core": patch
"@aoagents/ao-web": patch
---
Model activity evidence explicitly across lifecycle inference and dashboard rendering so missing or failed probes cannot spuriously produce idle or stuck interpretations. This also stabilizes repeated polls by preserving stronger prior lifecycle states when the only new evidence is weak or unavailable.

View File

@ -2,6 +2,7 @@ import chalk from "chalk";
import type { Command } from "commander";
import {
createInitialCanonicalLifecycle,
createActivitySignal,
type Agent,
type SCM,
type Session,
@ -488,6 +489,7 @@ async function showFallbackStatus(): Promise<void> {
projectId: "",
status: "working",
activity: null,
activitySignal: createActivitySignal("unavailable"),
lifecycle,
branch: null,
issueId: null,

View File

@ -138,8 +138,8 @@ describe("check (single session)", () => {
previousRuntimeState: "alive",
newRuntimeState: "alive",
primaryReason: "task_in_progress",
evidence: "activity:active",
signalsConsulted: ["activity:active"],
evidence: "activity_signal=valid via_native activity=active",
signalsConsulted: ["activity_signal=valid", "via_native", "activity=active"],
recoveryAction: null,
});
});
@ -488,6 +488,60 @@ describe("check (single session)", () => {
expect(lm.getStates().get("app-1")).toBe("working");
});
it("does not mark a session stuck from terminal-only idle evidence without a timestamp", async () => {
config.reactions = {
"agent-stuck": { auto: true, action: "notify", threshold: "1m" },
};
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
vi.mocked(plugins.agent.detectActivity).mockReturnValue("idle");
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(true);
const lm = setupCheck("app-1", {
session: makeSession({ status: "working" }),
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("working");
const meta = readMetadataRaw(env.sessionsDir, "app-1");
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=stale");
expect(meta?.["lifecycleEvidence"]).toContain("activity=idle");
});
it("does not treat stale activity as recent liveness evidence during runtime-loss detection", async () => {
vi.mocked(plugins.runtime.isAlive).mockResolvedValue(false);
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({
state: "active",
timestamp: new Date(Date.now() - 10 * 60_000),
});
vi.mocked(plugins.agent.isProcessRunning).mockResolvedValue(false);
const lm = setupCheck("app-1", {
session: makeSession({ status: "working" }),
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("killed");
const meta = readMetadataRaw(env.sessionsDir, "app-1");
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=stale");
});
it("records explicit probe-failure activity evidence", async () => {
vi.mocked(plugins.agent.getActivityState).mockRejectedValue(new Error("boom"));
const lm = setupCheck("app-1", {
session: makeSession({ status: "working" }),
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("detecting");
const meta = readMetadataRaw(env.sessionsDir, "app-1");
expect(meta?.["lifecycleEvidence"]).toContain("activity_signal=probe_failure");
});
it("detects needs_input from agent", async () => {
vi.mocked(plugins.agent.getActivityState).mockResolvedValue({ state: "waiting_input" });
@ -621,6 +675,48 @@ describe("check (single session)", () => {
expect(lm.getStates().get("app-1")).toBe("stuck");
});
it("preserves needs_input state when getActivityState returns null with no terminal evidence", async () => {
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
const lm = setupCheck("app-1", {
session: makeSession({ status: "needs_input" }),
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("needs_input");
});
it("preserves stuck state across repeated polls with unchanged weak evidence", async () => {
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
const lm = setupCheck("app-1", {
session: makeSession({ status: "stuck" }),
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("stuck");
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("stuck");
});
it("preserves needs_input across repeated polls with unchanged weak evidence", async () => {
vi.mocked(plugins.agent.getActivityState).mockResolvedValue(null);
vi.mocked(plugins.runtime.getOutput).mockResolvedValue("");
const lm = setupCheck("app-1", {
session: makeSession({ status: "needs_input" }),
});
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("needs_input");
await lm.check("app-1");
expect(lm.getStates().get("app-1")).toBe("needs_input");
});
it("detects PR states from SCM", async () => {
const mockSCM = createMockSCM({ getCISummary: vi.fn().mockResolvedValue("failing") });
const registry = createMockRegistry({

View File

@ -315,6 +315,7 @@ describe("list", () => {
// Should keep null (absent) when getActivityState fails
expect(sessions[0].activity).toBeNull();
expect(sessions[0].activitySignal.state).toBe("probe_failure");
});
it("keeps existing activity when getActivityState returns null", async () => {
@ -345,6 +346,36 @@ describe("list", () => {
// null = "I don't know" — activity stays null (absent)
expect(agentWithNull.getActivityState).toHaveBeenCalled();
expect(sessions[0].activity).toBeNull();
expect(sessions[0].activitySignal.state).toBe("null");
});
it("marks terminal fallback-free stale activity explicitly when timing is missing", async () => {
const agentWithIdleNoTimestamp: Agent = {
...mockAgent,
getActivityState: vi.fn().mockResolvedValue({ state: "idle" }),
};
const registryWithStaleSignal: PluginRegistry = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return agentWithIdleNoTimestamp;
return null;
}),
};
writeMetadata(sessionsDir, "app-1", {
worktree: "/tmp",
branch: "a",
status: "working",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
const sm = createSessionManager({ config, registry: registryWithStaleSignal });
const sessions = await sm.list();
expect(sessions[0].activity).toBe("idle");
expect(sessions[0].activitySignal.state).toBe("stale");
});
it("updates lastActivityAt when detection timestamp is newer", async () => {

View File

@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { getSessionsDir, getProjectBaseDir } from "../paths.js";
import { createInitialCanonicalLifecycle, deriveLegacyStatus } from "../lifecycle-state.js";
import { createActivitySignal } from "../activity-signal.js";
import type {
OrchestratorConfig,
PluginRegistry,
@ -86,6 +87,11 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "my-app",
status: deriveLegacyStatus(lifecycle),
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
lifecycle,
branch: "feat/test",
issueId: null,

View File

@ -0,0 +1,125 @@
import type { ActivityDetection, ActivitySignal, ActivitySignalSource, ActivityState } from "./types.js";
const TIMING_SENSITIVE_ACTIVITY_STATES: ReadonlySet<ActivityState> = new Set([
"active",
"ready",
"idle",
"blocked",
]);
const LIVENESS_ACTIVITY_STATES: ReadonlySet<ActivityState> = new Set(["active", "ready"]);
const IDLE_ACTIVITY_STATES: ReadonlySet<ActivityState> = new Set(["idle", "blocked"]);
export const ACTIVITY_STRONG_WINDOW_MS = 60_000;
export const ACTIVITY_WEAK_WINDOW_MS = 5 * 60_000;
export function summarizeActivityFreshness(
timestamp: Date | undefined,
now: Date = new Date(),
): "strong" | "weak" | "stale" | "none" {
if (!timestamp) return "none";
const ageMs = Math.max(0, now.getTime() - timestamp.getTime());
if (ageMs <= ACTIVITY_STRONG_WINDOW_MS) return "strong";
if (ageMs <= ACTIVITY_WEAK_WINDOW_MS) return "weak";
return "stale";
}
export function createActivitySignal(
state: ActivitySignal["state"],
options: {
activity?: ActivityState | null;
timestamp?: Date;
source?: ActivitySignalSource;
detail?: string;
} = {},
): ActivitySignal {
return {
state,
activity: options.activity ?? null,
timestamp: options.timestamp,
source: options.source ?? "none",
detail: options.detail,
};
}
export function classifyActivitySignal(
detection: ActivityDetection,
source: ActivitySignalSource,
now: Date = new Date(),
): ActivitySignal {
if (!TIMING_SENSITIVE_ACTIVITY_STATES.has(detection.state)) {
return createActivitySignal("valid", {
activity: detection.state,
timestamp: detection.timestamp,
source,
});
}
if (!detection.timestamp && IDLE_ACTIVITY_STATES.has(detection.state)) {
return createActivitySignal("stale", {
activity: detection.state,
source,
detail: "missing_timestamp",
});
}
if (!detection.timestamp) {
return createActivitySignal("valid", {
activity: detection.state,
source,
});
}
if (
LIVENESS_ACTIVITY_STATES.has(detection.state) &&
summarizeActivityFreshness(detection.timestamp, now) === "stale"
) {
return createActivitySignal("stale", {
activity: detection.state,
timestamp: detection.timestamp,
source,
detail: "stale_timestamp",
});
}
return createActivitySignal("valid", {
activity: detection.state,
timestamp: detection.timestamp,
source,
});
}
export function hasPositiveIdleEvidence(signal: ActivitySignal): signal is ActivitySignal & {
activity: "idle" | "blocked";
timestamp: Date;
state: "valid";
} {
return (
signal.state === "valid" &&
signal.timestamp instanceof Date &&
signal.activity !== null &&
IDLE_ACTIVITY_STATES.has(signal.activity)
);
}
export function supportsRecentLiveness(signal: ActivitySignal, now: Date = new Date()): boolean {
return (
signal.state === "valid" &&
signal.timestamp instanceof Date &&
signal.activity !== null &&
LIVENESS_ACTIVITY_STATES.has(signal.activity) &&
summarizeActivityFreshness(signal.timestamp, now) !== "stale"
);
}
export function isWeakActivityEvidence(signal: ActivitySignal): boolean {
return signal.state !== "valid";
}
export function formatActivitySignalEvidence(signal: ActivitySignal): string {
const source = signal.source === "none" ? "" : ` via_${signal.source}`;
const activity = signal.activity ? ` activity=${signal.activity}` : "";
const timing = signal.timestamp ? ` at=${signal.timestamp.toISOString()}` : "";
const detail = signal.detail ? ` detail=${signal.detail}` : "";
return `activity_signal=${signal.state}${source}${activity}${timing}${detail}`;
}

View File

@ -122,6 +122,17 @@ export {
classifyTerminalActivity,
recordTerminalActivity,
} from "./activity-log.js";
export {
ACTIVITY_STRONG_WINDOW_MS,
ACTIVITY_WEAK_WINDOW_MS,
classifyActivitySignal,
createActivitySignal,
formatActivitySignalEvidence,
hasPositiveIdleEvidence,
isWeakActivityEvidence,
summarizeActivityFreshness,
supportsRecentLiveness,
} from "./activity-signal.js";
// Agent workspace hooks — shared PATH-wrapper setup for non-Claude agents
export {

View File

@ -40,6 +40,14 @@ import {
import { buildLifecycleMetadataPatch, cloneLifecycle, deriveLegacyStatus } from "./lifecycle-state.js";
import { updateMetadata } from "./metadata.js";
import { getSessionsDir } from "./paths.js";
import {
classifyActivitySignal,
createActivitySignal,
formatActivitySignalEvidence,
hasPositiveIdleEvidence,
isWeakActivityEvidence,
supportsRecentLiveness,
} from "./activity-signal.js";
import {
isAgentReportFresh,
mapAgentReportToLifecycle,
@ -433,8 +441,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return idleMs > stuckThresholdMs;
}
const ACTIVITY_STRONG_WINDOW_MS = 60_000;
const ACTIVITY_WEAK_WINDOW_MS = 5 * 60_000;
const DETECTING_MAX_ATTEMPTS = 3;
type ProbeState = "alive" | "dead" | "unknown";
@ -456,14 +462,6 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function summarizeActivityFreshness(timestamp: Date | undefined): "strong" | "weak" | "stale" | "none" {
if (!timestamp) return "none";
const ageMs = Date.now() - timestamp.getTime();
if (ageMs <= ACTIVITY_STRONG_WINDOW_MS) return "strong";
if (ageMs <= ACTIVITY_WEAK_WINDOW_MS) return "weak";
return "stale";
}
/** Determine current status for a session by polling plugins. */
async function determineStatus(session: Session): Promise<DeterminedStatus> {
const project = config.projects[session.projectId];
@ -502,6 +500,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
): DeterminedStatus => {
session.lifecycle = lifecycle;
session.status = status;
session.activitySignal = activitySignal;
return {
status,
evidence,
@ -568,9 +567,9 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}
let activityState: Awaited<ReturnType<Agent["getActivityState"]>> | null = null;
let activitySignal = createActivitySignal("unavailable");
let processProbe: ProbeResult = { state: "unknown", failed: false };
let activityEvidence = "activity_unavailable";
let activityEvidence = formatActivitySignalEvidence(activitySignal);
if (agent && (session.runtimeHandle || session.workspacePath)) {
try {
@ -591,37 +590,38 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}
activityState = await agent.getActivityState(session, config.readyThresholdMs);
if (activityState) {
activityEvidence = `activity:${activityState.state}`;
const detectedActivity = await agent.getActivityState(session, config.readyThresholdMs);
if (detectedActivity) {
activitySignal = classifyActivitySignal(detectedActivity, "native");
activityEvidence = formatActivitySignalEvidence(activitySignal);
lifecycle.runtime.lastObservedAt = nowIso;
if (lifecycle.runtime.state !== "missing" && lifecycle.runtime.state !== "probe_failed") {
lifecycle.runtime.state = "alive";
lifecycle.runtime.reason = "process_running";
}
if (activityState.state === "waiting_input") {
if (detectedActivity.state === "waiting_input") {
setSessionState("needs_input", "awaiting_user_input");
return commit(SESSION_STATUS.NEEDS_INPUT, activityEvidence, 0);
}
if (activityState.state === "exited" && canProbeRuntimeIdentity) {
if (detectedActivity.state === "exited" && canProbeRuntimeIdentity) {
processProbe = { state: "dead", failed: false };
lifecycle.runtime.state = "exited";
lifecycle.runtime.reason = "process_missing";
}
if (
(activityState.state === "idle" || activityState.state === "blocked") &&
activityState.timestamp
) {
detectedIdleTimestamp = activityState.timestamp;
idleWasBlocked = activityState.state === "blocked";
if (hasPositiveIdleEvidence(activitySignal)) {
detectedIdleTimestamp = activitySignal.timestamp;
idleWasBlocked = activitySignal.activity === "blocked";
}
} else if (session.runtimeHandle && canProbeRuntimeIdentity) {
activitySignal = createActivitySignal("null", { source: "native" });
activityEvidence = formatActivitySignalEvidence(activitySignal);
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
const terminalOutput = runtime ? await runtime.getOutput(session.runtimeHandle, 10) : "";
if (terminalOutput) {
const activity = agent.detectActivity(terminalOutput);
activityEvidence = `terminal:${activity}`;
activitySignal = classifyActivitySignal({ state: activity }, "terminal");
activityEvidence = formatActivitySignalEvidence(activitySignal);
if (activity === "waiting_input") {
setSessionState("needs_input", "awaiting_user_input");
return commit(SESSION_STATUS.NEEDS_INPUT, activityEvidence, 0);
@ -639,16 +639,21 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
processProbe = { state: "unknown", failed: true };
}
}
} else {
activitySignal = createActivitySignal("null", { source: "native" });
activityEvidence = formatActivitySignalEvidence(activitySignal);
}
} catch {
activitySignal = createActivitySignal("probe_failure", { source: "native" });
activityEvidence = formatActivitySignalEvidence(activitySignal);
if (
lifecycle.session.state === "stuck" ||
lifecycle.session.state === "needs_input" ||
lifecycle.session.state === "detecting"
) {
return commit(session.status, "activity_probe_failed_preserved");
return commit(session.status, activityEvidence);
}
return buildDetectingAssessment("activity_probe_failed");
return buildDetectingAssessment(activityEvidence);
}
}
@ -671,9 +676,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}
const activityFreshness = summarizeActivityFreshness(activityState?.timestamp);
const recentActivitySupportsLiveness =
activityFreshness === "strong" || activityFreshness === "weak";
const recentActivitySupportsLiveness = supportsRecentLiveness(activitySignal);
if (runtimeProbe.failed || processProbe.failed) {
return buildDetectingAssessment(
@ -687,7 +690,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
(runtimeProbe.state === "dead" && recentActivitySupportsLiveness)
) {
return buildDetectingAssessment(
`signal_disagreement runtime=${runtimeProbe.state} process=${processProbe.state} activity=${activityFreshness}`,
`signal_disagreement runtime=${runtimeProbe.state} process=${processProbe.state} ${activityEvidence}`,
runtimeProbe.state === "dead" ? "runtime_lost" : "agent_process_exited",
);
}
@ -698,7 +701,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
canProbeRuntimeIdentity
) {
return buildDetectingAssessment(
`runtime_dead process_unknown activity=${activityFreshness}`,
`runtime_dead process_unknown ${activityEvidence}`,
"runtime_lost",
);
}
@ -709,7 +712,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
!recentActivitySupportsLiveness
) {
setSessionState("terminated", "runtime_lost");
return commit(SESSION_STATUS.KILLED, `runtime_dead process_dead activity=${activityFreshness}`, 0);
return commit(SESSION_STATUS.KILLED, `runtime_dead process_dead ${activityEvidence}`, 0);
}
if (
@ -788,10 +791,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return commit(SESSION_STATUS.REVIEW_PENDING, "review_pending", 0);
}
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
if (
detectedIdleTimestamp &&
hasPositiveIdleEvidence(activitySignal) &&
isIdleBeyondThreshold(session, detectedIdleTimestamp)
) {
lifecycle.pr.reason = "in_progress";
setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure");
return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0);
return commit(SESSION_STATUS.STUCK, `idle_beyond_threshold ${activityEvidence}`, 0);
}
lifecycle.pr.reason = "in_progress";
@ -846,10 +853,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return commit(SESSION_STATUS.REVIEW_PENDING, "review_pending", 0);
}
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
if (
detectedIdleTimestamp &&
hasPositiveIdleEvidence(activitySignal) &&
isIdleBeyondThreshold(session, detectedIdleTimestamp)
) {
lifecycle.pr.reason = "in_progress";
setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure");
return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0);
return commit(SESSION_STATUS.STUCK, `idle_beyond_threshold ${activityEvidence}`, 0);
}
lifecycle.pr.reason = "in_progress";
@ -880,9 +891,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
return commit(legacy, `agent_report:${agentReport.state}`, 0);
}
if (detectedIdleTimestamp && isIdleBeyondThreshold(session, detectedIdleTimestamp)) {
if (
detectedIdleTimestamp &&
hasPositiveIdleEvidence(activitySignal) &&
isIdleBeyondThreshold(session, detectedIdleTimestamp)
) {
setSessionState("stuck", idleWasBlocked ? "error_in_process" : "probe_failure");
return commit(SESSION_STATUS.STUCK, "idle_beyond_threshold", 0);
return commit(SESSION_STATUS.STUCK, `idle_beyond_threshold ${activityEvidence}`, 0);
}
if (
isWeakActivityEvidence(activitySignal) &&
(session.status === SESSION_STATUS.DETECTING ||
session.status === SESSION_STATUS.STUCK ||
session.status === SESSION_STATUS.NEEDS_INPUT)
) {
return commit(session.status, activityEvidence, 0);
}
if (

View File

@ -60,6 +60,7 @@ import {
parseCanonicalLifecycle,
} from "./lifecycle-state.js";
import { buildPrompt } from "./prompt-builder.js";
import { classifyActivitySignal, createActivitySignal } from "./activity-signal.js";
import {
getSessionsDir,
getWorktreesDir,
@ -975,6 +976,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
session.status = "killed";
}
session.activity = "exited";
session.activitySignal = createActivitySignal("valid", {
activity: "exited",
source: "runtime",
});
return;
}
} catch {
@ -991,10 +996,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
// external scripts (which don't store runtimeHandle) to always show "unknown".
// This now runs for ALL sessions, including terminal statuses, so a merged
// session with a live agent shows accurate activity (ready/idle/waiting_input).
session.activitySignal = createActivitySignal("unavailable");
if (plugins.agent) {
try {
const detected = await plugins.agent.getActivityState(session, config.readyThresholdMs);
if (detected !== null) {
session.activitySignal = classifyActivitySignal(detected, "native");
session.activity = detected.state;
session.lifecycle.runtime.state = "alive";
session.lifecycle.runtime.reason = "process_running";
@ -1002,9 +1009,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
if (detected.timestamp && detected.timestamp > session.lastActivityAt) {
session.lastActivityAt = detected.timestamp;
}
} else {
session.activitySignal = createActivitySignal("null", { source: "native" });
}
} catch {
// Can't detect activity — keep existing value
session.activitySignal = createActivitySignal("probe_failure", { source: "native" });
}
// Enrich with live agent session info (summary, cost).
@ -1233,6 +1242,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
projectId: spawnConfig.projectId,
status: deriveLegacyStatus(lifecycle),
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: createdAt,
source: "runtime",
}),
lifecycle,
branch,
issueId: spawnConfig.issueId ?? null,
@ -1566,6 +1580,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
projectId: orchestratorConfig.projectId,
status: deriveLegacyStatus(lifecycle),
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: createdAt,
source: "runtime",
}),
lifecycle,
branch,
issueId: null,

View File

@ -150,6 +150,23 @@ export const ACTIVITY_STATE = {
EXITED: "exited" as const,
} satisfies Record<string, ActivityState>;
export type ActivitySignalState = "valid" | "stale" | "null" | "unavailable" | "probe_failure";
export type ActivitySignalSource = "native" | "terminal" | "runtime" | "none";
export interface ActivitySignal {
/** Confidence bucket for the activity probe result. */
state: ActivitySignalState;
/** The observed activity value, if one was surfaced. */
activity: ActivityState | null;
/** Timestamp that makes timing-based inferences safe, when available. */
timestamp?: Date;
/** Where the activity signal came from. */
source: ActivitySignalSource;
/** Optional extra detail for stale / failed probes. */
detail?: string;
}
/** Result of activity detection, carrying both the state and an optional timestamp. */
export interface ActivityDetection {
state: ActivityState;
@ -275,6 +292,9 @@ export interface Session {
/** Activity state from agent plugin (null = not yet determined) */
activity: ActivityState | null;
/** Explicit confidence/availability contract for the current activity signal. */
activitySignal: ActivitySignal;
/** Canonical lifecycle truth persisted in metadata. */
lifecycle: CanonicalSessionLifecycle;

View File

@ -1,5 +1,6 @@
import type { RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js";
import type { ActivitySignal, RuntimeHandle, Session, SessionId, SessionStatus } from "../types.js";
import { deriveLegacyStatus, parseCanonicalLifecycle } from "../lifecycle-state.js";
import { createActivitySignal } from "../activity-signal.js";
import { parsePrFromUrl } from "./pr.js";
import { safeJsonParse, validateStatus } from "./validation.js";
@ -7,12 +8,29 @@ interface SessionFromMetadataOptions {
projectId?: string;
status?: SessionStatus;
activity?: Session["activity"];
activitySignal?: ActivitySignal;
runtimeHandle?: RuntimeHandle | null;
createdAt?: Date;
lastActivityAt?: Date;
restoredAt?: Date;
}
function deriveDefaultActivitySignal(options: SessionFromMetadataOptions): ActivitySignal {
if (options.activitySignal) {
return options.activitySignal;
}
if (options.activity === undefined || options.activity === null) {
return createActivitySignal("unavailable");
}
return createActivitySignal("valid", {
activity: options.activity,
timestamp: options.lastActivityAt,
source: options.activity === "exited" ? "runtime" : "native",
});
}
export function sessionFromMetadata(
sessionId: SessionId,
meta: Record<string, string>,
@ -38,6 +56,7 @@ export function sessionFromMetadata(
projectId: meta["project"] ?? options.projectId ?? "",
status,
activity: options.activity ?? null,
activitySignal: deriveDefaultActivitySignal(options),
lifecycle,
branch: meta["branch"] || null,
issueId: meta["issue"] || null,

View File

@ -1,4 +1,5 @@
import {
createActivitySignal,
createInitialCanonicalLifecycle,
type OrchestratorEvent,
type Session,
@ -42,6 +43,11 @@ export function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "my-project",
status: "working" as SessionStatus,
activity: "active" as ActivityState,
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date("2025-06-15T12:00:00Z"),
source: "native",
}),
lifecycle,
branch: "feat/test",
issueId: null,

View File

@ -2,7 +2,7 @@
* Factory helpers to build Session and RuntimeHandle objects for tests.
*/
import { createInitialCanonicalLifecycle, type RuntimeHandle, type Session } from "@aoagents/ao-core";
import { createActivitySignal, createInitialCanonicalLifecycle, type RuntimeHandle, type Session } from "@aoagents/ao-core";
/** Build a tmux RuntimeHandle for a given session name. */
export function makeTmuxHandle(sessionName: string): RuntimeHandle {
@ -31,6 +31,11 @@ export function makeSession(
projectId: "inttest",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
lifecycle,
branch: null,
issueId: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core";
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core";
// Mock fs/promises for getSessionInfo tests (readFile for .aider.chat.history.md)
vi.mock("node:fs/promises", async (importOriginal) => {
@ -53,6 +53,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/test",
issueId: null,
pr: null,

View File

@ -3,7 +3,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:
import { join } from "node:path";
import { tmpdir } from "node:os";
import { toClaudeProjectPath, create } from "../index.js";
import type { Session, RuntimeHandle } from "@aoagents/ao-core";
import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core";
// Mock homedir() so getActivityState looks in our temp dir
vi.mock("node:os", async (importOriginal) => {
@ -25,6 +25,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test",
status: "working",
activity: "idle",
activitySignal: createActivitySignal("valid", {
activity: "idle",
timestamp: new Date(),
source: "native",
}),
branch: "main",
issueId: null,
pr: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig, WorkspaceHooksConfig } from "@aoagents/ao-core";
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig, type WorkspaceHooksConfig } from "@aoagents/ao-core";
// ---------------------------------------------------------------------------
// Hoisted mocks — available inside vi.mock factories
@ -61,6 +61,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/test",
issueId: null,
pr: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig, AgentSpecificConfig } from "@aoagents/ao-core";
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig, type AgentSpecificConfig } from "@aoagents/ao-core";
// ---------------------------------------------------------------------------
// Hoisted mocks — available inside vi.mock factories
@ -83,6 +83,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/test",
issueId: null,
pr: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core";
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core";
// Mock fs/promises for getSessionInfo tests
vi.mock("node:fs/promises", async (importOriginal) => {
@ -73,6 +73,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/test",
issueId: null,
pr: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core";
import { createActivitySignal, type Session, type RuntimeHandle, type AgentLaunchConfig } from "@aoagents/ao-core";
const { mockAppendActivityEntry, mockReadLastActivityEntry, mockRecordTerminalActivity } =
vi.hoisted(() => ({
@ -42,6 +42,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/test",
issueId: null,
pr: null,

View File

@ -15,7 +15,7 @@ vi.mock("node:child_process", () => {
});
import { create, manifest } from "../src/index.js";
import type { PRInfo, SCMWebhookRequest, Session, ProjectConfig } from "@aoagents/ao-core";
import { createActivitySignal, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core";
// ---------------------------------------------------------------------------
// Fixtures
@ -46,6 +46,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/my-feature",
issueId: null,
pr: null,

View File

@ -13,7 +13,7 @@ vi.mock("node:child_process", () => {
});
import { create, manifest } from "../src/index.js";
import type { PRInfo, Session, ProjectConfig, SCMWebhookRequest } from "@aoagents/ao-core";
import { createActivitySignal, type PRInfo, type Session, type ProjectConfig, type SCMWebhookRequest } from "@aoagents/ao-core";
// ---------------------------------------------------------------------------
// Fixtures
@ -44,6 +44,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "test",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
branch: "feat/my-feature",
issueId: null,
pr: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import { createInitialCanonicalLifecycle, type Session } from "@aoagents/ao-core";
import { createInitialCanonicalLifecycle, createActivitySignal, type Session } from "@aoagents/ao-core";
vi.mock("node:child_process", () => ({
execFile: vi.fn(),
@ -28,6 +28,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "my-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
lifecycle,
branch: "feat/test",
issueId: null,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createInitialCanonicalLifecycle, type Session } from "@aoagents/ao-core";
import { createInitialCanonicalLifecycle, createActivitySignal, type Session } from "@aoagents/ao-core";
import { manifest, create } from "./index.js";
function makeSession(overrides: Partial<Session> = {}): Session {
@ -14,6 +14,11 @@ function makeSession(overrides: Partial<Session> = {}): Session {
projectId: "my-project",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
lifecycle,
branch: "feat/test",
issueId: null,

View File

@ -4,6 +4,7 @@ import {
SessionNotFoundError,
SessionNotRestorableError,
createInitialCanonicalLifecycle,
createActivitySignal,
type Session,
type SessionManager,
type OrchestratorConfig,
@ -27,6 +28,11 @@ function makeSession(overrides: Partial<Session> & { id: string }): Session {
projectId: "my-app",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
lifecycle,
branch: null,
issueId: null,

View File

@ -1,25 +1,208 @@
import type { DashboardSession, DashboardPR } from "@/lib/types";
import type {
DashboardActivitySignal,
DashboardLifecycle,
DashboardPR,
DashboardSession,
} from "@/lib/types";
function humanize(token: string): string {
return token.replace(/_/g, " ");
}
function deriveActivitySignal(session: DashboardSession): DashboardActivitySignal {
if (session.activitySignal) {
return session.activitySignal;
}
if (session.activity === "exited") {
return {
state: "valid",
activity: "exited",
timestamp: session.lastActivityAt,
source: "runtime",
};
}
if (session.activity === null) {
return {
state: "unavailable",
activity: null,
timestamp: null,
source: "none",
};
}
return {
state: "valid",
activity: session.activity,
timestamp: session.lastActivityAt,
source: "native",
};
}
function deriveLifecycle(session: DashboardSession): DashboardLifecycle {
const isRuntimeEnded =
session.activity === "exited" ||
session.status === "killed" ||
session.status === "terminated" ||
session.status === "cleanup" ||
session.status === "done" ||
session.status === "merged";
const sessionFacet = (() => {
switch (session.status) {
case "spawning":
return { state: "not_started", reason: "spawn_requested" };
case "detecting":
return { state: "detecting", reason: "probe_failure" };
case "needs_input":
return { state: "needs_input", reason: "awaiting_user_input" };
case "stuck":
case "errored":
return { state: "stuck", reason: "probe_failure" };
case "done":
return { state: "done", reason: "research_complete" };
case "merged":
return { state: "done", reason: "merged_waiting_decision" };
case "cleanup":
return { state: "terminated", reason: "agent_process_exited" };
case "killed":
return { state: "terminated", reason: "manually_killed" };
case "terminated":
return { state: "terminated", reason: "runtime_lost" };
case "pr_open":
return { state: "working", reason: "pr_created" };
case "ci_failed":
return { state: "working", reason: "fixing_ci" };
case "changes_requested":
return { state: "working", reason: "resolving_review_comments" };
case "review_pending":
case "approved":
case "mergeable":
case "idle":
return { state: "idle", reason: "awaiting_external_review" };
case "working":
default:
return { state: "working", reason: "task_in_progress" };
}
})();
const prFacet = (() => {
if (session.pr?.state === "merged" || session.status === "merged") {
return { state: "merged", reason: "merged" };
}
if (session.pr?.state === "closed") {
return { state: "closed", reason: "closed_unmerged" };
}
if (session.pr) {
if (session.pr.isDraft) {
return { state: "open", reason: "in_progress" };
}
if (session.pr.ciStatus === "failing") {
return { state: "open", reason: "ci_failing" };
}
if (session.pr.reviewDecision === "changes_requested") {
return { state: "open", reason: "changes_requested" };
}
if (session.pr.unresolvedThreads > 0) {
return { state: "open", reason: "review_pending" };
}
if (session.pr.reviewDecision === "approved") {
const ready =
session.pr.mergeability.mergeable &&
session.pr.mergeability.ciPassing &&
session.pr.mergeability.approved &&
session.pr.mergeability.noConflicts;
return { state: "open", reason: ready ? "merge_ready" : "in_progress" };
}
if (session.pr.reviewDecision === "pending" || session.pr.reviewDecision === "none") {
return { state: "open", reason: "review_pending" };
}
return { state: "open", reason: "in_progress" };
}
return { state: "none", reason: "not_created" };
})();
const runtimeFacet = (() => {
if (session.status === "detecting") {
return { state: "probe_failed", reason: "probe_error" };
}
if (session.status === "killed") {
return { state: "missing", reason: "manual_kill_requested" };
}
if (session.status === "terminated") {
return { state: "missing", reason: "process_missing" };
}
if (isRuntimeEnded) {
return { state: "exited", reason: "process_missing" };
}
if (session.status === "spawning") {
return { state: "unknown", reason: "spawn_incomplete" };
}
return { state: "alive", reason: "process_running" };
})();
return {
sessionState: sessionFacet.state,
sessionReason: sessionFacet.reason,
prState: prFacet.state,
prReason: prFacet.reason,
runtimeState: runtimeFacet.state,
runtimeReason: runtimeFacet.reason,
session: {
state: sessionFacet.state,
reason: sessionFacet.reason,
label: humanize(sessionFacet.state),
reasonLabel: humanize(sessionFacet.reason),
startedAt: session.createdAt,
completedAt: sessionFacet.state === "done" ? session.lastActivityAt : null,
terminatedAt: sessionFacet.state === "terminated" ? session.lastActivityAt : null,
lastTransitionAt: session.lastActivityAt,
},
pr: {
state: prFacet.state,
reason: prFacet.reason,
label: humanize(prFacet.state),
reasonLabel: humanize(prFacet.reason),
number: session.pr?.number ?? null,
url: session.pr?.url ?? null,
lastObservedAt: session.lastActivityAt,
},
runtime: {
state: runtimeFacet.state,
reason: runtimeFacet.reason,
label: humanize(runtimeFacet.state),
reasonLabel: humanize(runtimeFacet.reason),
lastObservedAt: session.lastActivityAt,
},
legacyStatus: session.status,
evidence: null,
detectingAttempts: session.status === "detecting" ? 1 : 0,
detectingEscalatedAt: null,
summary: `Session ${humanize(sessionFacet.state)}, PR ${humanize(prFacet.state)}, runtime ${humanize(runtimeFacet.state)}`,
guidance: null,
};
}
/** Create a minimal mock session with overrides */
export function makeSession(overrides: Partial<DashboardSession> = {}): DashboardSession {
return {
const baseSession: DashboardSession = {
id: "test-1",
projectId: "my-app",
status: "working",
activity: "active",
lifecycle: {
sessionState: "working",
sessionReason: "task_in_progress",
prState: "none",
prReason: "not_created",
runtimeState: "alive",
runtimeReason: "process_running",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date().toISOString(),
source: "native",
},
lifecycle: undefined,
branch: "feat/test",
issueId: "https://linear.app/test/issue/INT-100",
issueUrl: "https://linear.app/test/issue/INT-100",
issueLabel: "INT-100",
issueTitle: "Test issue",
issueId: null,
issueUrl: null,
issueLabel: null,
issueTitle: null,
userPrompt: null,
summary: "Test session",
summaryIsFallback: false,
@ -27,7 +210,18 @@ export function makeSession(overrides: Partial<DashboardSession> = {}): Dashboar
lastActivityAt: new Date().toISOString(),
pr: null,
metadata: {},
};
const session = {
...baseSession,
...overrides,
issueLabel: overrides.issueLabel ?? overrides.issueId ?? baseSession.issueLabel,
} satisfies DashboardSession;
return {
...session,
activitySignal: overrides.activitySignal ?? deriveActivitySignal(session),
lifecycle: overrides.lifecycle ?? deriveLifecycle(session),
};
}

View File

@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import {
createInitialCanonicalLifecycle,
createActivitySignal,
type Session,
type SessionManager,
type OrchestratorConfig,
@ -37,6 +38,11 @@ function makeSession(overrides: Partial<Session> & { id: string }): Session {
projectId: "my-app",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date("2025-01-01T00:00:00Z"),
source: "native",
}),
lifecycle,
branch: null,
issueId: null,

View File

@ -18,6 +18,8 @@ import {
getRuntimeTruthReasonLabel,
getLifecycleGuidance,
getLifecycleEvidence,
getActivitySignalLabel,
getActivitySignalReasonLabel,
isDashboardRuntimeEnded,
isDashboardSessionRestorable,
} from "@/lib/types";
@ -153,6 +155,11 @@ function SessionTruthPanel({ session }: { session: DashboardSession }) {
if (!session.lifecycle) return null;
const facts = [
{
heading: "Activity",
label: getActivitySignalLabel(session),
reason: getActivitySignalReasonLabel(session),
},
{
heading: "Session",
label: getSessionTruthLabel(session),
@ -559,7 +566,7 @@ export function SessionDetail({
isDashboardSessionRestorable(session) &&
!NON_RESTORABLE_STATUSES.has(session.status);
const activity = (session.activity && activityMeta[session.activity]) ?? {
label: session.activity ?? "unknown",
label: getActivitySignalLabel(session),
color: "var(--color-text-muted)",
};
const headline = getSessionTitle(session);

View File

@ -318,7 +318,7 @@ describe("Dashboard mobile layout", () => {
issueTitle: "Restore completed agent",
branch: null,
lastActivityAt: "2026-04-11T09:07:00.000Z",
pr: makePR({ number: 209, state: "merged", title: "Wrapped up work" }),
pr: makePR({ number: 209, state: "closed", title: "Wrapped up work" }),
}),
]}
/>,
@ -331,7 +331,7 @@ describe("Dashboard mobile layout", () => {
"href",
"https://github.com/acme/app/pull/100",
);
expect(screen.getByText("merged")).toBeInTheDocument();
expect(screen.getByText("terminated")).toBeInTheDocument();
expect(screen.getByText("2h ago")).toBeInTheDocument();
await act(async () => {

View File

@ -120,7 +120,7 @@ describe("SessionDetail desktop layout", () => {
);
expect(screen.getByText("3 files")).toBeInTheDocument();
expect(screen.getByText("Draft")).toBeInTheDocument();
expect(screen.getByText(/Changes requested/i)).toBeInTheDocument();
expect(screen.getAllByText(/Changes requested/i).length).toBeGreaterThan(0);
expect(screen.getByText(/Merge conflicts/i)).toBeInTheDocument();
expect(screen.getByText(/Unresolved Comments/i)).toBeInTheDocument();
expect(screen.getByText("Tighten the copy")).toBeInTheDocument();

View File

@ -16,6 +16,12 @@ function makeSession(overrides?: Partial<DashboardSession>): DashboardSession {
projectId: "test",
status: "working",
activity: "active",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date().toISOString(),
source: "native",
},
branch: null,
issueId: null,
issueUrl: null,

View File

@ -40,6 +40,12 @@ function createCoreSession(overrides?: Partial<Session>): Session {
projectId: "test",
status: "working",
activity: "active",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date("2025-01-01T01:00:00Z"),
source: "native",
},
lifecycle,
branch: "feat/test",
issueId: null,
@ -130,6 +136,13 @@ describe("sessionToDashboard", () => {
expect(dashboard.projectId).toBe("test");
expect(dashboard.status).toBe("working");
expect(dashboard.activity).toBe("active");
expect(dashboard.activitySignal).toEqual({
state: "valid",
activity: "active",
timestamp: "2025-01-01T01:00:00.000Z",
source: "native",
detail: undefined,
});
expect(dashboard.branch).toBe("feat/test");
expect(dashboard.createdAt).toBe("2025-01-01T00:00:00.000Z");
expect(dashboard.lastActivityAt).toBe("2025-01-01T01:00:00.000Z");
@ -510,6 +523,12 @@ describe("enrichSessionPR", () => {
projectId: "test",
status: "working",
activity: "active",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date().toISOString(),
source: "native",
},
branch: "feat/test",
issueId: null,
issueUrl: null,
@ -717,6 +736,12 @@ describe("enrichSessionIssueTitle", () => {
projectId: "test",
status: "working",
activity: "active",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date().toISOString(),
source: "native",
},
branch: "feat/test",
issueId: null,
issueUrl: null,

View File

@ -5,6 +5,8 @@
import { describe, it, expect } from "vitest";
import {
getAttentionLevel,
getActivitySignalLabel,
getActivitySignalReasonLabel,
isPRMergeReady,
TERMINAL_STATUSES,
TERMINAL_ACTIVITIES,
@ -27,6 +29,12 @@ function createSession(overrides?: Partial<DashboardSession>): DashboardSession
projectId: "test",
status: "working",
activity: "active",
activitySignal: {
state: "valid",
activity: "active",
timestamp: new Date().toISOString(),
source: "native",
},
branch: "feat/test",
issueId: null,
issueUrl: null,
@ -773,3 +781,35 @@ describe("constants sync with core", () => {
expect(NON_RESTORABLE_STATUSES).toBe(CORE_NON_RESTORABLE_STATUSES);
});
});
describe("activity signal fallback", () => {
it("treats legacy idle activity without a timestamp as stale", () => {
const session = createSession({
activity: "idle",
activitySignal: undefined,
});
expect(getActivitySignalLabel(session)).toBe("idle (stale)");
expect(getActivitySignalReasonLabel(session)).toBe("missing timestamp");
});
it("treats legacy blocked activity without a timestamp as stale", () => {
const session = createSession({
activity: "blocked",
activitySignal: undefined,
});
expect(getActivitySignalLabel(session)).toBe("blocked (stale)");
expect(getActivitySignalReasonLabel(session)).toBe("missing timestamp");
});
it("keeps legacy active activity valid", () => {
const session = createSession({
activity: "active",
activitySignal: undefined,
});
expect(getActivitySignalLabel(session)).toBe("active");
expect(getActivitySignalReasonLabel(session)).toBeNull();
});
});

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createActivitySignal,
createInitialCanonicalLifecycle,
type ProjectConfig,
type SCMWebhookEvent,
@ -93,6 +94,11 @@ describe("findAffectedSessions", () => {
projectId: "my-app",
status: "working",
activity: "active",
activitySignal: createActivitySignal("valid", {
activity: "active",
timestamp: new Date(),
source: "native",
}),
lifecycle: activeLifecycle,
branch: "feat/one",
issueId: null,
@ -118,6 +124,11 @@ describe("findAffectedSessions", () => {
projectId: "my-app",
status: "merged",
activity: "exited",
activitySignal: createActivitySignal("valid", {
activity: "exited",
timestamp: new Date(),
source: "runtime",
}),
lifecycle: mergedLifecycle,
branch: "feat/one",
issueId: null,

View File

@ -166,6 +166,13 @@ export function sessionToDashboard(session: Session): DashboardSession {
projectId: session.projectId,
status: session.status,
activity: session.activity,
activitySignal: {
state: session.activitySignal.state,
activity: session.activitySignal.activity,
timestamp: session.activitySignal.timestamp?.toISOString() ?? null,
source: session.activitySignal.source,
detail: session.activitySignal.detail,
},
lifecycle: buildDashboardLifecycle(session),
branch: session.branch,
issueId: session.issueId, // Deprecated: kept for backwards compatibility

View File

@ -11,6 +11,8 @@
export type {
SessionStatus,
ActivityState,
ActivitySignalState,
ActivitySignalSource,
CIStatus,
ReviewDecision,
MergeReadiness,
@ -35,6 +37,8 @@ import {
type CIStatus,
type SessionStatus,
type ActivityState,
type ActivitySignalState,
type ActivitySignalSource,
type ReviewDecision,
type CanonicalSessionState,
type CanonicalSessionReason,
@ -72,6 +76,7 @@ export interface DashboardSession {
projectId: string;
status: SessionStatus;
activity: ActivityState | null;
activitySignal?: DashboardActivitySignal;
lifecycle?: DashboardLifecycle;
branch: string | null;
issueId: string | null; // Deprecated: use issueUrl instead
@ -89,6 +94,14 @@ export interface DashboardSession {
attentionLevel?: AttentionLevel;
}
export interface DashboardActivitySignal {
state: ActivitySignalState;
activity: ActivityState | null;
timestamp: string | null;
source: ActivitySignalSource;
detail?: string;
}
export interface DashboardLifecycleFacet<
TState extends string = string,
TReason extends string = string,
@ -249,28 +262,28 @@ function humanizeLifecycleToken(token: string): string {
}
export function getSessionTruthLabel(session: DashboardSession): string {
return session.lifecycle?.session.label ?? humanizeLifecycleToken(session.status);
return session.lifecycle?.session?.label ?? humanizeLifecycleToken(session.status);
}
export function getSessionTruthReasonLabel(session: DashboardSession): string | null {
return session.lifecycle?.session.reasonLabel ?? null;
return session.lifecycle?.session?.reasonLabel ?? null;
}
export function getPRTruthLabel(session: DashboardSession): string {
if (session.lifecycle) return session.lifecycle.pr.label;
if (session.lifecycle?.pr?.label) return session.lifecycle.pr.label;
return session.pr?.state ? humanizeLifecycleToken(session.pr.state) : "not created";
}
export function getPRTruthReasonLabel(session: DashboardSession): string | null {
return session.lifecycle?.pr.reasonLabel ?? null;
return session.lifecycle?.pr?.reasonLabel ?? null;
}
export function getRuntimeTruthLabel(session: DashboardSession): string {
return session.lifecycle?.runtime.label ?? "unknown";
return session.lifecycle?.runtime?.label ?? "unknown";
}
export function getRuntimeTruthReasonLabel(session: DashboardSession): string | null {
return session.lifecycle?.runtime.reasonLabel ?? null;
return session.lifecycle?.runtime?.reasonLabel ?? null;
}
export function getLifecycleGuidance(session: DashboardSession): string | null {
@ -281,6 +294,64 @@ export function getLifecycleEvidence(session: DashboardSession): string | null {
return session.lifecycle?.evidence ?? session.metadata["lifecycleEvidence"] ?? null;
}
function resolveActivitySignal(session: DashboardSession): DashboardActivitySignal {
if (session.activitySignal) {
return session.activitySignal;
}
if (session.activity === null) {
return {
state: "unavailable",
activity: null,
timestamp: null,
source: "none",
};
}
if (session.activity === "idle" || session.activity === "blocked") {
return {
state: "stale",
activity: session.activity,
timestamp: null,
source: "none",
detail: "missing_timestamp",
};
}
return {
state: "valid",
activity: session.activity,
timestamp: null,
source: "none",
};
}
export function getActivitySignalLabel(session: DashboardSession): string {
const signal = resolveActivitySignal(session);
switch (signal.state) {
case "valid":
return signal.activity ? humanizeLifecycleToken(signal.activity) : "valid";
case "stale":
return signal.activity ? `${humanizeLifecycleToken(signal.activity)} (stale)` : "stale";
case "null":
return "no activity signal";
case "unavailable":
return "activity unavailable";
case "probe_failure":
return "activity probe failed";
}
}
export function getActivitySignalReasonLabel(session: DashboardSession): string | null {
const signal = resolveActivitySignal(session);
const parts = [
signal.source !== "none" ? `source ${signal.source}` : null,
signal.timestamp ? `observed ${signal.timestamp}` : null,
signal.detail ? humanizeLifecycleToken(signal.detail) : null,
].filter((value): value is string => value !== null);
return parts.length > 0 ? parts.join(" • ") : null;
}
export function isDashboardSessionDone(session: DashboardSession): boolean {
if (session.lifecycle) {
return (