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/verbose-error-causes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gh-symphony/cli": patch
---

Fix issue #396 so `-v` / `--verbose` surfaces stack traces and error cause chains for top-level CLI and orchestrator failures, including daemon startup diagnostics.
4 changes: 2 additions & 2 deletions packages/cli/src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ exports[`help output > renders the colored grouped help snapshot 1`] = `
Global Options:
--config <dir> Config directory override (advanced; default uses initialized
cwd runtime, then ~/.gh-symphony)
--verbose, -v Verbose output
--verbose, -v Verbose output, including top-level error stack traces
--json JSON output
--no-color Disable color output
--help, -h Show help
Expand Down Expand Up @@ -76,7 +76,7 @@ Maintenance:
Global Options:
--config <dir> Config directory override (advanced; default uses initialized
cwd runtime, then ~/.gh-symphony)
--verbose, -v Verbose output
--verbose, -v Verbose output, including top-level error stack traces
--json JSON output
--no-color Disable color output
--help, -h Show help
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const HELP_SECTIONS: HelpSection[] = [
},
{
name: "--verbose, -v",
description: "Verbose output",
description: "Verbose output, including top-level error stack traces",
},
{
name: "--json",
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/commands/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ const ghAuthMocks = vi.hoisted(() => ({
const promptMocks = vi.hoisted(() => ({
confirm: vi.fn(),
}));
const childProcessMocks = vi.hoisted(() => ({
spawn: vi.fn(() => ({
pid: 2468,
unref: vi.fn(),
})),
}));

vi.mock("node:child_process", () => ({
spawn: childProcessMocks.spawn,
}));

vi.mock("@gh-symphony/orchestrator", () => ({
acquireProjectLock,
Expand Down Expand Up @@ -114,6 +124,11 @@ beforeEach(() => {
ghAuthMocks.runGhAuthRefresh.mockReset();
promptMocks.confirm.mockReset();
promptMocks.confirm.mockResolvedValue(true);
childProcessMocks.spawn.mockClear();
childProcessMocks.spawn.mockReturnValue({
pid: 2468,
unref: vi.fn(),
});
process.env.GITHUB_GRAPHQL_TOKEN = originalGithubToken;
process.env.LINEAR_API_KEY = originalLinearApiKey;
serviceDependencies.length = 0;
Expand Down Expand Up @@ -608,6 +623,47 @@ describe("start command foreground locking", () => {
expect(exitSpy).toHaveBeenCalledWith(0);
});

it("maps the global verbose option to orchestrator verbose logs", async () => {
const configDir = await createConfigFixture({
activeProject: "tenant-a",
projects: [createProject("tenant-a", "acme", "platform")],
});
run.mockResolvedValue(undefined);

await startModule.default([], {
...baseOptions(configDir),
verbose: true,
});

expect(serviceDependencies.at(-1)).toMatchObject({
logLevel: "verbose",
});
});

it("passes global verbose through to daemon child diagnostics", async () => {
const configDir = await createConfigFixture({
activeProject: "tenant-a",
projects: [createProject("tenant-a", "acme", "platform")],
});

await startModule.default(["--daemon"], {
...baseOptions(configDir),
verbose: true,
});

expect(childProcessMocks.spawn).toHaveBeenCalledTimes(1);
const childArgs = childProcessMocks.spawn.mock.calls[0]?.[1];
expect(childArgs).toEqual(
expect.arrayContaining([
"repo",
"start",
"--verbose",
"--log-level",
"verbose",
])
);
});

it("tails completed worker logs from the flat runtime run path", async () => {
const configDir = await createConfigFixture({
activeProject: "tenant-a",
Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,10 +686,11 @@ const handler = async (
const runtimeRoot = resolveRuntimeRoot(options.configDir);
const projectId = projectConfig.projectId;
let logLevel: OrchestratorLogLevel;
const requestedLogLevel =
parsed.logLevel ??
(options.verbose ? "verbose" : process.env.SYMPHONY_LOG_LEVEL);
try {
logLevel = resolveOrchestratorLogLevel(
parsed.logLevel ?? process.env.SYMPHONY_LOG_LEVEL
);
logLevel = resolveOrchestratorLogLevel(requestedLogLevel);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : "Unsupported log level"}\n`
Expand All @@ -709,7 +710,7 @@ const handler = async (
await startDaemon(
options,
projectId,
parsed.logLevel,
parsed.logLevel ?? (options.verbose ? "verbose" : undefined),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve verbose diagnostics for daemon child

In the daemon path, gh-symphony -v repo start --daemon converts the parent verbose flag only into --log-level verbose for the detached child. The child is another CLI process whose top-level catch enables stack/cause output only when hasVerboseFlag(process.argv.slice(2)) sees -v/--verbose, so uncaught startup failures in the daemon log still get the old single-line message even though the user requested verbose diagnostics. Pass the verbose flag through separately, or make the child diagnostic check recognize this propagated state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5049ad9: startDaemon now passes --verbose to the child CLI when the parent global verbose option is set, alongside --log-level verbose. Added start.test.ts coverage asserting the daemon child argv includes both flags.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검증 결과: 데몬 경로의 verbose가 자식 프로세스 top-level catch까지 전달되지 않습니다 (Codex P2 확인).

startDaemon은 자식을 gh-symphony repo start --log-level verbose 로 띄웁니다(line ~1087). 그런데 자식 CLI의 top-level catch(packages/cli/src/index.ts)는 hasVerboseFlag(process.argv.slice(2)) 로만 verbose를 켭니다. hasVerboseFlag-v/--verbose만 인식하고 --log-level verbose는 인식하지 않습니다.

로컬에서 확인:

hasVerboseFlag(["repo","start","--log-level","verbose"]) === false

따라서 gh-symphony -v repo start --daemon 으로 띄운 데몬이 startup 중 top-level 에러로 죽으면, 사용자가 -v를 줬는데도 데몬 로그에는 여전히 한 줄 메시지만 남습니다 — #396이 정확히 노리는 시나리오입니다.

범위는 좁습니다(서비스 레벨 verbose 로깅은 정상 전달되며, 영향은 데몬의 top-level uncaught 에러에 한정). 선택적/비차단 수정 제안: verbose 요청 시 자식 args에 --verbose도 함께 넘겨 자식의 top-level catch가 인식하도록 하면 됩니다.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5049ad9: startDaemon now passes --verbose to the child CLI when the parent global verbose option is set, alongside --log-level verbose. Added start.test.ts coverage asserting the daemon child argv includes both flags.

parsed.httpPort,
parsed.webPort,
parsed.assignedOnly === true
Expand Down Expand Up @@ -1080,6 +1081,7 @@ async function startDaemon(
process.argv[1]!,
"repo",
"start",
...(options.verbose ? ["--verbose"] : []),
...(assignedOnly ? ["--assigned-only"] : []),
...(httpPort !== undefined ? ["--http", String(httpPort)] : []),
...(webPort !== undefined ? ["--web", String(webPort)] : []),
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { realpathSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { formatErrorForTerminal, hasVerboseFlag } from "@gh-symphony/core";
import {
Command,
CommanderError,
Expand Down Expand Up @@ -89,7 +90,7 @@ function addGlobalOptions(command: Command): Command {
return command
.option("--config <dir>", "Config directory")
.addOption(new Option("--config-dir <dir>").hideHelp())
.option("-v, --verbose", "Enable verbose output")
.option("-v, --verbose", "Enable verbose output with stack traces")
.option("--json", "Output in JSON format")
.option("--no-color", "Disable color output");
}
Expand Down Expand Up @@ -763,7 +764,9 @@ if (
) {
main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : "Unknown error"}\n`
formatErrorForTerminal(error, {
verbose: hasVerboseFlag(process.argv.slice(2)),
})
);
process.exitCode = 1;
});
Expand Down
54 changes: 54 additions & 0 deletions packages/core/src/observability/error-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { formatErrorForTerminal, hasVerboseFlag } from "./error-format.js";

describe("terminal error formatting", () => {
it("keeps non-verbose errors to a single message line", () => {
const error = new Error("top-level failure", {
cause: new Error("root cause"),
});

expect(formatErrorForTerminal(error)).toBe("top-level failure\n");
});

it("prints stacks and walks causes when verbose", () => {
const cause = new Error("root cause");
cause.stack = "Error: root cause\n at root";
const error = new Error("top-level failure", { cause });
error.stack = "Error: top-level failure\n at top";

expect(formatErrorForTerminal(error, { verbose: true })).toBe(
[
"Error: top-level failure",
" at top",
"Caused by: Error: root cause",
" at root",
"",
].join("\n")
);
});

it("detects circular cause chains before repeating the top-level error", () => {
const cause = new Error("root cause");
cause.stack = "Error: root cause\n at root";
const error = new Error("top-level failure", { cause });
error.stack = "Error: top-level failure\n at top";
cause.cause = error;

expect(formatErrorForTerminal(error, { verbose: true })).toBe(
[
"Error: top-level failure",
" at top",
"Caused by: Error: root cause",
" at root",
"Caused by: [Circular cause]",
"",
].join("\n")
);
});

it("detects both verbose flags", () => {
expect(hasVerboseFlag(["repo", "start", "--verbose"])).toBe(true);
expect(hasVerboseFlag(["-v", "repo", "start"])).toBe(true);
expect(hasVerboseFlag(["repo", "start"])).toBe(false);
});
});
50 changes: 50 additions & 0 deletions packages/core/src/observability/error-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type TerminalErrorFormatOptions = {
verbose?: boolean;
};

export function hasVerboseFlag(argv: readonly string[]): boolean {
return argv.some((arg) => arg === "--verbose" || arg === "-v");
}

export function formatErrorForTerminal(
error: unknown,
options: TerminalErrorFormatOptions = {}
): string {
if (!options.verbose) {
return `${error instanceof Error ? error.message : "Unknown error"}\n`;
}

const lines = [formatSingleError(error)];
const seenCauses = new Set<object>();
if (typeof error === "object" && error !== null) {
seenCauses.add(error);
}
let cause = resolveCause(error);

while (cause !== undefined) {
if (typeof cause === "object" && cause !== null) {
if (seenCauses.has(cause)) {
lines.push("Caused by: [Circular cause]");
break;
}
seenCauses.add(cause);
}

lines.push(`Caused by: ${formatSingleError(cause)}`);
cause = resolveCause(cause);
}

return `${lines.join("\n")}\n`;
}

function formatSingleError(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? error.message;
}

return String(error);
}

function resolveCause(error: unknown): unknown {
return error instanceof Error ? error.cause : undefined;
}
1 change: 1 addition & 0 deletions packages/core/src/observability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./fs-reader.js";
export * from "./event-formatter.js";
export * from "./redaction.js";
export * from "./status-assembler.js";
export * from "./error-format.js";
31 changes: 31 additions & 0 deletions packages/orchestrator/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,37 @@ describe("orchestrator CLI", () => {
);
});

it("treats -v as verbose log level", async () => {
const runtimeRoot = await mkdtemp(join(tmpdir(), "orchestrator-cli-"));
const service = createMockService();
const createService = vi.fn<
(
runtimeRoot: string,
projectId?: string,
options?: {
eventsDir?: string;
logLevel: OrchestratorLogLevel;
stderr: Pick<NodeJS.WriteStream, "write">;
}
) => OrchestratorService
>(() => service);

await runCli(
["run", "--runtime-root", runtimeRoot, "--project-id", "tenant-1", "-v"],
{
createService,
}
);

expect(createService).toHaveBeenCalledWith(
runtimeRoot,
"tenant-1",
expect.objectContaining({
logLevel: "verbose",
})
);
});

it("passes --events-dir to service creation", async () => {
const runtimeRoot = await mkdtemp(join(tmpdir(), "orchestrator-cli-"));
const service = createMockService();
Expand Down
21 changes: 19 additions & 2 deletions packages/orchestrator/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { pathToFileURL } from "node:url";
import { resolve } from "node:path";
import { formatErrorForTerminal, hasVerboseFlag } from "@gh-symphony/core";
import {
createStore,
OrchestratorService,
Expand Down Expand Up @@ -244,7 +245,7 @@ function parseArgs(args: string[]): {
const argument = args[index];
const value = args[index + 1];

if (!argument?.startsWith("--")) {
if (!argument?.startsWith("-")) {
continue;
}

Expand Down Expand Up @@ -276,6 +277,10 @@ function parseArgs(args: string[]): {
parsed.logLevel = value;
index += 1;
break;
case "--verbose":
case "-v":
parsed.logLevel = "verbose";
break;
default:
throw new Error(`Unknown option: ${argument}`);
}
Expand All @@ -298,8 +303,20 @@ if (
) {
main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : "Unknown error"}\n`
formatErrorForTerminal(error, {
verbose: hasVerboseOrchestratorDiagnostics(process.argv.slice(2)),
})
);
process.exitCode = 1;
});
}

function hasVerboseOrchestratorDiagnostics(argv: readonly string[]): boolean {
if (hasVerboseFlag(argv) || process.env.SYMPHONY_LOG_LEVEL === "verbose") {
return true;
}

return argv.some(
(arg, index) => arg === "--log-level" && argv[index + 1] === "verbose"
);
}
Loading