Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0db31a7
feat(cli): unify notification queue for cron and background agents
tanzhenxin Apr 16, 2026
de0d8d5
feat(cli): emit SDK task events for background subagents
tanzhenxin Apr 17, 2026
e8b46c7
fix: address codex review issues for background subagents
tanzhenxin Apr 17, 2026
14673c0
fix(cli): restore cancellation, approval, and error paths in queued d…
tanzhenxin Apr 17, 2026
d5ce385
fix(cli): skip duplicate user-message item for cron prompts
tanzhenxin Apr 17, 2026
98df850
fix(cli): honor SIGINT/SIGTERM during cron scheduler wait
tanzhenxin Apr 17, 2026
818f683
feat(core): propagate tool-use id through background agent notifications
tanzhenxin Apr 17, 2026
e398631
fix(cli): drain single-flight race kept task_notification from emitting
tanzhenxin Apr 17, 2026
feadf05
fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doe…
tanzhenxin Apr 17, 2026
ba99d10
docs(skill): tighten worked-example blurb in structured-debugging
tanzhenxin Apr 17, 2026
1104a03
docs(skill): mirror SKILL.md improvements (reframing failure mode, ge…
tanzhenxin Apr 17, 2026
58e8a1f
docs(skill): mirror worked example into .qwen/skills/structured-debug…
tanzhenxin Apr 17, 2026
8efdfa4
docs(skill): mirror generalized side-note path guidance
tanzhenxin Apr 17, 2026
09825e3
Merge branch 'feat/background-subagent' into feat/background-subagent…
tanzhenxin Apr 17, 2026
dfb091f
fix(cli): harden headless cron and background-agent failure paths
tanzhenxin Apr 17, 2026
cf9d93f
test(cli): update stdout/stderr assertions for trailing newline
tanzhenxin Apr 17, 2026
8c7693f
fix: address review comments on background-agent notifications
tanzhenxin Apr 17, 2026
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
40 changes: 34 additions & 6 deletions .qwen/skills/structured-debugging/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: structured-debugging
description:
description: >
Hypothesis-driven debugging methodology for hard bugs. Use this skill whenever
you're investigating non-trivial bugs, unexpected behavior, flaky tests, or
tracing issues through complex systems. Activate proactively when debugging
Expand Down Expand Up @@ -32,11 +32,9 @@ Good: "The leader hangs because `hasActiveTeammates()` returns true after all ag
have reported completed, likely because terminal status isn't being set on the agent
object after the backend process exits."

Create a side note file for the investigation:

```
~/.qwen/investigations/<project>-<issue>.md
```
For bugs you expect to take more than one round, create a side note file
for the investigation in whichever location the project uses for such
notes.

Write your hypothesis there. This file persists across conversation turns and even
across sessions — it's your investigation journal.
Expand All @@ -49,6 +47,12 @@ confirm or reject your hypothesis. Think about what data you need to see.
Don't scatter `console.log` everywhere. Identify the 2-3 places where your
hypothesis makes a testable prediction, and instrument those.

Prefer logging _values_ (return codes, payload contents, stream types,
message bodies, env state) over _presence checks_ ("was this function
called?", "was this branch taken?"). Code-path traces tell you what ran;
data traces tell you what it ran on. Most non-trivial bugs are correct
code processing wrong data.

Ask yourself: "If my hypothesis is correct, what will I see at point X?
If it's wrong, what will I see instead?"

Expand Down Expand Up @@ -129,6 +133,23 @@ is still broken if the inbox contains stale messages from a previous run.
Always inspect the _content_ flowing through the code, not just whether the code
runs. Check payloads, message contents, file data, and database state.

### Reframing the user's report instead of investigating it

When the user reports a symptom your own run doesn't reproduce, the
contradiction _is_ the evidence — the two environments differ in some way
you haven't identified yet. The wrong move is to reframe their report
("they must be on a stale SHA", "they must be confused about what they
saw", "must be a flake") so that your run becomes the ground truth. Once
you do that, every later piece of evidence gets bent to defend the
reframing, and the actual bug stays hidden.

The right move: catalogue what differs between their environment and
yours (TTY vs pipe, terminal emulator, shell, locale, env vars, prior
state, build artifacts) before forming any hypothesis. For ambiguous
symptoms ("no output", "it's slow", "it's wrong") ask one disambiguating
question first — e.g., "does it hang or exit cleanly?" — that prunes the
hypothesis space cheaply before any test run.

### Losing context across attempts

After several debugging rounds, you start forgetting what you already tried and
Expand Down Expand Up @@ -164,3 +185,10 @@ Fix: [what you're changing and why it addresses the root cause]
```

Then apply the fix, remove instrumentation, and verify with a clean run.

## Worked examples

- [`examples/headless-bg-agent-empty-stdout.md`](examples/headless-bg-agent-empty-stdout.md)
— pipe-captured runs all passed; the user's TTY printed nothing. The
contradiction _was_ the bug. Illustrates _reproduction contradiction is
data_ and _instrument data, not code paths_.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Worked example: headless run prints empty stdout in zsh TTY

A short qwen-code case to illustrate two failure modes from `SKILL.md`:
_reproduction contradiction is data_, and _instrument the data flow, not
just the code path_.

## The bug

User: `npm run dev -- -p "..."` in zsh prints nothing. Process exits clean,
`~/.qwen/logs` shows the model returned proper text. Stdout was empty.

Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without
a trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects
the missing newline and emits `\r\033[K` before drawing the next prompt,
erasing the line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is
invisible there.

Fix: append `\n` to the write.

## What made the case instructive

Every reproduction attempt from a debugging environment that captures
stdout (Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**.
14/14 success against the user's 0/N. Same SHA, same machine, same
command. The only variable was: pipe stdout vs TTY stdout.

That contradiction was the entire investigation. Once it was named, the
fix was one line.

## Lessons mapped to SKILL.md

- **Reproduction contradiction is data, not user error.** When your run
succeeds and the user's fails on identical state, the _difference
between the two environments_ is where the bug lives. Catalogue what
differs (TTY vs pipe, terminal emulator, shell, locale, env vars,
prior state) before forming any hypothesis. Reframing the user's
report ("they must be on stale code") burns rounds and credibility.

- **Ask the one disambiguating question first.** "Does it hang or exit
cleanly?" would have falsified the most tempting wrong hypothesis here
(the recently-fixed drain-loop hang) on turn one. For any "no output"
report, that question is free and prunes half the hypothesis space.

- **Instrument the data flow, not just the code path.** Tracing whether
`write` was called showed the happy path firing every time and resolved
nothing. The breakthrough was logging the _return value_ of
`process.stdout.write` together with `process.stdout.isTTY`. Code-path
traces tell you what ran; data traces tell you what it ran on.

- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user
sees the same output. Shell prompts can post-process trailing-newline-
less writes; terminals can swallow control sequences; pipes do
neither. When debugging interactive-shell symptoms, get evidence from
the user's actual terminal at least once.

## Reference

Fix commit: qwen-code `feadf052f` —
`fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line`
4 changes: 2 additions & 2 deletions packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ export class JsonOutputAdapter

if (this.config.getOutputFormat() === 'text') {
if (resultMessage.is_error) {
process.stderr.write(`${resultMessage.error?.message || ''}`);
process.stderr.write(`${resultMessage.error?.message || ''}\n`);
} else {
process.stdout.write(`${resultMessage.result}`);
process.stdout.write(`${resultMessage.result}\n`);
}
} else {
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
Expand Down
27 changes: 16 additions & 11 deletions packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ describe('runNonInteractive', () => {
isInteractive: vi.fn().mockReturnValue(false),
isCronEnabled: vi.fn().mockReturnValue(false),
getCronScheduler: vi.fn().mockReturnValue(null),
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
setNotificationCallback: vi.fn(),
setRegisterCallback: vi.fn(),
getRunning: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;

mockSettings = {
Expand Down Expand Up @@ -255,7 +260,7 @@ describe('runNonInteractive', () => {
'prompt-id-1',
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});

Expand Down Expand Up @@ -319,7 +324,7 @@ describe('runNonInteractive', () => {
'prompt-id-2',
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n');
});

it('should handle error during tool execution and should send error back to the model', async () => {
Expand Down Expand Up @@ -388,7 +393,7 @@ describe('runNonInteractive', () => {
'prompt-id-3',
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.\n');
});

it('should exit with error if sendMessageStream throws initially', async () => {
Expand Down Expand Up @@ -450,7 +455,7 @@ describe('runNonInteractive', () => {
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
"Sorry, I can't find that tool.\n",
);
});

Expand Down Expand Up @@ -514,7 +519,7 @@ describe('runNonInteractive', () => {
);

// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.\n');
});

it('should process input and write JSON output with stats', async () => {
Expand Down Expand Up @@ -887,7 +892,7 @@ describe('runNonInteractive', () => {
{ type: SendMessageType.UserQuery },
);

expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command\n');
});

it('should handle command that requires confirmation by returning early', async () => {
Expand All @@ -912,7 +917,7 @@ describe('runNonInteractive', () => {

// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
);
});

Expand Down Expand Up @@ -947,7 +952,7 @@ describe('runNonInteractive', () => {
{ type: SendMessageType.UserQuery },
);

expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown\n');
});

it('should handle known but unsupported slash commands like /help by returning early', async () => {
Expand All @@ -970,7 +975,7 @@ describe('runNonInteractive', () => {

// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.',
'The command "/help" is not supported in non-interactive mode.\n',
);
});

Expand All @@ -995,7 +1000,7 @@ describe('runNonInteractive', () => {

// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled',
'Unknown command result type: unhandled\n',
);
});

Expand Down Expand Up @@ -1033,7 +1038,7 @@ describe('runNonInteractive', () => {

expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');

expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged\n');
});

it('should emit stream-json envelopes when output format is stream-json', async () => {
Expand Down
Loading