mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
feat(arena): add IDLE status for agent follow-up task support
- Introduce AgentStatus.IDLE for agents that finished work but can accept follow-up messages - Add isSettledStatus() helper to check if agent is settled (IDLE or terminal) - Update ArenaManager to transition to IDLE after agents finish initial task - Keep agent tabs visible when session is IDLE so users can continue interacting - Fix listener cleanup to not detach on IDLE (agents remain alive) - Update tests to expect 'idle' status after successful completion This enables the arena collaboration feature where agents can receive additional tasks after completing their initial work. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
4a681f435d
commit
eaef9efe90
13 changed files with 125 additions and 51 deletions
|
|
@ -334,7 +334,7 @@ function executeArenaCommand(
|
|||
})
|
||||
.then(
|
||||
() => {
|
||||
debugLogger.debug('Arena session completed');
|
||||
debugLogger.debug('Arena agents settled');
|
||||
},
|
||||
(error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -344,13 +344,18 @@ function executeArenaCommand(
|
|||
// Clear the stored manager so subsequent /arena start calls
|
||||
// are not blocked by the stale reference after a startup failure.
|
||||
config.setArenaManager(null);
|
||||
|
||||
// Detach listeners on failure — session is done for good.
|
||||
for (const detach of detachListeners) {
|
||||
detach();
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
for (const detach of detachListeners) {
|
||||
detach();
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
// NOTE: listeners are NOT detached when start() resolves because agents
|
||||
// may still be alive (IDLE) and accept follow-up tasks. The listeners
|
||||
// reference this manager's emitter, so they are garbage collected when
|
||||
// the manager is cleaned up and replaced.
|
||||
|
||||
// Store so that stop can wait for start() to fully unwind before cleanup
|
||||
manager.setLifecyclePromise(lifecycle);
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ export const Composer = () => {
|
|||
|
||||
{/* Exclusive area: only one component visible at a time */}
|
||||
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
|
||||
{!showSuggestions &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
|
||||
{uiState.isInputActive &&
|
||||
!showSuggestions &&
|
||||
(showShortcuts ? (
|
||||
<KeyboardShortcuts />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -148,11 +148,13 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
|
|||
const colChanges = 10;
|
||||
|
||||
const titleLabel =
|
||||
sessionStatus === 'completed'
|
||||
? 'Arena Complete'
|
||||
: sessionStatus === 'cancelled'
|
||||
? 'Arena Cancelled'
|
||||
: 'Arena Failed';
|
||||
sessionStatus === 'idle'
|
||||
? 'Agents Status · Idle'
|
||||
: sessionStatus === 'completed'
|
||||
? 'Arena Complete'
|
||||
: sessionStatus === 'cancelled'
|
||||
? 'Arena Cancelled'
|
||||
: 'Arena Failed';
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -266,6 +268,15 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
|
|||
<Box height={1} />
|
||||
|
||||
{/* Hint */}
|
||||
{sessionStatus === 'idle' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
Switch to an agent tab to continue, or{' '}
|
||||
<Text color={theme.text.accent}>/arena select</Text> to pick a
|
||||
winner.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{sessionStatus === 'completed' && (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
type ArenaAgentState,
|
||||
type InProcessBackend,
|
||||
type AgentStatsSummary,
|
||||
isTerminalStatus,
|
||||
isSettledStatus,
|
||||
ArenaSessionStatus,
|
||||
DISPLAY_MODE,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -46,7 +46,7 @@ function pad(
|
|||
}
|
||||
|
||||
function getElapsedMs(agent: ArenaAgentState): number {
|
||||
if (isTerminalStatus(agent.status)) {
|
||||
if (isSettledStatus(agent.status)) {
|
||||
return agent.stats.durationMs;
|
||||
}
|
||||
return Date.now() - agent.startedAt;
|
||||
|
|
@ -61,6 +61,8 @@ function getSessionStatusLabel(status: ArenaSessionStatus): {
|
|||
return { text: 'Running', color: theme.status.success };
|
||||
case ArenaSessionStatus.INITIALIZING:
|
||||
return { text: 'Initializing', color: theme.status.warning };
|
||||
case ArenaSessionStatus.IDLE:
|
||||
return { text: 'Idle', color: theme.status.success };
|
||||
case ArenaSessionStatus.COMPLETED:
|
||||
return { text: 'Completed', color: theme.status.success };
|
||||
case ArenaSessionStatus.CANCELLED:
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
ArenaEventType,
|
||||
ArenaSessionStatus,
|
||||
DISPLAY_MODE,
|
||||
type ArenaManager,
|
||||
type ArenaAgentStartEvent,
|
||||
type ArenaSessionCompleteEvent,
|
||||
type Config,
|
||||
type InProcessBackend,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -123,9 +125,9 @@ export function useArenaInProcess(config: Config): void {
|
|||
tryRegister(MAX_AGENT_RETRIES);
|
||||
};
|
||||
|
||||
// On session end, unregister agents, remove listeners from this
|
||||
// manager, and resume polling for a genuinely new manager instance.
|
||||
const onSessionEnd = () => {
|
||||
// Tear down agent tabs, remove listeners, and resume polling for
|
||||
// a genuinely new manager instance.
|
||||
const teardown = () => {
|
||||
actionsRef.current.unregisterAll();
|
||||
for (const timeout of retryTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
|
|
@ -133,8 +135,8 @@ export function useArenaInProcess(config: Config): void {
|
|||
retryTimeouts.clear();
|
||||
// Remove listeners eagerly so they don't fire again
|
||||
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
|
||||
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd);
|
||||
emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd);
|
||||
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
|
||||
emitter.off(ArenaEventType.SESSION_ERROR, teardown);
|
||||
detachListeners = null;
|
||||
// Keep attachedManager reference — prevents reattach to this
|
||||
// same (completed) manager on the next poll tick.
|
||||
|
|
@ -144,14 +146,24 @@ export function useArenaInProcess(config: Config): void {
|
|||
}
|
||||
};
|
||||
|
||||
// When agents settle to IDLE the session is still alive — keep
|
||||
// the tab bar so users can continue interacting with agents.
|
||||
// Only tear down on truly terminal session statuses.
|
||||
const onSessionComplete = (event: ArenaSessionCompleteEvent) => {
|
||||
if (event.result.status === ArenaSessionStatus.IDLE) {
|
||||
return;
|
||||
}
|
||||
teardown();
|
||||
};
|
||||
|
||||
emitter.on(ArenaEventType.AGENT_START, onAgentStart);
|
||||
emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionEnd);
|
||||
emitter.on(ArenaEventType.SESSION_ERROR, onSessionEnd);
|
||||
emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
|
||||
emitter.on(ArenaEventType.SESSION_ERROR, teardown);
|
||||
|
||||
detachListeners = () => {
|
||||
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
|
||||
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd);
|
||||
emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd);
|
||||
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
|
||||
emitter.off(ArenaEventType.SESSION_ERROR, teardown);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ export const DefaultAppLayout: React.FC = () => {
|
|||
<ExitWarning />
|
||||
</Box>
|
||||
|
||||
{/* Tab bar: visible whenever in-process agents exist */}
|
||||
{hasAgents && <AgentTabBar />}
|
||||
{/* Tab bar: visible whenever in-process agents exist and input is active */}
|
||||
{hasAgents && !uiState.dialogsVisible && <AgentTabBar />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface StatusLabel {
|
|||
|
||||
export function getArenaStatusLabel(status: AgentStatus): StatusLabel {
|
||||
switch (status) {
|
||||
case AgentStatus.IDLE:
|
||||
return { icon: '✓', text: 'Idle', color: theme.status.success };
|
||||
case AgentStatus.COMPLETED:
|
||||
return { icon: '✓', text: 'Done', color: theme.status.success };
|
||||
case AgentStatus.CANCELLED:
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ import {
|
|||
ARENA_MAX_AGENTS,
|
||||
safeAgentId,
|
||||
} from './types.js';
|
||||
import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
|
||||
import {
|
||||
AgentStatus,
|
||||
isTerminalStatus,
|
||||
isSettledStatus,
|
||||
} from '../runtime/agent-types.js';
|
||||
import {
|
||||
logArenaSessionStarted,
|
||||
logArenaAgentCompleted,
|
||||
|
|
@ -374,9 +378,10 @@ export class ArenaManager {
|
|||
this.sessionStatus = ArenaSessionStatus.RUNNING;
|
||||
await this.runAgents();
|
||||
|
||||
// Only mark as completed if not already cancelled/timed out
|
||||
// Mark session as idle (agents finished but still alive) unless
|
||||
// already cancelled/timed out.
|
||||
if (this.sessionStatus === ArenaSessionStatus.RUNNING) {
|
||||
this.sessionStatus = ArenaSessionStatus.COMPLETED;
|
||||
this.sessionStatus = ArenaSessionStatus.IDLE;
|
||||
}
|
||||
|
||||
// Collect results (uses this.sessionStatus for result status)
|
||||
|
|
@ -1114,6 +1119,25 @@ export class ArenaManager {
|
|||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Emit progress messages for follow-up transitions (only after
|
||||
// the initial task — the session is IDLE once all agents first settle).
|
||||
if (this.sessionStatus === ArenaSessionStatus.IDLE) {
|
||||
const displayName = agent.model.displayName || agent.model.modelId;
|
||||
if (
|
||||
previousStatus === AgentStatus.IDLE &&
|
||||
newStatus === AgentStatus.RUNNING
|
||||
) {
|
||||
this.emitProgress(
|
||||
`Agent ${displayName} is working on a follow-up task…`,
|
||||
);
|
||||
} else if (
|
||||
previousStatus === AgentStatus.RUNNING &&
|
||||
newStatus === AgentStatus.IDLE
|
||||
) {
|
||||
this.emitProgress(`Agent ${displayName} finished follow-up task.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit AGENT_COMPLETE when agent reaches a terminal status
|
||||
if (isTerminalStatus(newStatus)) {
|
||||
const result = this.buildAgentResult(agent);
|
||||
|
|
@ -1194,7 +1218,7 @@ export class ArenaManager {
|
|||
return new Promise<boolean>((resolve) => {
|
||||
const checkSettled = () => {
|
||||
for (const agent of this.agents.values()) {
|
||||
if (!isTerminalStatus(agent.status)) {
|
||||
if (!isSettledStatus(agent.status)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1283,7 +1307,7 @@ export class ArenaManager {
|
|||
agent.error =
|
||||
interactive.getLastRoundError() || interactive.getError();
|
||||
}
|
||||
if (isTerminalStatus(resolved)) {
|
||||
if (isSettledStatus(resolved)) {
|
||||
agent.stats.durationMs = Date.now() - agent.startedAt;
|
||||
}
|
||||
this.updateAgentStatus(agent.agentId, resolved);
|
||||
|
|
@ -1337,9 +1361,9 @@ export class ArenaManager {
|
|||
const consolidatedAgents: Record<string, ArenaStatusFile> = {};
|
||||
|
||||
for (const agent of this.agents.values()) {
|
||||
// Only poll agents that are still alive (RUNNING)
|
||||
// Only poll agents that are actively working
|
||||
if (
|
||||
isTerminalStatus(agent.status) ||
|
||||
isSettledStatus(agent.status) ||
|
||||
agent.status === AgentStatus.INITIALIZING
|
||||
) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ export enum ArenaSessionStatus {
|
|||
INITIALIZING = 'initializing',
|
||||
/** Session is running */
|
||||
RUNNING = 'running',
|
||||
/** Session completed (all agents finished) */
|
||||
/** All agents finished their current task and are idle (can accept follow-ups) */
|
||||
IDLE = 'idle',
|
||||
/** Session completed for good (winner selected or explicit end) */
|
||||
COMPLETED = 'completed',
|
||||
/** Session was cancelled */
|
||||
CANCELLED = 'cancelled',
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
createContentGenerator,
|
||||
} from '../../core/contentGenerator.js';
|
||||
import { AUTH_ENV_MAPPINGS } from '../../models/constants.js';
|
||||
import { AgentStatus } from '../runtime/agent-types.js';
|
||||
import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
|
||||
import { AgentCore } from '../runtime/agent-core.js';
|
||||
import { AgentEventEmitter } from '../runtime/agent-events.js';
|
||||
import { ContextState } from '../runtime/agent-headless.js';
|
||||
|
|
@ -130,9 +130,14 @@ export class InProcessBackend implements Backend {
|
|||
const context = new ContextState();
|
||||
await interactive.start(context);
|
||||
|
||||
// Watch for completion and fire exit callback
|
||||
// Watch for completion and fire exit callback — but only for
|
||||
// truly terminal statuses. IDLE means the agent is still alive
|
||||
// and can accept follow-up messages.
|
||||
void interactive.waitForCompletion().then(() => {
|
||||
const status = interactive.getStatus();
|
||||
if (!isTerminalStatus(status)) {
|
||||
return;
|
||||
}
|
||||
const exitCode =
|
||||
status === AgentStatus.COMPLETED
|
||||
? 0
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ describe('AgentInteractive', () => {
|
|||
|
||||
await agent.start(context);
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
expect(core.runReasoningLoop).toHaveBeenCalledOnce();
|
||||
|
|
@ -123,6 +123,7 @@ describe('AgentInteractive', () => {
|
|||
expect(agent.getMessages()[0]?.content).toBe('Do something');
|
||||
|
||||
await agent.shutdown();
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
});
|
||||
|
||||
it('should process enqueued messages', async () => {
|
||||
|
|
@ -134,7 +135,7 @@ describe('AgentInteractive', () => {
|
|||
|
||||
agent.enqueueMessage('Hello');
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
expect(core.runReasoningLoop).toHaveBeenCalledOnce();
|
||||
|
|
@ -197,7 +198,7 @@ describe('AgentInteractive', () => {
|
|||
// Second message works fine
|
||||
agent.enqueueMessage('recover');
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
|
|
@ -313,7 +314,7 @@ describe('AgentInteractive', () => {
|
|||
|
||||
await agent.start(context);
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
const assistantMsgs = agent
|
||||
|
|
@ -352,12 +353,12 @@ describe('AgentInteractive', () => {
|
|||
|
||||
await agent.start(context);
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
agent.enqueueMessage('second message');
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
expect(runCount).toBe(2);
|
||||
});
|
||||
|
||||
|
|
@ -399,7 +400,7 @@ describe('AgentInteractive', () => {
|
|||
|
||||
await agent.start(context);
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
const messages = agent.getMessages();
|
||||
|
|
@ -458,7 +459,7 @@ describe('AgentInteractive', () => {
|
|||
|
||||
await agent.start(context);
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
const messages = agent.getMessages();
|
||||
|
|
@ -517,7 +518,7 @@ describe('AgentInteractive', () => {
|
|||
|
||||
await agent.start(context);
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.getStatus()).toBe('completed');
|
||||
expect(agent.getStatus()).toBe('idle');
|
||||
});
|
||||
|
||||
const messages = agent.getMessages();
|
||||
|
|
|
|||
|
|
@ -323,12 +323,16 @@ export class AgentInteractive {
|
|||
|
||||
// ─── Private Helpers ───────────────────────────────────────
|
||||
|
||||
/** Emit terminal status for the just-completed round. */
|
||||
/**
|
||||
* Settle status after the run loop empties.
|
||||
* On success → IDLE (agent stays alive for follow-up messages).
|
||||
* On error → FAILED (terminal).
|
||||
*/
|
||||
private settleRoundStatus(): void {
|
||||
if (this.lastRoundError) {
|
||||
this.setStatus(AgentStatus.FAILED);
|
||||
} else {
|
||||
this.setStatus(AgentStatus.COMPLETED);
|
||||
this.setStatus(AgentStatus.IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,28 +99,34 @@ export enum AgentTerminateMode {
|
|||
* Canonical lifecycle status for any agent (headless, interactive, arena).
|
||||
*
|
||||
* State machine:
|
||||
* INITIALIZING → RUNNING ⇄ COMPLETED / FAILED / CANCELLED
|
||||
* INITIALIZING → RUNNING → IDLE ⇄ RUNNING → … → COMPLETED / FAILED / CANCELLED
|
||||
*
|
||||
* - INITIALIZING: Setting up (creating chat, loading tools).
|
||||
* - RUNNING: Actively processing (model thinking / tool execution).
|
||||
* - COMPLETED: Finished successfully (may re-enter RUNNING on new input).
|
||||
* - IDLE: Finished current work, waiting — can accept new messages.
|
||||
* - COMPLETED: Finished for good (explicit shutdown). No further interaction.
|
||||
* - FAILED: Finished with error (API failure, process crash, etc.).
|
||||
* - CANCELLED: Cancelled by user or system.
|
||||
*/
|
||||
export enum AgentStatus {
|
||||
INITIALIZING = 'initializing',
|
||||
RUNNING = 'running',
|
||||
IDLE = 'idle',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
/** True for COMPLETED, FAILED, CANCELLED — agent is done working. */
|
||||
/** True for COMPLETED, FAILED, CANCELLED — agent is done for good. */
|
||||
export const isTerminalStatus = (s: AgentStatus): boolean =>
|
||||
s === AgentStatus.COMPLETED ||
|
||||
s === AgentStatus.FAILED ||
|
||||
s === AgentStatus.CANCELLED;
|
||||
|
||||
/** True for terminal statuses OR IDLE — agent has settled (not actively working). */
|
||||
export const isSettledStatus = (s: AgentStatus): boolean =>
|
||||
s === AgentStatus.IDLE || isTerminalStatus(s);
|
||||
|
||||
/**
|
||||
* Lightweight configuration for an AgentInteractive instance.
|
||||
* Carries only interactive-specific parameters; the heavy runtime
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue