mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
fix(serve): keep opted-out workspaces untouched and agent sessions owned
- Recovery skips an opted-out workspace that has no collaboration store instead of tearing it down, which read (and so created) the store every five seconds once any other workspace had opted in. - A run whose task session is resident without its agent source (a person restored the transcript) closes it before reloading it as the agent's; attaching would run the turn without the persona, tool ceiling, or thread tools. - `agent_collaboration_v1` is advertised only while the routes are mounted, so a workspace opted in after boot no longer gets a capability whose routes 404 until restart. - A settings file that fails to parse keeps the last collaboration answer for that workspace rather than reading as "off" and stranding its live runs.
This commit is contained in:
parent
ef201269a4
commit
1d3173a53e
5 changed files with 74 additions and 9 deletions
|
|
@ -9,8 +9,9 @@ import * as http from 'node:http';
|
|||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import express, { type RequestHandler } from 'express';
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import { getAgentsDir } from '@qwen-code/qwen-code-core/agents/workspace-agents/store.js';
|
||||
import { publishAgentEvent } from '../workspace-agents/agent-events.js';
|
||||
import {
|
||||
createWorkspaceGenerationGuard,
|
||||
|
|
@ -70,6 +71,12 @@ it('answers 404 for a workspace whose settings have not opted in', async () => {
|
|||
);
|
||||
expect(enabled.status).toBe(200);
|
||||
await enabled.body?.cancel();
|
||||
|
||||
// Recovery visits `on` (creating its store) before `off`; once it has,
|
||||
// the opted-out workspace must still have nothing written into it.
|
||||
await vi.waitFor(() => fs.stat(getAgentsDir(on.workspaceCwd)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await expect(fs.stat(getAgentsDir(off.workspaceCwd))).rejects.toThrow();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
* the shell; there is no field they can set to claim otherwise.
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises';
|
||||
import type { Application, Request, RequestHandler, Response } from 'express';
|
||||
import type {
|
||||
ThreadPriority,
|
||||
|
|
@ -40,6 +41,7 @@ import {
|
|||
createThread,
|
||||
generateAgentId,
|
||||
generateEventId,
|
||||
getAgentsDir,
|
||||
isValidAgentName,
|
||||
listThreads,
|
||||
readWorkspaceAgents,
|
||||
|
|
@ -425,8 +427,18 @@ export function registerWorkspaceAgentRoutes(
|
|||
// The workspace never opted in or flipped the flag off. Recovery
|
||||
// cannot tell "turned off" from "crashed", so strand any live
|
||||
// runs an earlier opt-in left behind — a person decides their
|
||||
// fate — then leave nothing running. strandLocalReads reads
|
||||
// nothing when the store is absent.
|
||||
// fate — then leave nothing running. A workspace that never had a
|
||||
// store is skipped outright: teardown reads the store, and reading
|
||||
// it creates it.
|
||||
if (
|
||||
!owners.has(runtime.workspaceCwd) &&
|
||||
!(await stat(getAgentsDir(runtime.workspaceCwd)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await strandLocalRuns(runtime.workspaceCwd);
|
||||
await teardownWorkspaceOwner(runtime);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -1184,7 +1184,10 @@ export function createServeApp(
|
|||
sessionShellCommandEnabled,
|
||||
multiWorkspaceSessionsEnabled: () =>
|
||||
workspaceRegistry.listEntries().length > 1,
|
||||
agentCollaborationEnabled: () => anyAgentCollaborationEnabled(),
|
||||
// Present only while the routes are: a workspace opted in after boot
|
||||
// does not mount them until the daemon restarts.
|
||||
agentCollaborationEnabled: () =>
|
||||
agentCollaborationRoutesMounted && anyAgentCollaborationEnabled(),
|
||||
dynamicWorkspaceRegistrationAvailable:
|
||||
deps.createWorkspaceRuntime !== undefined,
|
||||
persistentWorkspaceRegistrationAvailable:
|
||||
|
|
@ -1581,20 +1584,26 @@ export function createServeApp(
|
|||
// operator's process-wide override. The predicate is consulted at request
|
||||
// time, so a workspace registered or reconfigured after boot is seen
|
||||
// without a daemon restart.
|
||||
// A settings file caught mid-edit (half-written JSON) keeps the last answer
|
||||
// read for that workspace: reading it as "off" would strand every live run
|
||||
// there within one recovery tick.
|
||||
const lastAgentCollaborationSetting = new Map<string, boolean>();
|
||||
const isAgentCollaborationEnabledFor = (workspaceCwd: string): boolean => {
|
||||
if (process.env['QWEN_CODE_ENABLE_AGENT_COLLABORATION'] === '1')
|
||||
return true;
|
||||
try {
|
||||
return (
|
||||
const enabled =
|
||||
loadSettingsCached(workspaceCwd).merged.experimental
|
||||
?.agentCollaboration === true
|
||||
);
|
||||
?.agentCollaboration === true;
|
||||
lastAgentCollaborationSetting.set(workspaceCwd, enabled);
|
||||
return enabled;
|
||||
} catch {
|
||||
return false;
|
||||
return lastAgentCollaborationSetting.get(workspaceCwd) ?? false;
|
||||
}
|
||||
};
|
||||
// Whether the routes and the recovery sweep exist at all. Evaluated at
|
||||
// call time over the registry rather than snapshotted at boot.
|
||||
let agentCollaborationRoutesMounted = false;
|
||||
const anyAgentCollaborationEnabled = () =>
|
||||
workspaceRegistry
|
||||
.listAll()
|
||||
|
|
@ -3487,6 +3496,7 @@ export function createServeApp(
|
|||
mutate,
|
||||
isAgentCollaborationEnabledFor,
|
||||
});
|
||||
agentCollaborationRoutesMounted = true;
|
||||
} else {
|
||||
// Close out runs the switch left mid-flight. Recovery cannot tell "the
|
||||
// daemon crashed" from "the operator turned this off" — both look like a
|
||||
|
|
|
|||
|
|
@ -131,6 +131,24 @@ describe('session dispatch port', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('closes a transcript a person restored before running the agent in it', async () => {
|
||||
// Restoring strips the agent source, so the id can sit resident as an
|
||||
// ordinary session; attaching to it would run without the persona.
|
||||
const sessionId = agentThreadSessionId(AGENT.id, TURN.threadId);
|
||||
const { bridge } = makeBridge([{ sessionId }]);
|
||||
const closeSession = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(bridge, { closeSession });
|
||||
const port = createSessionDispatchPort({ bridge, workspaceCwd: WS });
|
||||
|
||||
const result = await port.start({ agent: AGENT, prompt: 'work', ...TURN });
|
||||
|
||||
expect(result.status).toBe('started');
|
||||
expect(closeSession).toHaveBeenCalledWith(sessionId);
|
||||
expect(bridge.spawnOrAttach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId, sourceType: 'agent' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves mid-run replies for durable rebooking instead of another prompt', async () => {
|
||||
const { bridge, sendPrompt } = makeBridge([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -71,7 +71,10 @@ export type AgentSessionBridge = Pick<
|
|||
Partial<
|
||||
Pick<
|
||||
AcpSessionBridge,
|
||||
'updateSessionMetadata' | 'getSessionTurnStatus' | 'subscribeEvents'
|
||||
| 'updateSessionMetadata'
|
||||
| 'getSessionTurnStatus'
|
||||
| 'subscribeEvents'
|
||||
| 'closeSession'
|
||||
>
|
||||
>;
|
||||
|
||||
|
|
@ -384,6 +387,21 @@ export function createSessionDispatchPort(
|
|||
sourceType: AGENT_SESSION_SOURCE_TYPE,
|
||||
sourceId: agent.id,
|
||||
};
|
||||
// A person opening this transcript restores it without its agent
|
||||
// source, so it can sit resident as an ordinary session. Resuming
|
||||
// would attach to that body — no persona, no tool ceiling, no
|
||||
// thread tools — so it is closed and reloaded as the agent's.
|
||||
const resident = bridge
|
||||
.listWorkspaceSessions(workspaceCwd)
|
||||
.some((candidate) => candidate.sessionId === request.sessionId);
|
||||
if (resident) {
|
||||
if (!bridge.closeSession) {
|
||||
throw new Error(
|
||||
`Session ${request.sessionId} is open outside its agent.`,
|
||||
);
|
||||
}
|
||||
await bridge.closeSession(request.sessionId);
|
||||
}
|
||||
session = (await sessions.sessionExists(request.sessionId))
|
||||
? await bridge.resumeSession(request)
|
||||
: await bridge.spawnOrAttach({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue