Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
fix(managed-agent): address D6a review on Hosted approvals
- Recognise allow under the policy revision the Action's options
  recorded, not the current constant.
- Answer an Action from its record whenever one landed: before the
  write, after the decision is published and when a write conflicts,
  so a same decision that won a race answers like a replay and a
  different one gets action_already_resolved.
- Ask the Session's writability inside the authority's serial section
  through a new optional resolveAction guard, so a decision is not
  written after the Session blocks while the write waits its turn.
- Treat a failed expiry write like a failed decision write: 409
  hosted_turn_recovery_required and a woken Turn when the Session can
  no longer write, 503 only when a retry can succeed.
- Tests for each review suggestion and race, and a 10 s wait limit for
  the Session-level approval tests.
- Docs: the server README lists the Actions note, and the Hosted
  Workspace tool-turn note carries an approval update banner.

Part of #12867
  • Loading branch information
wenshao committed Sep 30, 2026
commit 706d402aeb0a53c94b1c8b5db50eebe35971da8e
2 changes: 2 additions & 0 deletions docs/design/2026-09-27-hosted-workspace-tool-turn.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

Status: implemented behind the private gates, based on main `daaac2223`. Part of proposal #12380, following the Hosted no-tool path and W0c-3. This is a private integration slice, not public capability enablement.

> Approval update (2026-09-30): the Hosted Workspace tool turn can now ask for approval before calls that the Session's approval mode does not pre-approve, so the "does not implement interactive approvals" clause below describes the earlier slice. See [Actions](2026-09-30-managed-agent-actions.md).

## Problem and current state

The Hosted Harness has a durable no-tool text turn. W0c-3 independently runs tools through a persisted Workspace binding and holds a SQL storage owner. Nothing connects the model's function calls to that path. Before this change, the generic TypeScript Broker provider depended on control, prepare and start operations absent from the production Broker. This slice supplies prepare/start but deliberately does not implement that provider's generic control contract.
Expand Down
2 changes: 2 additions & 0 deletions docs/design/2026-09-27-hosted-workspace-tool-turn.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

状态:已在私有门禁后实现,基于 main `daaac2223`。属于 proposal #12380,承接 Hosted 无工具路径和 W0c-3。这是私有集成切片,不是公开能力启用。

> 审批更新(2026-09-30):Hosted Workspace 工具回合现在可以在 Session 的审批模式不预批准的调用之前请求审批,因此下文“不实现交互式审批”一句描述的是之前的切片。详见 [Actions](2026-09-30-managed-agent-actions.zh-CN.md)。

## 问题与现状

Hosted Harness 已有持久化的无工具文本回合。W0c-3 独立支持通过持久化 Workspace 绑定执行工具,并持有 SQL 存储所有权。目前没有代码把模型的函数调用接到这条路径。变更前,通用 TypeScript Broker provider 依赖生产 Broker 尚未实现的 control、prepare 和 start 操作。本片补齐 prepare/start,但明确不实现该 provider 的通用 control 契约。
Expand Down
60 changes: 52 additions & 8 deletions packages/cli/src/serve/hosted-harness-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { HostedShellPublisher } from './hosted-shell-publisher.js';
import type { ShellPublisherDescriptor } from './managed-shell-publisher.js';
import type { HostedWorkspaceToolTurn } from './hosted-workspace-tool-turn.js';
import {
HOSTED_APPROVAL_TIMEOUT_MS,
HOSTED_TOOL_APPROVAL_POLICY,
HostedApprovalWaiters,
} from './hosted-tool-approval.js';
Expand Down Expand Up @@ -1117,6 +1118,11 @@ describe('Hosted Harness no-tool session', () => {
});

describe('Hosted Harness tool approvals', () => {
// Turns commit and sync several records, which can take over a second
// on a busy host.
const waitFor = <T>(check: () => T | Promise<T>) =>
vi.waitFor(check, { timeout: 10_000 });

beforeEach(async () => {
state.root = await mkdtemp(path.join(tmpdir(), 'hosted-harness-test-'));
state.model.mockReset();
Expand Down Expand Up @@ -1334,11 +1340,11 @@ describe('Hosted Harness tool approvals', () => {
})
.expect(202);
const count = requestIds.length + 1;
await vi.waitFor(() => expect(requestIds).toHaveLength(count));
await waitFor(() => expect(requestIds).toHaveLength(count));
return requestIds.at(-1)!;
};
const finished = async (clientId: string) =>
vi.waitFor(async () => {
waitFor(async () => {
const status = await headers(
supertest(server).get(`/session/${SESSION_ID}/status`),
).set('X-Qwen-Client-Id', clientId);
Expand Down Expand Up @@ -1470,7 +1476,7 @@ describe('Hosted Harness tool approvals', () => {
})
.expect(202);
const count = requestIds.length + 1;
await vi.waitFor(() => expect(requestIds).toHaveLength(count));
await waitFor(() => expect(requestIds).toHaveLength(count));
};
await submit(PROMPT_ID);
const answer = (optionId: string) =>
Expand Down Expand Up @@ -1506,7 +1512,7 @@ describe('Hosted Harness tool approvals', () => {
const failed = await answer('allow');
expect(failed.status).toBe(409);
expect(failed.body.code).toBe('hosted_turn_recovery_required');
await vi.waitFor(async () =>
await waitFor(async () =>
expect(await status()).toMatchObject({
hasActivePrompt: false,
recoveryBlocked: true,
Expand All @@ -1524,7 +1530,7 @@ describe('Hosted Harness tool approvals', () => {
await headers(supertest(server).post(`/session/${SESSION_ID}/cancel`))
.set('X-Qwen-Client-Id', clientId)
.expect(204);
await vi.waitFor(async () =>
await waitFor(async () =>
expect(await status()).toMatchObject({
hasActivePrompt: false,
recoveryBlocked: false,
Expand Down Expand Up @@ -1561,7 +1567,7 @@ describe('Hosted Harness tool approvals', () => {
await headers(supertest(server).post(`/session/${SESSION_ID}/cancel`))
.set('X-Qwen-Client-Id', clientId)
.expect(204);
await vi.waitFor(async () =>
await waitFor(async () =>
expect(await status()).toMatchObject({
hasActivePrompt: false,
recoveryBlocked: true,
Expand All @@ -1576,12 +1582,21 @@ describe('Hosted Harness tool approvals', () => {
it('asks again in the Turn after one whose calls were all refused', async () => {
const { server, clientId, answer, status, submit } = await waitingSession();
const finished = () =>
vi.waitFor(async () =>
waitFor(async () =>
expect(await status()).toMatchObject({
hasActivePrompt: false,
recoveryBlocked: false,
}),
);
expect(await definitions()).toEqual([
{
engine: 'managed',
sessionId: SESSION_ID,
toolProfile: files,
approvalMode: 'default',
approvalTimeoutMs: HOSTED_APPROVAL_TIMEOUT_MS,
},
]);
expect((await answer('deny')).status).toBe(200);
await finished();
const second = randomUUID();
Expand All @@ -1605,7 +1620,7 @@ describe('Hosted Harness tool approvals', () => {
.mockImplementation(() => {});
const { server, clientId, answer, status } = await waitingSession();
expect((await answer('allow')).status).toBe(200);
await vi.waitFor(async () =>
await waitFor(async () =>
expect(await status()).toMatchObject({ hasActivePrompt: false }),
);
vi.spyOn(
Expand All @@ -1616,6 +1631,11 @@ describe('Hosted Harness tool approvals', () => {
.set('X-Qwen-Client-Id', clientId)
.send({ title: 'renamed' })
.expect(503);
Comment thread
wenshao marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

[Suggestion] R1-3: This test never observes its own premise: every assertion holds identically whether or not the Session's writes were actually stopped. Still stands at the reviewed commit (dcec43b) — no second /title call is driven, so the decided-before-writable ordering still has no positive witness.

Witness:

probe (round 1): a mutant that stops assigning authority.writeFailure leaves the unmodified test green (1 passed, 30 skipped); adding the premise observation makes the same mutant fail inside the test (expected 503, got 200).

Fix: drive the /title failure twice so only a genuinely stopped Session can reject the second write, and assert the premise before the answers. The original comment below carries the full fix and fix witness.

中文说明

建议 R1-3:该测试从未验证它自己的前提:无论写入是否真的停止,每条断言都照样成立。

在本轮审查的提交(dcec43b9)上仍然成立——仍未驱动第二次 /title 失败,「先 decided 后 writable」的顺序依然缺少正向见证。

修复:连续触发两次 /title 失败,使只有真正停止写入的 Session 才会拒绝第二次写入,并在回答前断言该前提。(完整修复与验收标准见下方原始评论)。

— glm-5.3-flash via Qwen Code /review (v0.24.6)

// Only a Session whose writes stopped refuses the next write as well.
await headers(supertest(server).post(`/session/${SESSION_ID}/title`))
.set('X-Qwen-Client-Id', clientId)
.send({ title: 'renamed again' })
.expect(503);
vi.spyOn(
LocalManagedSessionResourceStore.prototype,
'read',
Expand All @@ -1631,4 +1651,28 @@ describe('Hosted Harness tool approvals', () => {
expect.stringContaining('store unavailable'),
);
});

it('asks again in the next Turn after an approval expired unanswered', async () => {
const { answer, status, submit } = await waitingSession();
const finished = () =>
waitFor(async () =>
expect(await status()).toMatchObject({
hasActivePrompt: false,
recoveryBlocked: false,
}),
);
// An answer after the expiry time expires the Action at once.
const now = vi
.spyOn(Date, 'now')
.mockReturnValue(Date.now() + HOSTED_APPROVAL_TIMEOUT_MS);
const late = await answer('allow');
now.mockRestore();
expect(late.status).toBe(409);
expect(late.body.code).toBe('action_expired');
await finished();
await submit(randomUUID());
expect((await answer('allow')).status).toBe(200);
await finished();
expect(HostedWorkspaceBroker.prototype.execute).toHaveBeenCalledOnce();
});
});
11 changes: 2 additions & 9 deletions packages/cli/src/serve/hosted-harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,6 @@ export function registerHostedHarnessSessionRoutes(
const session = identity(req, sessions);
if (!session) return error(res, 404, 'hosted_session_not_found');
const requestId = req.params['requestId'];
const stopped = session.managed.authority.writesStopped;
void resolveHostedAction(
session.managed,
session.waiters,
Expand All @@ -814,17 +813,11 @@ export function registerHostedHarnessSessionRoutes(
? res.json(result.body)
: error(res, result.status, result.code),
(cause) => {
// This answer recorded nothing, so a retry is safe.
writeStderrLineSafe(
`qwen serve: Hosted Action ${requestId} could not be resolved: ${String(cause)}`,
);
// Only this answer's own write can have stopped the writes; any
// earlier failure wrote nothing and stays retryable.
if (stopped || !session.managed.authority.writesStopped)
return error(res, 503, 'action_resolution_failed');
// A failed append stops every later write, so no retry can succeed.
// Wake the waiting Turn so it blocks the Session now, not at expiry.
session.waiters.notify(requestId);
error(res, 409, 'hosted_turn_recovery_required');
error(res, 503, 'action_resolution_failed');
},
);
});
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/serve/hosted-tool-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { createHash } from 'node:crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
HOSTED_APPROVAL_TIMEOUT_MS,
HOSTED_TOOL_APPROVAL_POLICY,
HostedApprovalWaiters,
hostedActionAllowed,
hostedApprovalAsks,
hostedApprovalDefinition,
parseHostedApprovalSettings,
Expand Down Expand Up @@ -70,6 +73,45 @@ describe('Hosted tool approval settings', () => {
});
});

describe('Hosted approval decisions', () => {
it('recognises allow under the policy revision the Action recorded', () => {
const decision = (optionId: string, policyRevision: string) =>
createHash('sha256')
.update(
JSON.stringify({ v: 1, optionId, inputRevision: 1, policyRevision }),
)
.digest('hex');
const action = (digest: string) => ({
requestId: 'tool_approval_1',
kind: 'permission',
source: 'tool_call',
inputRevision: 1,
optionsRef: null,
state: 'decided' as const,
decisionRef: {
resourceId: 'r',
kind: 'managed-action-decision',
schemaVersion: 1,
byteLength: 1,
digest,
},
});
const next = 'hosted-tool-approval/2';
expect(hostedActionAllowed(action(decision('allow', next)), next)).toBe(
true,
);
expect(
hostedActionAllowed(
action(decision('allow', next)),
HOSTED_TOOL_APPROVAL_POLICY,
),
).toBe(false);
expect(hostedActionAllowed(action(decision('deny', next)), next)).toBe(
false,
);
});
});

describe('Hosted approval waiters', () => {
afterEach(() => {
vi.useRealTimers();
Expand Down Expand Up @@ -122,6 +164,22 @@ describe('Hosted approval waiters', () => {
expect(vi.getTimerCount()).toBe(0);
});

it('does not wait once the signal has aborted', async () => {
vi.useFakeTimers();
const controller = new AbortController();
controller.abort();
let settled = false;
const waiting = new HostedApprovalWaiters()
.wait('a', Date.now() + 5_000, controller.signal, () => false)
.then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(0);
expect(settled).toBe(true);
await waiting;
expect(vi.getTimerCount()).toBe(0);
});

it('does not wait for an Action that is already final', async () => {
vi.useFakeTimers();
const waiters = new HostedApprovalWaiters();
Expand Down
85 changes: 53 additions & 32 deletions packages/cli/src/serve/hosted-tool-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,19 @@ function decisionBytes(
}

/**
* Whether a decided Action chose `allow`. Decision bytes are deterministic, so
* their recorded digest says which option was chosen without reading them.
* Whether a decided Action chose `allow` under the policy revision its options
* recorded. Decision bytes are deterministic, so their recorded digest says
* which option was chosen without reading them.
*/
export function hostedActionAllowed(action: ManagedSessionAction): boolean {
export function hostedActionAllowed(
action: ManagedSessionAction,
policyRevision: string,
): boolean {
return (
action.state === 'decided' &&
action.decisionRef?.digest ===
createHash('sha256')
.update(
decisionBytes(
'allow',
action.inputRevision,
HOSTED_TOOL_APPROVAL_POLICY,
),
)
.update(decisionBytes('allow', action.inputRevision, policyRevision))
.digest('hex')
);
}
Expand Down Expand Up @@ -284,33 +282,61 @@ export async function resolveHostedAction(
)
return { status: 400, code: 'invalid_action_response' };
const writable = () => !isBlocked() && !authority.writesStopped;
if (
authority.action(requestId)!.state === 'requested' &&
Date.now() >= options.expiresAt
) {
if (!writable()) return RECOVERY_REQUIRED;
await endHostedAction(session, requestId, 'expired');
waiters.notify(requestId);
}
const bytes = decisionBytes(
optionId,
existing.inputRevision,
options.policyRevision,
);
const digest = createHash('sha256').update(bytes).digest('hex');
const current = authority.action(requestId)!;
if (current.state === 'expired' || current.state === 'cancelled')
return { status: 409, code: ENDED_CODES[current.state] };
if (current.state === 'decided') {
return current.decisionRef?.digest === digest
const decided = (action: ManagedSessionAction): HostedActionResolution =>
action.decisionRef?.digest === digest
? { status: 200, body: { requestId, state: 'decided', optionId } }
: { status: 409, code: 'action_already_resolved' };
// What is already recorded is answered as it stands, blocked or not.
const recorded = (): HostedActionResolution | undefined => {
const action = authority.action(requestId)!;
if (action.state === 'expired' || action.state === 'cancelled')
return { status: 409, code: ENDED_CODES[action.state] };
return action.state === 'decided' ? decided(action) : undefined;
};
// A write that failed: answer what won the race, or report a Session that
// can no longer write, where no retry can succeed, and wake the waiting
// Turn so it stops now rather than at the expiry. Anything else is
// retryable.
const failed = (cause: unknown): HostedActionResolution => {
const raced = recorded();
if (raced) {
if (authority.action(requestId)!.state === 'decided')
waiters.notify(requestId);
return raced;
}
if (writable()) throw cause;
waiters.notify(requestId);
return RECOVERY_REQUIRED;
};
if (
authority.action(requestId)!.state === 'requested' &&
Date.now() >= options.expiresAt
) {
if (!writable()) return RECOVERY_REQUIRED;
try {
await endHostedAction(session, requestId, 'expired');
} catch (cause) {
return failed(cause);
}
waiters.notify(requestId);
}
const current = recorded();
if (current) return current;
if (!writable()) return RECOVERY_REQUIRED;
const decisionRef = await session.resources.publish(
'managed-action-decision',
bytes,
);
// Another answer, the expiry or a cancel may have landed meanwhile.
const landed = recorded();
if (landed) return landed;
if (!writable()) return RECOVERY_REQUIRED;
try {
await authority.resolveAction(
{
Expand All @@ -320,17 +346,12 @@ export async function resolveHostedAction(
contentDigest: digest,
},
{ requestId, state: 'decided', decisionRef },
// Asked again inside the authority's queue, since the Turn may block
// while this write waits behind others.
writable,
);
} catch (cause) {
const raced = authority.action(requestId)!;
if (
!(cause instanceof ManagedSessionConflictError) ||
raced.state === 'requested'
)
throw cause;
return raced.state === 'decided'
? { status: 409, code: 'action_already_resolved' }
: { status: 409, code: ENDED_CODES[raced.state] };
return failed(cause);
}
waiters.notify(requestId);
return { status: 200, body: { requestId, state: 'decided', optionId } };
Expand Down
Loading
Loading