mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 05:40:14 +00:00
feat(agents): deleting an agent retires it instead of erasing it
Decision 3 was settled as Multica has it: retire the identity, keep the history. The type had `retiredAt` and nothing ever wrote it, while the delete route removed the roster entry and deleted the agent's transcripts. Removing the entry was the obvious implementation and the wrong one. Every post an agent wrote names it, and a thread is read long after the agent stops working: removing the row turns its side of a conversation into an author nobody can look up, and a mention of it into an apparent typo. removeWorkspaceAgent is now retireWorkspaceAgent. It stamps `retiredAt` and keeps the row, so `isAgentAddressable` refuses new work from then on. Retiring twice is idempotent rather than an error. Live work still refuses with 409: an agent cannot be retired out from under a run that is mid-turn, which is the answer deletion already gave. The transcript deletion is gone with the helper that did it. "Whether anyone is left" is now a question about who can still take work, so the host session stops when no addressable agent remains rather than when the roster empties. The name stays taken. A second agent under a retired one's name would make the old posts read as the new agent's, so create still refuses it and now says why, instead of reporting a clash with an agent that is not in the list. In the UI the row says "retired", loses its enable and retire buttons, and is never offered as an assignee; a thread assigned to a retired agent keeps naming it and marks it retired rather than dropping it. The confirm prompt says what retiring actually does. Not run on this machine: no build, typecheck or test. ESLint is clean across the changed surface.
This commit is contained in:
parent
6616312c67
commit
8d6e199cc4
5 changed files with 118 additions and 88 deletions
|
|
@ -24,8 +24,7 @@
|
|||
* the shell; there is no field they can set to claim otherwise.
|
||||
*/
|
||||
|
||||
import { open, readdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { open } from 'node:fs/promises';
|
||||
|
||||
import type { Application, Request, RequestHandler, Response } from 'express';
|
||||
import {
|
||||
|
|
@ -47,7 +46,8 @@ import {
|
|||
readAgentWorkspace,
|
||||
readThread,
|
||||
releaseAgentHostSession,
|
||||
removeWorkspaceAgent,
|
||||
retireWorkspaceAgent,
|
||||
isAgentAddressable,
|
||||
resolveThreadStatus,
|
||||
setWorkspaceAgentEnabled,
|
||||
updateWorkspaceAgents,
|
||||
|
|
@ -59,8 +59,6 @@ import {
|
|||
DEFAULT_THREAD_TOKEN_BUDGET,
|
||||
Storage,
|
||||
getAgentJsonlPath,
|
||||
getSubagentsRootDir,
|
||||
sanitizeFilenameComponent,
|
||||
agentBodyId,
|
||||
type WorkspaceAgent,
|
||||
type Thread,
|
||||
|
|
@ -113,36 +111,6 @@ function lastActivity(thread: Thread): number {
|
|||
return Math.max(lastPost, lastRun);
|
||||
}
|
||||
|
||||
async function deleteWorkspaceAgentTranscripts(
|
||||
workspaceCwd: string,
|
||||
runtimeBaseDir: string | undefined,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const root = getSubagentsRootDir(
|
||||
new Storage(
|
||||
workspaceCwd,
|
||||
runtimeBaseDir ?? Storage.getRuntimeBaseDir(),
|
||||
).getProjectDir(),
|
||||
);
|
||||
const sessions = await readdir(root, { withFileTypes: true }).catch(
|
||||
(error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
const stem = `agent-${sanitizeFilenameComponent(
|
||||
agentBodyId({ id: agentId }),
|
||||
)}`;
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.flatMap((entry) => [
|
||||
rm(path.join(root, entry.name, `${stem}.jsonl`), { force: true }),
|
||||
rm(path.join(root, entry.name, `${stem}.meta.json`), { force: true }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Why a run exists, in the words a reader asks the question in. */
|
||||
function triggerText(thread: Thread, run: ThreadRun): string {
|
||||
const first = thread.messages.find((message) =>
|
||||
|
|
@ -372,6 +340,13 @@ export function registerWorkspaceAgentRoutes(
|
|||
...(agent.description ? { description: agent.description } : {}),
|
||||
...(agent.color ? { color: agent.color } : {}),
|
||||
enabled: agent.enabled !== false,
|
||||
// A retired agent is listed, not hidden. Its posts are still on
|
||||
// the threads, and a reader who meets its name needs somewhere to
|
||||
// look it up. `enabled` stays a separate answer: a retired agent
|
||||
// is not merely paused, and the two are not interchangeable.
|
||||
...(agent.retiredAt !== undefined
|
||||
? { retiredAt: agent.retiredAt }
|
||||
: {}),
|
||||
...(active && activeRun
|
||||
? {
|
||||
workingOn: {
|
||||
|
|
@ -933,13 +908,17 @@ export function registerWorkspaceAgentRoutes(
|
|||
}
|
||||
let created: WorkspaceAgent | undefined;
|
||||
let duplicate = false;
|
||||
// A retired agent still holds its name. Saying so is the difference
|
||||
// between a person renaming and a person hunting for an agent that is
|
||||
// not in the list.
|
||||
let duplicateRetired = false;
|
||||
await updateWorkspaceAgents(root, (agents) => {
|
||||
if (
|
||||
agents.some(
|
||||
(agent) => agent.name.toLowerCase() === name.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
const clash = agents.find(
|
||||
(agent) => agent.name.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
if (clash) {
|
||||
duplicate = true;
|
||||
duplicateRetired = clash.retiredAt !== undefined;
|
||||
return agents;
|
||||
}
|
||||
created = {
|
||||
|
|
@ -960,7 +939,9 @@ export function registerWorkspaceAgentRoutes(
|
|||
});
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
error: `An agent named "${name}" already exists.`,
|
||||
error: duplicateRetired
|
||||
? `A retired agent is named "${name}". Retired names stay taken so its old posts still read as its own.`
|
||||
: `An agent named "${name}" already exists.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -983,7 +964,7 @@ export function registerWorkspaceAgentRoutes(
|
|||
if (!runtime) return;
|
||||
const agentId = String(req.params['id']);
|
||||
try {
|
||||
const result = await removeWorkspaceAgent(
|
||||
const result = await retireWorkspaceAgent(
|
||||
runtime.workspaceCwd,
|
||||
agentId,
|
||||
);
|
||||
|
|
@ -995,16 +976,15 @@ export function registerWorkspaceAgentRoutes(
|
|||
res.status(409).json({ error: 'agent_has_live_work' });
|
||||
return;
|
||||
}
|
||||
const remainingAgents = await readWorkspaceAgents(runtime.workspaceCwd);
|
||||
// Retiring keeps the roster entry, so "is anyone left" is a question
|
||||
// about who can still take work, not about how many rows exist.
|
||||
const remainingAgents = (
|
||||
await readWorkspaceAgents(runtime.workspaceCwd)
|
||||
).filter(isAgentAddressable);
|
||||
const dispatchError =
|
||||
remainingAgents.length > 0
|
||||
? await startBookedRuns(runtime)
|
||||
: undefined;
|
||||
await deleteWorkspaceAgentTranscripts(
|
||||
runtime.workspaceCwd,
|
||||
runtime.sessionRuntimeBaseDir,
|
||||
agentId,
|
||||
);
|
||||
if (remainingAgents.length === 0) {
|
||||
owners.get(runtime.workspaceCwd)?.owner.stop();
|
||||
owners.delete(runtime.workspaceCwd);
|
||||
|
|
@ -1021,7 +1001,11 @@ export function registerWorkspaceAgentRoutes(
|
|||
}
|
||||
res.json({
|
||||
id: agentId,
|
||||
// The identity is gone from the roster's point of view and its posts
|
||||
// are still readable. `deleted` stays for callers that read it, and
|
||||
// says what actually happened alongside it.
|
||||
deleted: true,
|
||||
retired: true,
|
||||
...(dispatchError ? { dispatchError } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ export {
|
|||
readAgentWorkspace,
|
||||
readThread,
|
||||
releaseAgentHostSession,
|
||||
removeWorkspaceAgent,
|
||||
retireWorkspaceAgent,
|
||||
isAgentAddressable,
|
||||
maxConcurrentRunsFor,
|
||||
setWorkspaceAgentEnabled,
|
||||
updateWorkspaceAgents,
|
||||
updateThread,
|
||||
|
|
|
|||
|
|
@ -1211,18 +1211,38 @@ export async function setWorkspaceAgentEnabled(
|
|||
});
|
||||
}
|
||||
|
||||
export async function removeWorkspaceAgent(
|
||||
/**
|
||||
* Retires an identity: it takes no new work and keeps everything it did.
|
||||
*
|
||||
* Deleting the roster entry was the obvious implementation and the wrong one.
|
||||
* Every post an agent wrote names it, and a thread is read long after the
|
||||
* agent stops working: removing the entry turns its side of a conversation
|
||||
* into an author nobody can look up, and a mention of it into a typo. So the
|
||||
* entry stays, `retiredAt` is stamped, and `isAgentAddressable` refuses new
|
||||
* work from then on.
|
||||
*
|
||||
* The consequences are deliberate. The name stays taken, because a second
|
||||
* agent under a retired one's name would make the old posts read as that new
|
||||
* agent's. Retiring twice is idempotent rather than an error. Live work still
|
||||
* refuses: an agent cannot be retired out from under a run that is mid-turn,
|
||||
* which is the same answer deletion gave.
|
||||
*/
|
||||
export async function retireWorkspaceAgent(
|
||||
projectRoot: string,
|
||||
agentId: string,
|
||||
): Promise<WorkspaceAgentRosterChange> {
|
||||
return withAgentStoreTransaction(projectRoot, async (transaction) => {
|
||||
const agents = await transaction.readAgents();
|
||||
if (!agents.some((candidate) => candidate.id === agentId)) {
|
||||
return 'not_found';
|
||||
}
|
||||
const existing = agents.find((candidate) => candidate.id === agentId);
|
||||
if (!existing) return 'not_found';
|
||||
if (existing.retiredAt !== undefined) return 'updated';
|
||||
if (await agentHasLiveWork(transaction, agentId)) return 'has_live_work';
|
||||
await transaction.writeAgents(
|
||||
agents.filter((candidate) => candidate.id !== agentId),
|
||||
agents.map((candidate) =>
|
||||
candidate.id === agentId
|
||||
? { ...candidate, retiredAt: Date.now() }
|
||||
: candidate,
|
||||
),
|
||||
);
|
||||
return 'updated';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,7 +68,12 @@ export interface TranscriptSliceView {
|
|||
|
||||
export interface ThreadViewProps {
|
||||
thread: ThreadDetailView;
|
||||
agents: readonly { name: string; enabled: boolean }[];
|
||||
agents: readonly {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
/** Retired identities stay in the list and are never offered new work. */
|
||||
retiredAt?: number;
|
||||
}[];
|
||||
/** Server-computed routing for the current draft. */
|
||||
preview?: readonly RoutingPreviewTarget[];
|
||||
draft: string;
|
||||
|
|
@ -303,14 +308,21 @@ export function ThreadView({
|
|||
aria-label="Thread assignee"
|
||||
>
|
||||
<option value="">No assignee</option>
|
||||
{thread.assigneeName && !currentAssignee?.enabled ? (
|
||||
{thread.assigneeName &&
|
||||
(!currentAssignee?.enabled || currentAssignee?.retiredAt) ? (
|
||||
// The thread keeps naming whoever it was assigned to, and says
|
||||
// in what way they are unavailable rather than dropping them.
|
||||
<option value={thread.assigneeName}>
|
||||
{thread.assigneeName}{' '}
|
||||
{currentAssignee ? '(disabled)' : '(removed)'}
|
||||
{currentAssignee?.retiredAt
|
||||
? '(retired)'
|
||||
: currentAssignee
|
||||
? '(disabled)'
|
||||
: '(removed)'}
|
||||
</option>
|
||||
) : null}
|
||||
{agents
|
||||
.filter((agent) => agent.enabled)
|
||||
.filter((agent) => agent.enabled && !agent.retiredAt)
|
||||
.map((agent) => (
|
||||
<option key={agent.name} value={agent.name}>
|
||||
{agent.name}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export interface WorkspaceAgentSummaryView {
|
|||
description?: string;
|
||||
color?: string;
|
||||
enabled: boolean;
|
||||
/** Set once the identity is retired: it keeps its posts and takes no work. */
|
||||
retiredAt?: number;
|
||||
workingOn?: {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -283,7 +285,7 @@ export function ThreadsPage({
|
|||
>
|
||||
<option value="">No assignee</option>
|
||||
{agents
|
||||
.filter((agent) => agent.enabled)
|
||||
.filter((agent) => agent.enabled && !agent.retiredAt)
|
||||
.map((agent) => (
|
||||
<option key={agent.id} value={agent.name}>
|
||||
{agent.name}
|
||||
|
|
@ -354,14 +356,16 @@ export function ThreadsPage({
|
|||
<div
|
||||
key={agent.id}
|
||||
className={
|
||||
agent.enabled
|
||||
agent.enabled && !agent.retiredAt
|
||||
? styles.agentRow
|
||||
: `${styles.agentRow} ${styles.agentRowDisabled}`
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
agent.enabled ? styles.agentDot : styles.agentDotDisabled
|
||||
agent.enabled && !agent.retiredAt
|
||||
? styles.agentDot
|
||||
: styles.agentDotDisabled
|
||||
}
|
||||
style={agent.color ? { color: agent.color } : undefined}
|
||||
aria-hidden="true"
|
||||
|
|
@ -371,37 +375,45 @@ export function ThreadsPage({
|
|||
{agent.description || 'general agent'}
|
||||
</span>
|
||||
<span className={styles.agentActivity}>
|
||||
{agent.workingOn
|
||||
? `${agent.workingOn.state} · ${agent.workingOn.title}`
|
||||
: 'idle'}
|
||||
{agent.retiredAt
|
||||
? 'retired'
|
||||
: agent.workingOn
|
||||
? `${agent.workingOn.state} · ${agent.workingOn.title}`
|
||||
: 'idle'}
|
||||
</span>
|
||||
<span className={styles.agentWaiting}>
|
||||
{agent.waiting ? `${agent.waiting} waiting` : '—'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.agentAction}
|
||||
disabled={pending}
|
||||
onClick={() => onSetAgentEnabled(agent.id, !agent.enabled)}
|
||||
>
|
||||
{agent.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.agentAction} ${styles.agentRemove}`}
|
||||
disabled={Boolean(agent.workingOn || agent.waiting)}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Remove agent "${agent.name}"? Existing thread posts keep its name.`,
|
||||
)
|
||||
) {
|
||||
onDeleteAgent(agent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
{agent.retiredAt ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.agentAction}
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
onSetAgentEnabled(agent.id, !agent.enabled)
|
||||
}
|
||||
>
|
||||
{agent.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.agentAction} ${styles.agentRemove}`}
|
||||
disabled={Boolean(agent.workingOn || agent.waiting)}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Retire agent "${agent.name}"? It stops taking work. Its posts stay on every thread, and its name stays taken so nothing else can post as it.`,
|
||||
)
|
||||
) {
|
||||
onDeleteAgent(agent.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retire
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue