Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hide-stale-cleanup-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gh-symphony/cli": patch
---

Hide stale incomplete-turn recovery from repo status and repo explain after issue workspace cleanup removes the backing workspace for #421.
44 changes: 37 additions & 7 deletions packages/cli/src/commands/repo-explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
import type { GlobalOptions } from "../index.js";
import {
WorkflowConfigStore,
type IssueWorkspaceRecord,
type IssueOrchestrationRecord,
type OrchestratorProjectConfig,
type OrchestratorRunRecord,
Expand Down Expand Up @@ -148,13 +149,17 @@ const handler = async (
identifier.trim().toLowerCase()
) ?? null
);
const [issues, issue, issueRecords, runs, snapshot] = await Promise.all([
issuesPromise,
issuePromise,
readJsonFile<IssueOrchestrationRecord[]>(join(runtimeRoot, "issues.json")),
readRuns(runtimeRoot, projectConfig.projectId),
readJsonFile<ProjectStatusSnapshot>(join(runtimeRoot, "status.json")),
]);
const [issues, issue, issueRecords, issueWorkspaces, runs, snapshot] =
await Promise.all([
issuesPromise,
issuePromise,
readJsonFile<IssueOrchestrationRecord[]>(
join(runtimeRoot, "issues.json")
),
readIssueWorkspaces(runtimeRoot, projectConfig.projectId),
readRuns(runtimeRoot, projectConfig.projectId),
readJsonFile<ProjectStatusSnapshot>(join(runtimeRoot, "status.json")),
]);
const canonicalIssues = resolveCanonicalSubjectIssues(issues);
const canonicalIssue =
canonicalIssues.find((candidate) =>
Expand Down Expand Up @@ -191,6 +196,7 @@ const handler = async (
allIssues: canonicalIssues,
lifecycle: workflow.lifecycle,
issueRecords: issueRecords ?? [],
issueWorkspaces,
runs,
activeRunCount,
maxConcurrentAgents: workflow.maxConcurrentAgents,
Expand Down Expand Up @@ -361,6 +367,30 @@ async function readRuns(
);
}

async function readIssueWorkspaces(
runtimeRoot: string,
projectId: string
): Promise<IssueWorkspaceRecord[]> {
let workspaceKeys: string[];
try {
workspaceKeys = await readdir(runtimeRoot);
} catch {
return [];
}

const records = await Promise.all(
workspaceKeys.map((workspaceKey) =>
readJsonFile<IssueWorkspaceRecord>(
join(runtimeRoot, workspaceKey, "workspace.json")
)
)
);
return records.filter(
(record): record is IssueWorkspaceRecord =>
record !== null && record.projectId === projectId
);
}

async function readJsonFile<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/src/commands/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,56 @@ describe("status command", () => {
expect(stdout.output()).toContain("Tokens: 600 / 1,700 total");
});

it("does not render incomplete-turn recovery when snapshot recovery is null", async () => {
const configDir = await createConfigFixture();
const projectId = "tenant-a";
await writeFile(
join(configDir, "status.json"),
JSON.stringify(
{
projectId,
slug: projectId,
tracker: { adapter: "github-project", bindingId: "project-1" },
lastTickAt: "2026-03-30T11:00:00.000Z",
health: "idle",
summary: {
dispatched: 2,
suppressed: 1,
recovered: 0,
activeRuns: 0,
},
activeRuns: [],
retryQueue: [],
codexTotals: {
inputTokens: 1400,
outputTokens: 300,
totalTokens: 1700,
secondsRunning: 60,
},
lastError: null,
recovery: null,
},
null,
2
) + "\n",
"utf8"
);
const stdout = captureWrites(process.stdout);

try {
await statusCommand([], {
configDir,
verbose: false,
json: false,
noColor: true,
});
} finally {
stdout.restore();
}

expect(stdout.output()).not.toContain("Recoverable incomplete turn:");
});

it("renders incomplete-turn recovery details in text status output", async () => {
const configDir = await createConfigFixture();
const projectId = "tenant-a";
Expand Down Expand Up @@ -249,6 +299,58 @@ describe("status command", () => {
);
});

it("does not render incomplete-turn recovery details when snapshot recovery is null", async () => {
const configDir = await createConfigFixture();
const projectId = "tenant-a";
await writeFile(
join(configDir, "status.json"),
JSON.stringify(
{
projectId,
slug: projectId,
tracker: { adapter: "github-project", bindingId: "project-1" },
lastTickAt: "2026-03-30T11:00:00.000Z",
health: "idle",
summary: {
dispatched: 2,
suppressed: 1,
recovered: 0,
activeRuns: 0,
},
activeRuns: [],
retryQueue: [],
codexTotals: {
inputTokens: 1400,
outputTokens: 300,
totalTokens: 1700,
secondsRunning: 60,
},
lastError: null,
recovery: null,
},
null,
2
) + "\n",
"utf8"
);
const stdout = captureWrites(process.stdout);

try {
await statusCommand([], {
configDir,
verbose: false,
json: false,
noColor: true,
});
} finally {
stdout.restore();
}

const output = stdout.output();
expect(output).not.toContain("Recoverable incomplete turn:");
expect(output).not.toContain("Workspace /tmp/work/repository");
});

it("falls back to the legacy per-project status snapshot path", async () => {
const configDir = await createConfigFixture("legacy");
const stdout = captureWrites(process.stdout);
Expand Down
104 changes: 104 additions & 0 deletions packages/core/src/observability/snapshot-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
OrchestratorProjectConfig,
OrchestratorRunRecord,
} from "../contracts/status-surface.js";
import type { IssueWorkspaceRecord } from "../domain/issue.js";

/**
* Helper to create a minimal OrchestratorProjectConfig for testing
Expand Down Expand Up @@ -69,6 +70,25 @@ function mockRun(
};
}

function mockIssueWorkspace(
overrides?: Partial<IssueWorkspaceRecord>
): IssueWorkspaceRecord {
return {
workspaceKey: "key-001",
projectId: "tenant-123",
adapter: "github",
issueSubjectId: "subject-001",
issueIdentifier: "acme/platform#42",
workspacePath: "/tmp/workspace",
repositoryPath: "/tmp/work/repository",
status: "active",
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
lastError: null,
...overrides,
};
}

describe("buildProjectSnapshot", () => {
it("returns idle health when no active runs and no error", () => {
const input: SnapshotInput = {
Expand Down Expand Up @@ -382,6 +402,90 @@ describe("buildProjectSnapshot", () => {
expect(snapshot.recovery).toEqual(latestRun.recovery);
});

it("does not surface incomplete-turn recovery for a removed issue workspace", () => {
const run = mockRun({
runId: "run-removed",
status: "suppressed",
issueWorkspaceKey: "key-removed",
updatedAt: "2024-01-01T00:07:00Z",
recovery: {
kind: "incomplete-turn-dirty-workspace",
runId: "run-removed",
issueId: "issue-001",
issueIdentifier: "acme/platform#42",
workspacePath: "/tmp/work/removed-repository",
dirtyFiles: ["partial.txt"],
lastEvent: "heartbeat",
lastEventAt: "2024-01-01T00:06:30Z",
sessionId: "session-removed",
threadId: "thread-removed",
suggestedCommand:
"cd /tmp/work/removed-repository && git status --short && git diff",
detectedAt: "2024-01-01T00:07:00Z",
},
});

const snapshot = buildProjectSnapshot({
project: mockProject(),
activeRuns: [],
allRuns: [run],
issueWorkspaces: [
mockIssueWorkspace({
workspaceKey: "key-removed",
repositoryPath: "/tmp/work/removed-repository",
status: "removed",
}),
],
summary: { dispatched: 0, suppressed: 1, recovered: 0 },
lastTickAt: "2024-01-01T00:10:00Z",
lastError: null,
});

expect(snapshot.recovery).toBeNull();
});

it("surfaces incomplete-turn recovery for an active issue workspace", () => {
const run = mockRun({
runId: "run-active",
status: "suppressed",
issueWorkspaceKey: "key-active",
updatedAt: "2024-01-01T00:07:00Z",
recovery: {
kind: "incomplete-turn-dirty-workspace",
runId: "run-active",
issueId: "issue-001",
issueIdentifier: "acme/platform#42",
workspacePath: "/tmp/work/active-repository",
dirtyFiles: ["partial.txt"],
lastEvent: "heartbeat",
lastEventAt: "2024-01-01T00:06:30Z",
sessionId: "session-active",
threadId: "thread-active",
suggestedCommand:
"cd /tmp/work/active-repository && git status --short && git diff",
detectedAt: "2024-01-01T00:07:00Z",
},
});

const snapshot = buildProjectSnapshot({
project: mockProject(),
activeRuns: [],
allRuns: [run],
issueWorkspaces: [
mockIssueWorkspace({
workspaceKey: "key-active",
repositoryPath: "/tmp/work/active-repository",
status: "active",
}),
],
summary: { dispatched: 0, suppressed: 1, recovered: 0 },
lastTickAt: "2024-01-01T00:10:00Z",
lastError: null,
});

expect(snapshot.recovery).toEqual(run.recovery);
});

it("does not surface stale recovery after the recovery run completes", () => {
const suppressedRun = mockRun({
runId: "run-suppressed",
Expand Down
Loading
Loading