Merge pull request #127 from harshitsinghbhandari/feat/122
fix: model missing activity evidence explicitly
This commit is contained in:
commit
5fe243b39e
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue