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:
yiliang114 2026-09-28 14:01:14 +09:00
parent ef201269a4
commit 1d3173a53e
5 changed files with 74 additions and 9 deletions

View file

@ -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()));
}

View file

@ -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;

View file

@ -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

View file

@ -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([
{

View file

@ -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({