Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b88fdac
feat(workflows): add cooperative pause and resume
qwen-code-dev-bot Aug 1, 2026
b84c144
fix(workflows): restrict pause to background runs
qqqys Aug 1, 2026
2337d58
fix(cli): clarify foreground workflow pause errors
qwen-code-dev-bot Aug 1, 2026
beba054
fix(core): preserve dispatch errors across cancellation
qwen-code-dev-bot Aug 1, 2026
552cb77
test(core): cover late workflow state callbacks
qwen-code-dev-bot Aug 1, 2026
e0ec123
fix(workflows): address review suggestions (#8320)
qwen-code-dev-bot Aug 2, 2026
ad9adcd
test(workflows): pin review-flagged mutation-surviving branches (#8320)
Aug 2, 2026
9b9d2f5
test(cli): use valid agent status in detail-view reset test (#8320)
Aug 2, 2026
e2d9f09
test(workflows): harden pause-gate settle probes with a full flush (#…
Aug 2, 2026
7b35105
fix(workflows): address round-5 review findings (#8320)
Aug 3, 2026
75c6653
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
wenshao Aug 3, 2026
bb9be5c
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 3, 2026
4b27640
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 3, 2026
3fc2555
test(ci): sync review timeout assertions with repository variables (#…
qwen-code-dev-bot Aug 3, 2026
56822a1
Merge remote-tracking branch 'origin/main' into codex/issue-8105-work…
qwen-code-dev-bot Aug 3, 2026
b2c97d5
Merge remote-tracking branch 'origin/main' into codex/issue-8105-work…
qwen-code-dev-bot Aug 4, 2026
25fb589
fix(workflows): address round-6 review findings (#8320)
qwen-code-dev-bot Aug 4, 2026
f3ea784
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 4, 2026
eb6e6a4
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
wenshao Aug 4, 2026
eafa7fb
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 4, 2026
4f89c15
fix(workflows): address round-7 review findings (#8320)
qwen-code-dev-bot Aug 4, 2026
e005d60
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 4, 2026
da95171
fix(workflows): address round-8 review findings (#8320)
qwen-code-dev-bot Aug 5, 2026
57e9044
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
wenshao Aug 5, 2026
c594078
fix(workflows): address round-9 review findings (#8320)
qwen-code-dev-bot Aug 6, 2026
3f5f27d
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
wenshao Aug 6, 2026
e27867d
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 6, 2026
9685fb0
fix(workflows): address round-10 review findings (#8320)
qwen-code-dev-bot Aug 6, 2026
fd8a6ed
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
wenshao Aug 6, 2026
81d7702
fix(workflows): address round-11 review findings (#8320)
qwen-code-dev-bot Aug 7, 2026
5c117fa
Merge branch 'main' into codex/issue-8105-workflow-pause-resume
qwen-code-dev-bot Aug 7, 2026
89b7803
fix(workflows): address round-12 review findings (#8320)
qwen-code-dev-bot Aug 7, 2026
750133f
fix(workflows): address round-13 review findings (#8320)
qwen-code-dev-bot Aug 7, 2026
444b52b
fix(workflows): address round-14 review findings (#8320)
qwen-code-dev-bot Aug 7, 2026
e41fb76
fix(workflows): address round-15 review findings (#8320)
qwen-code-dev-bot Aug 8, 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
134 changes: 130 additions & 4 deletions packages/cli/src/ui/commands/workflowsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,22 @@ describe('workflowsCommand', () => {
let context: CommandContext;
let listMock: ReturnType<typeof vi.fn>;
let getMock: ReturnType<typeof vi.fn>;
let pauseMock: ReturnType<typeof vi.fn>;
let resumeMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
listMock = vi.fn().mockReturnValue([] as WorkflowTask[]);
getMock = vi.fn().mockReturnValue(undefined);
pauseMock = vi.fn().mockReturnValue(true);
resumeMock = vi.fn().mockReturnValue(true);
context = createMockCommandContext({
services: {
config: {
getWorkflowRunRegistry: () => ({
list: listMock,
get: getMock,
pause: pauseMock,
resume: resumeMock,
}),
},
},
Expand All @@ -70,7 +76,7 @@ describe('workflowsCommand', () => {
});
});

it('lists active + recent buckets with running first', async () => {
it('lists active + recent buckets with active runs first', async () => {
listMock.mockReturnValue([
entry({
runId: 'wf_done',
Expand All @@ -89,7 +95,7 @@ describe('workflowsCommand', () => {
]);
const result = await workflowsCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Workflow runs (2 total · 1 running)');
expect(result.content).toContain('Workflow runs (2 total · 1 active)');
const activeIdx = result.content.indexOf('Active');
const recentIdx = result.content.indexOf('Recent');
// Active section comes before Recent in the output.
Expand All @@ -102,6 +108,33 @@ describe('workflowsCommand', () => {
expect(result.content).toContain('capitals');
});

it('treats pausing and paused workflows as active and explains cooperative controls', async () => {
listMock.mockReturnValue([
entry({ runId: 'wf_pausing', status: 'pausing', startTime: 2 }),
entry({ runId: 'wf_paused', status: 'paused', startTime: 1 }),
entry({
runId: 'wf_done',
status: 'completed',
startTime: 3,
endTime: 4,
}),
]);

const result = await workflowsCommand.action!(context, '');
if (!result || result.type !== 'message') throw new Error('no result');

expect(result.content).toContain('Workflow runs (3 total · 2 active)');
expect(result.content.indexOf('wf_pausing')).toBeLessThan(
result.content.indexOf('Recent'),
);
expect(result.content.indexOf('wf_paused')).toBeLessThan(
result.content.indexOf('Recent'),
);
expect(result.content).toContain('Background tasks');
expect(result.content).toContain('p');
expect(result.content).toContain('cooperative');
});

it('omits the interactive tip in non_interactive / acp modes', async () => {
const ctx = createMockCommandContext({
services: {
Expand All @@ -117,6 +150,99 @@ describe('workflowsCommand', () => {
expect(result.content).not.toMatch(/Tip:/);
});

it('pauses a live running workflow with p <runId>', async () => {
getMock.mockReturnValue(entry({ runId: 'wf_running', status: 'running' }));

const result = await workflowsCommand.action!(context, 'p wf_running');

expect(pauseMock).toHaveBeenCalledWith('wf_running');
expect(resumeMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
});
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Cooperative pause requested');
});

it('resumes a live paused workflow with p <runId>', async () => {
getMock.mockReturnValue(entry({ runId: 'wf_paused', status: 'paused' }));

const result = await workflowsCommand.action!(context, 'p wf_paused');

expect(resumeMock).toHaveBeenCalledWith('wf_paused');
expect(pauseMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
});
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Resume requested');
});

it.each([
['pausing', 'still pausing'],
['completed', 'cannot be paused or resumed'],
] as const)('rejects p for a %s workflow', async (status, message) => {
getMock.mockReturnValue(entry({ runId: 'wf_target', status }));

const result = await workflowsCommand.action!(context, 'p wf_target');

expect(pauseMock).not.toHaveBeenCalled();
expect(resumeMock).not.toHaveBeenCalled();
expect(result).toMatchObject({ type: 'message' });
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain(message);
});

it('rejects p for unknown or malformed targets without reading snapshots', async () => {
const unknown = await workflowsCommand.action!(context, 'p wf_missing');
const malformed = await workflowsCommand.action!(context, 'p');

expect(unknown).toMatchObject({
type: 'message',
messageType: 'error',
content: 'Unknown live workflow runId: wf_missing',
});
expect(malformed).toMatchObject({
type: 'message',
messageType: 'error',
});
expect(pauseMock).not.toHaveBeenCalled();
expect(resumeMock).not.toHaveBeenCalled();
});

it.each(['non_interactive', 'acp'] as const)(
'does not expose pause control in %s mode',
async (executionMode) => {
const ctx = createMockCommandContext({
services: {
config: {
getWorkflowRunRegistry: () => ({
list: listMock,
get: getMock,
pause: pauseMock,
resume: resumeMock,
}),
},
},
executionMode,
} as unknown as Parameters<typeof createMockCommandContext>[0]);
getMock.mockReturnValue(
entry({ runId: 'wf_running', status: 'running' }),
);

const result = await workflowsCommand.action!(ctx, 'p wf_running');

expect(result).toMatchObject({
type: 'message',
messageType: 'error',
});
expect(pauseMock).not.toHaveBeenCalled();
expect(resumeMock).not.toHaveBeenCalled();
},
);

it('detail view: known runId returns full per-field dump', async () => {
const detail = entry({
runId: 'wf_target',
Expand Down Expand Up @@ -334,7 +460,7 @@ describe('workflowsCommand', () => {
]);
const result = await workflowsCommand.action!(ctx, '');
if (!result || result.type !== 'message') throw new Error('no result');
expect(result.content).toContain('Workflow runs (1 total · 0 running)');
expect(result.content).toContain('Workflow runs (1 total · 0 active)');
expect(result.content).toContain('Recent');
expect(result.content).toContain('wf_persisted');
expect(result.content).toContain('oldrun');
Expand All @@ -355,7 +481,7 @@ describe('workflowsCommand', () => {
const result = await workflowsCommand.action!(ctx, '');
if (!result || result.type !== 'message') throw new Error('no result');
// Exactly one entry total; the live entry's meta wins, disk is dropped.
expect(result.content).toContain('Workflow runs (1 total · 0 running)');
expect(result.content).toContain('Workflow runs (1 total · 0 active)');
expect(result.content).toContain('live-name');
expect(result.content).not.toContain('disk-name');
expect(result.content.match(/wf_dup/g)?.length).toBe(1);
Expand Down
95 changes: 81 additions & 14 deletions packages/cli/src/ui/commands/workflowsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
*/

import type { WorkflowTask, WorkflowSnapshot } from '@qwen-code/qwen-code-core';
import { listWorkflowSnapshots } from '@qwen-code/qwen-code-core';
import {
isActiveWorkflowStatus,
listWorkflowSnapshots,
} from '@qwen-code/qwen-code-core';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
Expand Down Expand Up @@ -152,12 +155,10 @@ function detailLines(entry: WorkflowTask, now: number): string[] {
export const workflowsCommand: SlashCommand = {
name: 'workflows',
get description() {
return t(
'List active and completed workflow runs (text dump — interactive dialog opens via the footer pill)',
);
return t('List workflow runs or cooperatively pause/resume a live run');
},
get argumentHint() {
return t('[runId]');
return t('[runId | p <runId>]');
},
kind: CommandKind.BUILT_IN,
// Same triple-mode coverage as `/tasks`: the dialog is richer in
Expand All @@ -175,11 +176,77 @@ export const workflowsCommand: SlashCommand = {
}
const registry = config.getWorkflowRunRegistry();
const allEntries = registry.list();
const trimmedArgs = (args ?? '').trim();
const tokens = trimmedArgs.split(/\s+/);

if (tokens[0] === 'p') {
if (context.executionMode !== 'interactive') {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'Workflow pause controls are available only in the interactive TUI.',
};
}
if (tokens.length !== 2 || !tokens[1]) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: 'Usage: /workflows p <runId>',
};
}
const runId = tokens[1];
const target = registry.get(runId);
if (!target) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: `Unknown live workflow runId: ${runId}`,
};
}
if (target.status === 'pausing') {
return {
type: 'message' as const,
messageType: 'warning' as const,
content: `Workflow ${runId} is still pausing; wait until it reaches paused before resuming.`,
};
}
if (target.status === 'running') {
return registry.pause(runId)
? {
type: 'message' as const,
messageType: 'info' as const,
content: `Cooperative pause requested for workflow ${runId}.`,
}
: {
type: 'message' as const,
messageType: 'error' as const,
content: `Workflow ${runId} could not be paused because its state changed.`,
};
Comment on lines +250 to +261

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The error message "could not be paused because its state changed" is misleading when registry.pause() returns false because the workflow is not backgrounded. The command checks target.status === 'running' before calling pause(), but the registry also gates on entry.isBackgrounded — so a foreground workflow with status === 'running' passes the command's check but fails the registry's, and the user sees a state-change error that doesn't apply. — Failure scenario: user types /workflows p <runId> on a foreground workflow and receives "could not be paused because its state changed" when the actual reason is that the workflow isn't running in the background.

Suggested change
if (target.status === 'running') {
return registry.pause(runId)
? {
type: 'message' as const,
messageType: 'info' as const,
content: `Cooperative pause requested for workflow ${runId}.`,
}
: {
type: 'message' as const,
messageType: 'error' as const,
content: `Workflow ${runId} could not be paused because its state changed.`,
};
if (target.status === 'running') {
if (!target.isBackgrounded) {
return {
type: 'message' as const,
messageType: 'error' as const,
content: `Workflow ${runId} is not running in the background and cannot be paused.`,
};
}
return registry.pause(runId)
? {
type: 'message' as const,
messageType: 'info' as const,
content: `Cooperative pause requested for workflow ${runId}.`,
}
: {
type: 'message' as const,
messageType: 'error' as const,
content: `Workflow ${runId} could not be paused because its state changed.`,
};

— qwen3.7-max via Qwen Code /review

}
if (target.status === 'paused') {
return registry.resume(runId)
? {
type: 'message' as const,
messageType: 'info' as const,
content: `Resume requested for workflow ${runId}.`,
}
: {
type: 'message' as const,
messageType: 'error' as const,
content: `Workflow ${runId} could not be resumed because its state changed.`,
};
}
return {
type: 'message' as const,
messageType: 'error' as const,
content: `Workflow ${runId} is ${target.status} and cannot be paused or resumed.`,
};
}

// Targeted detail view: `/workflows wf_abc123` opens the detail
// dump for that run if it exists. Reject early on unknown runId so
// the user sees a clear error instead of an empty listing.
const trimmedArgs = (args ?? '').trim();
if (trimmedArgs.length > 0) {
let target = registry.get(trimmedArgs);
if (!target) {
Expand Down Expand Up @@ -223,34 +290,34 @@ export const workflowsCommand: SlashCommand = {
}

const now = Date.now();
// Order: running first (oldest startTime first inside the bucket so
// Order: active first (oldest startTime first inside the bucket so
// long-runners stay visible), then terminal by endTime DESC. Mirrors
// the dialog's two-bucket sort. Snapshots are always terminal, so they
// only ever join the second bucket.
const running = allEntries
.filter((e) => e.status === 'running')
const active = allEntries
.filter((e) => isActiveWorkflowStatus(e.status))
.sort((a, b) => a.startTime - b.startTime);
const terminal = [
...allEntries.filter((e) => e.status !== 'running'),
...allEntries.filter((e) => !isActiveWorkflowStatus(e.status)),
...snapshotTasks,
].sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0));

const lines: string[] = [];
if (context.executionMode === 'interactive') {
lines.push(
t(
'Tip: use `/workflows <runId>` for the per-run detail view (name, description, phase tree, recent logs).',
'Tip: use `/workflows p <runId>` or Background tasks + p to cooperatively pause/resume; use `/workflows <runId>` for details.',
),
'',
);
}
lines.push(
`Workflow runs (${running.length + terminal.length} total · ${running.length} running)`,
`Workflow runs (${active.length + terminal.length} total · ${active.length} active)`,
'',
);
if (running.length > 0) {
if (active.length > 0) {
lines.push('Active');
for (const entry of running) lines.push(rowLine(entry, now));
for (const entry of active) lines.push(rowLine(entry, now));
lines.push('');
}
if (terminal.length > 0) {
Expand Down
Loading
Loading