From c13c04ea39d2383b3ff2fd119ad10548b1321196 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 28 Oct 2026 17:32:37 +0800 Subject: [PATCH 001/166] fix(acp): close managed writers only after peer flushes settle The writer-close loop invoked closeSessionWriter synchronously while beginManagedShutdown was still collecting the per-session cancel/flush promises, so the writer could close before the flush riding the cancel promise landed. Defer the loop into the allSettled(peerFlushes) continuation so the ordering is cancel -> flush -> close. --- packages/cli/src/acp-integration/acpAgent.ts | 52 ++++++++++---------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index df1750e74c6..9d0e2e7840a 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -4274,32 +4274,34 @@ class QwenAgent implements Agent { } const configList = [...configs]; - const writerTerminals: Array> = []; - for (const config of configList) { - // A Managed session's Runtime worker stops before its log is handed - // off, so no call outlives the log; other writers close at once. - if (config.getSessionExecutionEngine?.() === 'managed') { - writerTerminals.push( - Promise.resolve() - .then(() => config.closeManagedRuntime()) - .catch((error: unknown) => { - debugLogger.error( - '[ACP] Managed Runtime worker shutdown error:', - error, - ); - }) - .then(() => config.closeSessionWriter({ handoff: true })), - ); - continue; - } - try { - writerTerminals.push(config.closeSessionWriter({ handoff: true })); - } catch (error) { - writerTerminals.push(Promise.reject(error)); - } - } const writerShutdown = Promise.allSettled(peerFlushes) - .then(() => Promise.allSettled(writerTerminals)) + .then(() => { + const writerTerminals: Array> = []; + for (const config of configList) { + // A Managed session's Runtime worker stops before its log is handed + // off, so no call outlives the log; other writers close at once. + if (config.getSessionExecutionEngine?.() === 'managed') { + writerTerminals.push( + Promise.resolve() + .then(() => config.closeManagedRuntime()) + .catch((error: unknown) => { + debugLogger.error( + '[ACP] Managed Runtime worker shutdown error:', + error, + ); + }) + .then(() => config.closeSessionWriter({ handoff: true })), + ); + continue; + } + try { + writerTerminals.push(config.closeSessionWriter({ handoff: true })); + } catch (error) { + writerTerminals.push(Promise.reject(error)); + } + } + return Promise.allSettled(writerTerminals); + }) .then((results) => { const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [], From 021621825acd9e84c7cdfb44672c34a3bbf016f1 Mon Sep 17 00:00:00 2001 From: 2SinceJuly <2929362966@qq.com> Date: Thu, 29 Oct 2026 00:50:30 +0800 Subject: [PATCH 002/166] test: settle the two loaded-host waits from #15593 (#15639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: settle the two loaded-host waits from #15593 The command-checkpoint wait in the tool-turn suite was the only bare `vi.waitFor` in that file and fell back to vitest's 1 s default, while all sixteen sibling waits in the same file pass 5 s or 10 s. The gate suite's persisted-stamp assertions polled under the same 1 s default. Await the scheduler's own `pendingPersist` instead, as the rest of that file already does, so the read happens after the write it asserts on lands, rather than inside a wall-clock budget. Verified: with the durable write delayed by 1.2 s the old assertion fails (`expected 1736908140000 to be 1736908260000`, the same shape as the CI flip); the new one passes at 1.2 s and at 3 s. Both suites are green on macOS and on Linux under node:22. * test(core): await the write chain from one seam in the gate suite Review of #15639 found the file's last 1 s-default poll still standing 44 lines above the hunks: the `enableDurable` catch-up stamp. The signal does exist there — `start()` drains the pending catch-up synchronously, so the stamp write is already chained when the poll would have run — so it now awaits the same chain instead of polling. The await itself was reaching the private field through a repeated double cast: ten sites, no compile-time tie to the field. One `persisted()` accessor in the describe scope replaces all ten, dereferencing the field per call because it is reassigned on every tracked write. Acceptance for the converted poll, with the durable write held 1.2 s: the two catch-up cases fail on the previous commit (`expected 1736895600000 to be 1736908140000`) and pass here. The file's 28 tests and the wider cron suites (237) are green. --------- Co-authored-by: 肖洪进 --- ...osted-workspace-tool-turn.commands.test.ts | 11 ++-- .../src/services/cronScheduler.gate.test.ts | 54 +++++++++---------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/serve/hosted-workspace-tool-turn.commands.test.ts b/packages/cli/src/serve/hosted-workspace-tool-turn.commands.test.ts index b32a618cacc..52ebd8c92ae 100644 --- a/packages/cli/src/serve/hosted-workspace-tool-turn.commands.test.ts +++ b/packages/cli/src/serve/hosted-workspace-tool-turn.commands.test.ts @@ -631,9 +631,14 @@ it('parks on the command checkpoint, answers from the reply, and marks it consum call({ status: 'pending' }, 'call-list-1', 'task_list'), ]); const commandId = commandIdOf('call-list-1'); - await vi.waitFor(() => { - expect(commands.command(commandId)).toBeDefined(); - }); + // The turn's append chain can outlast vitest's 1 s waitFor default on a + // loaded host (#15593); this is the budget the file's other projections use. + await vi.waitFor( + () => { + expect(commands.command(commandId)).toBeDefined(); + }, + { timeout: 10_000 }, + ); // The checkpoint named the batch before the outbox commit ever ran. const authority = await session.authority.harnessRunAuthorization(); if (authority.status !== 'runnable') diff --git a/packages/core/src/services/cronScheduler.gate.test.ts b/packages/core/src/services/cronScheduler.gate.test.ts index caf58f1c495..c0e703e0f56 100644 --- a/packages/core/src/services/cronScheduler.gate.test.ts +++ b/packages/core/src/services/cronScheduler.gate.test.ts @@ -376,6 +376,13 @@ describe('CronScheduler gate', () => { describe('durable tasks', () => { let tmpDir: string; + // The chain every background write is appended to. Dereferenced per call: + // the field is reassigned on each tracked write, so a value captured once + // would await a chain that has already resolved. + const persisted = (): Promise => + (scheduler as unknown as { pendingPersist: Promise }) + .pendingPersist; + beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cron-gate-test-')); Storage.setRuntimeBaseDir(tmpDir); @@ -388,8 +395,7 @@ describe('CronScheduler gate', () => { afterEach(async () => { scheduler.destroy(); - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); Storage.setRuntimeBaseDir(null); await fs.rm(tmpDir, { recursive: true, @@ -465,15 +471,13 @@ describe('CronScheduler gate', () => { // The stamp lands before the gate decides, so a restart cannot // replay the catch-up; the run does not. let task: DurableCronTask | undefined; - await vi.waitFor(async () => { - [task] = await readCronTasks(tmpDir); - expect(task?.lastFiredAt).toBe(at(10, 29).getTime()); - }); + await persisted(); + [task] = await readCronTasks(tmpDir); + expect(task?.lastFiredAt).toBe(at(10, 29).getTime()); expect(task?.runs ?? []).toHaveLength(0); await script.release(result); expect(fired).toHaveLength(runs); - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); [task] = await readCronTasks(tmpDir); expect(task?.runs ?? []).toHaveLength(runs); if (runs > 0) { @@ -509,10 +513,10 @@ describe('CronScheduler gate', () => { scheduler.tick(at(10, 30, 59)); await script.release(quiet); scheduler.tick(at(10, 31, 59)); - await vi.waitFor(async () => { - [task] = await readCronTasks(tmpDir); - expect(task?.lastFiredAt).toBe(at(10, 31).getTime()); - }); + await (scheduler as unknown as { pendingPersist: Promise }) + .pendingPersist; + [task] = await readCronTasks(tmpDir); + expect(task?.lastFiredAt).toBe(at(10, 31).getTime()); expect(task?.runs ?? []).toHaveLength(0); expect(task?.gateStatus).toMatchObject({ lastOutcome: 'quiet', @@ -521,10 +525,10 @@ describe('CronScheduler gate', () => { await script.release(output('event: comment\n')); expect(fired).toHaveLength(1); - await vi.waitFor(async () => { - [task] = await readCronTasks(tmpDir); - expect(task?.runs).toHaveLength(1); - }); + await (scheduler as unknown as { pendingPersist: Promise }) + .pendingPersist; + [task] = await readCronTasks(tmpDir); + expect(task?.runs).toHaveLength(1); expect(task?.runs?.[0]).toMatchObject({ at: at(10, 31).getTime(), kind: 'scheduled', @@ -611,8 +615,7 @@ describe('CronScheduler gate', () => { // Before the next slot's tick persist: the file already has the // streak ended, so a scheduler that reloads it reports its next // failure. - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); const [task] = await readCronTasks(tmpDir); expect(task?.gateStatus).toMatchObject({ lastOutcome: 'quiet', @@ -639,8 +642,7 @@ describe('CronScheduler gate', () => { live.lastFiredAt = at(10, 45).getTime(); await script.release(output('event: comment\n')); expect(fired[0]!.lastFiredAt).toBe(at(10, 30).getTime()); - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); const [task] = await readCronTasks(tmpDir); expect(task?.runs).toEqual([ expect.objectContaining({ @@ -672,8 +674,7 @@ describe('CronScheduler gate', () => { ); await script.release(failed); // the old command's late failure expect(fired).toHaveLength(1); - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); let [task] = await readCronTasks(tmpDir); expect(task?.gate).toBe('./new.sh'); expect(task?.gateStatus).toBeUndefined(); @@ -685,8 +686,7 @@ describe('CronScheduler gate', () => { await script.release(failed); // the new command's first failure expect(fired).toHaveLength(2); expect(fired[1]!.prompt).toContain('./new.sh'); - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); [task] = await readCronTasks(tmpDir); expect(task?.gateStatus?.consecutiveFailures).toBe(1); }); @@ -695,9 +695,6 @@ describe('CronScheduler gate', () => { // The old command already failed once; the PATCH swaps the command and // drops the status; a tick lands before the watcher reloads the task. // Its persist must not put the old streak back on the new gate. - const persisted = () => - (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; const job = await scheduler.createDurable( '* * * * *', 'p', @@ -758,8 +755,7 @@ describe('CronScheduler gate', () => { expect(fired).toHaveLength(1); const current = () => scheduler.list().find((j) => j.id === job.id); expect(current()?.gateStatus?.consecutiveFailures).toBe(2); - await (scheduler as unknown as { pendingPersist: Promise }) - .pendingPersist; + await persisted(); await updateCronTasks(tmpDir, (tasks) => tasks.map(({ gateStatus: _dropped, ...t }) => From 08d97f383a7ec3c51060426fad843ca0c4a94b64 Mon Sep 17 00:00:00 2001 From: "Bob.qwencode" <1144323068@qq.com> Date: Thu, 29 Oct 2026 01:02:53 +0800 Subject: [PATCH 003/166] chore(release): sdk-typescript v0.1.25 [skip ci] * chore(release): v0.26.3 * chore(release): sdk-typescript v0.1.25 --------- Co-authored-by: github-actions[bot] --- integrations/external-context-mem0/package.json | 2 +- integrations/external-context-mem0/qwen-extension.json | 2 +- integrations/external-context/package.json | 2 +- integrations/generated-ui/package.json | 2 +- integrations/test-failure-explorer/package.json | 2 +- package.json | 4 ++-- packages/acp-bridge/package.json | 2 +- packages/audio-capture/package.json | 2 +- packages/channels/base/package.json | 2 +- packages/channels/dingtalk/package.json | 4 ++-- packages/channels/dws/package.json | 4 ++-- packages/channels/email/package.json | 4 ++-- packages/channels/feishu/package.json | 4 ++-- packages/channels/github/package.json | 4 ++-- packages/channels/gitlab/package.json | 4 ++-- packages/channels/plugin-example/package.json | 2 +- packages/channels/qqbot/package.json | 4 ++-- packages/channels/telegram/package.json | 4 ++-- packages/channels/wecom/package.json | 4 ++-- packages/channels/weixin/package.json | 4 ++-- packages/chrome-extension/package.json | 2 +- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/sdk-typescript/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- packages/web-shell/package.json | 2 +- packages/web-templates/package.json | 2 +- 27 files changed, 39 insertions(+), 39 deletions(-) diff --git a/integrations/external-context-mem0/package.json b/integrations/external-context-mem0/package.json index 52e273f382e..992f04cfa81 100644 --- a/integrations/external-context-mem0/package.json +++ b/integrations/external-context-mem0/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/external-context-mem0", - "version": "0.26.2", + "version": "0.26.3", "description": "Configurable Mem0-compatible External Context Extension", "repository": { "type": "git", diff --git a/integrations/external-context-mem0/qwen-extension.json b/integrations/external-context-mem0/qwen-extension.json index 8ad1c093449..e3de32be00d 100644 --- a/integrations/external-context-mem0/qwen-extension.json +++ b/integrations/external-context-mem0/qwen-extension.json @@ -8,7 +8,7 @@ "en": "Configurable retrieval from an administrator-bound Mem0-compatible service", "zh": "从管理员绑定的 Mem0 兼容服务检索外部上下文" }, - "version": "0.26.2", + "version": "0.26.3", "mcpServers": { "external-context-mem0": { "command": "node", diff --git a/integrations/external-context/package.json b/integrations/external-context/package.json index 3f7c09d56bc..7e7d175f533 100644 --- a/integrations/external-context/package.json +++ b/integrations/external-context/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/external-context", - "version": "0.26.2", + "version": "0.26.3", "private": true, "description": "Provider-bound external context for Qwen Code", "type": "module", diff --git a/integrations/generated-ui/package.json b/integrations/generated-ui/package.json index 1b78aa71466..54c35a08bc1 100644 --- a/integrations/generated-ui/package.json +++ b/integrations/generated-ui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/generated-ui", - "version": "0.26.2", + "version": "0.26.3", "private": true, "description": "Bounded locally interactive HTML and JavaScript views over MCP Apps", "repository": { diff --git a/integrations/test-failure-explorer/package.json b/integrations/test-failure-explorer/package.json index 70bfc585853..9af3924de5c 100644 --- a/integrations/test-failure-explorer/package.json +++ b/integrations/test-failure-explorer/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/test-failure-explorer", - "version": "0.26.2", + "version": "0.26.3", "private": true, "description": "Workspace-bound queries over immutable Vitest JSON reports", "repository": { diff --git a/package.json b/package.json index 07df5d81a92..539414acf77 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/qwen-code", "private": true, - "version": "0.26.2", + "version": "0.26.3", "packageManager": "pnpm@11.24.0+sha512.bd27e345e976dcb0be0b7a1228217b049a817e21b1f355c90dbe7dc46671895a8bc1e6d06c24554505ea93ea0b45f489a27ec1bfbc8de6a9659fca0f16fa0000", "engines": { "node": ">=22.0.0" @@ -35,7 +35,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.26.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.26.3" }, "scripts": { "start": "node scripts/start.js", diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 2e8e01ad484..04df7773b2a 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/acp-bridge", "private": true, - "version": "0.26.2", + "version": "0.26.3", "description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.", "repository": { "type": "git", diff --git a/packages/audio-capture/package.json b/packages/audio-capture/package.json index 16cfdc9edf0..7695627c726 100644 --- a/packages/audio-capture/package.json +++ b/packages/audio-capture/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/audio-capture", - "version": "0.26.2", + "version": "0.26.3", "description": "Native microphone capture backend for Qwen Code voice input", "repository": { "type": "git", diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json index 42f57b3862e..26dfdf61ef6 100644 --- a/packages/channels/base/package.json +++ b/packages/channels/base/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-base", - "version": "0.26.2", + "version": "0.26.3", "description": "Base channel infrastructure for Qwen Code", "repository": { "type": "git", diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json index 0a7e804228b..1b99740d407 100644 --- a/packages/channels/dingtalk/package.json +++ b/packages/channels/dingtalk/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-dingtalk", - "version": "0.26.2", + "version": "0.26.3", "description": "DingTalk channel adapter for Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "dingtalk-stream-sdk-nodejs": "^2.0.4" }, "devDependencies": { diff --git a/packages/channels/dws/package.json b/packages/channels/dws/package.json index e51d3dc1d1a..28a8d1e909a 100644 --- a/packages/channels/dws/package.json +++ b/packages/channels/dws/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-dws", - "version": "0.26.2", + "version": "0.26.3", "description": "DingTalk Workspace channel adapter for Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2" + "@qwen-code/channel-base": "0.26.3" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/channels/email/package.json b/packages/channels/email/package.json index 97948144741..8aa56554352 100644 --- a/packages/channels/email/package.json +++ b/packages/channels/email/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/channel-email", "private": true, - "version": "0.26.2", + "version": "0.26.3", "description": "IMAP and SMTP Email channel for Qwen Code", "type": "module", "main": "dist/index.js", @@ -21,7 +21,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "imapflow": "^1.7.8", "mailparser": "^3.9.28", "nodemailer": "^10.0.10", diff --git a/packages/channels/feishu/package.json b/packages/channels/feishu/package.json index 32b3d8ec273..eca43a316b4 100644 --- a/packages/channels/feishu/package.json +++ b/packages/channels/feishu/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-feishu", - "version": "0.26.2", + "version": "0.26.3", "description": "Feishu (Lark) channel adapter for Qwen Code", "repository": { "type": "git", @@ -26,7 +26,7 @@ }, "dependencies": { "@larksuiteoapi/node-sdk": "^1.73.0", - "@qwen-code/channel-base": "0.26.2" + "@qwen-code/channel-base": "0.26.3" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/channels/github/package.json b/packages/channels/github/package.json index c2d23862cc8..90d776be9a5 100644 --- a/packages/channels/github/package.json +++ b/packages/channels/github/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-github", - "version": "0.26.2", + "version": "0.26.3", "description": "GitHub polling channel adapter for Qwen Code", "repository": { "type": "git", @@ -26,7 +26,7 @@ }, "dependencies": { "@octokit/rest": "^21.1.1", - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "https-proxy-agent": "^7.0.6" }, "devDependencies": { diff --git a/packages/channels/gitlab/package.json b/packages/channels/gitlab/package.json index 1761b4eca2b..c4c91124661 100644 --- a/packages/channels/gitlab/package.json +++ b/packages/channels/gitlab/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/channel-gitlab", "private": true, - "version": "0.26.2", + "version": "0.26.3", "description": "GitLab polling channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", @@ -22,7 +22,7 @@ }, "dependencies": { "@gitbeaker/rest": "^42.5.0", - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "zod": "^3.25.0" }, "devDependencies": { diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index 068e18f6ce0..73c17518b6e 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-plugin-example", - "version": "0.26.2", + "version": "0.26.3", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/channels/qqbot/package.json b/packages/channels/qqbot/package.json index c2d5174295d..c8b09c2ef7a 100644 --- a/packages/channels/qqbot/package.json +++ b/packages/channels/qqbot/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-qqbot", - "version": "0.26.2", + "version": "0.26.3", "description": "QQ Bot (QQ机器人) channel adapter for Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "@tencent-connect/qqbot-connector": "^1.1.0", "ws": "^8.18.0" }, diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 2c2f5e29eca..f90f529542e 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-telegram", - "version": "0.26.2", + "version": "0.26.3", "description": "Telegram channel adapter for Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "grammy": "^1.41.1", "https-proxy-agent": "^7.0.6", "telegram-markdown-formatter": "^0.1.2" diff --git a/packages/channels/wecom/package.json b/packages/channels/wecom/package.json index 0d751154281..6fd0895ff0a 100644 --- a/packages/channels/wecom/package.json +++ b/packages/channels/wecom/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-wecom", - "version": "0.26.2", + "version": "0.26.3", "description": "WeCom channel adapter for Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2", + "@qwen-code/channel-base": "0.26.3", "@wecom/aibot-node-sdk": "^1.0.7", "saxes": "6.0.0" }, diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json index 3a0c8032922..2677fc935ce 100644 --- a/packages/channels/weixin/package.json +++ b/packages/channels/weixin/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-weixin", - "version": "0.26.2", + "version": "0.26.3", "description": "WeChat (Weixin) channel adapter for Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "test:ci": "vitest run" }, "dependencies": { - "@qwen-code/channel-base": "0.26.2" + "@qwen-code/channel-base": "0.26.3" }, "devDependencies": { "typescript": "^5.0.0" diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index 0d209d6427d..6640c759876 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/chrome-bridge", - "version": "0.26.2", + "version": "0.26.3", "description": "Chrome extension bridge for Qwen CLI - enables AI-powered browser interactions", "private": true, "repository": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3ed9a98e335..f4f2c5bcf97 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/qwen-code", "private": true, - "version": "0.26.2", + "version": "0.26.3", "description": "Qwen Code", "repository": { "type": "git", @@ -38,7 +38,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.26.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.26.3" }, "dependencies": { "@alcalzone/ansi-tokenize": "0.3.0", diff --git a/packages/core/package.json b/packages/core/package.json index f5f6dba6051..6508e7550eb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/qwen-code-core", "private": true, - "version": "0.26.2", + "version": "0.26.3", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index a9690f2d47a..df7161d2fbe 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.24", + "version": "0.1.25", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 1b0b465d53d..adcf8745bbf 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -3,7 +3,7 @@ "private": true, "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.26.2", + "version": "0.26.3", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json index d3899cb37cb..037d4e66562 100644 --- a/packages/web-shell/package.json +++ b/packages/web-shell/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/web-shell", - "version": "0.26.2", + "version": "0.26.3", "repository": { "type": "git", "url": "git+https://github.com/QwenLM/qwen-code.git", diff --git a/packages/web-templates/package.json b/packages/web-templates/package.json index 270ab85c314..b7d5c1b935a 100644 --- a/packages/web-templates/package.json +++ b/packages/web-templates/package.json @@ -1,7 +1,7 @@ { "name": "@qwen-code/web-templates", "private": true, - "version": "0.26.2", + "version": "0.26.3", "description": "Web templates bundled as embeddable JS/CSS strings", "repository": { "type": "git", From 4196cf81d1e64e961ca7f6fb3f89b1fe352b0bc6 Mon Sep 17 00:00:00 2001 From: "Bob.qwencode" <1144323068@qq.com> Date: Thu, 29 Oct 2026 02:08:59 +0800 Subject: [PATCH 004/166] chore(release): v0.26.3 [skip ci] * chore(release): v0.26.3 * docs(changelog): sync for v0.26.3 * docs(changelog): sync for v0.26.3 --------- Co-authored-by: github-actions[bot] --- CHANGELOG.md | 257 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6849779ac..f456e098e40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,263 @@ are listed; nightly and preview pre-releases are intentionally omitted. > [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it > by hand — run `npm run changelog` to regenerate. +## [0.26.3](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.3) - 2026-10-28 + +### Highlights + +- Generated UI cards keep inputs and state across navigation and split views, show live views after initial calculation, and appear only for clients that can render MCP Apps. ([#15611](https://github.com/QwenLM/qwen-code/pull/15611), [#15787](https://github.com/QwenLM/qwen-code/pull/15787), [#15720](https://github.com/QwenLM/qwen-code/pull/15720), [#15790](https://github.com/QwenLM/qwen-code/pull/15790)) +- Persistent session Workflow mode can be enabled with --workflow-mode on, /workflows mode on, SDK, or ACP, and workflow switches now reach the CLI sandbox. ([#15422](https://github.com/QwenLM/qwen-code/pull/15422), [#15743](https://github.com/QwenLM/qwen-code/pull/15743)) +- Tool results shrink to remaining headroom before auto-compaction, and qwen-latest-series- or qwen4 aliases are detected with 1M-token context windows. ([#13599](https://github.com/QwenLM/qwen-code/pull/13599), [#15758](https://github.com/QwenLM/qwen-code/pull/15758), [#15762](https://github.com/QwenLM/qwen-code/pull/15762)) +- Valid XML tool calls are recovered when parameters quote other call markup, malformed blocks are isolated, and quoted examples are not dispatched. ([#13579](https://github.com/QwenLM/qwen-code/pull/13579)) +- ACP and daemon clients keep rewind identity consistent across retries, cancellations, checkpoints, recordings, and notifications, while SDK frames preserve subagent identity. ([#15441](https://github.com/QwenLM/qwen-code/pull/15441), [#15657](https://github.com/QwenLM/qwen-code/pull/15657)) +- /autofix-pr start stops when the PR head branch has unpushed commits or uncommitted changes, preventing the watch from overwriting unseen work. ([#15736](https://github.com/QwenLM/qwen-code/pull/15736)) + +### Breaking Changes + +No known breaking changes. + +### Complete Change List + +#### Features + +- feat(memory): opt-in extraction cadence after a no-op run ([#13571](https://github.com/QwenLM/qwen-code/pull/13571)) by @yiliang114 +- Shrinks tool results to remaining headroom before auto-compaction, respecting explicit truncation settings and protected content to reduce unnecessary compaction. ([#13599](https://github.com/QwenLM/qwen-code/pull/13599)) by @yiliang114 +- Keeps eligible Generated UI cards alive across history navigation, session switching, and split-view changes in the same page, preserving inputs, controls, and runtime state. ([#15611](https://github.com/QwenLM/qwen-code/pull/15611)) by @qqqys +- Adds persistent session Workflow mode through --workflow-mode on, /workflows mode on, SDK, and ACP controls, with saved mode shown in Ink and OpenTUI. ([#15422](https://github.com/QwenLM/qwen-code/pull/15422)) by @qqqys +- Adds opt-in composerVariant simple to Web Shell, providing a compact input with a limited add menu and shared editor, attachments, and session actions. ([#15663](https://github.com/QwenLM/qwen-code/pull/15663)) by @samuelhsin +- /autofix-pr start now stops if the PR head branch has unpushed commits or uncommitted changes, preventing the watch from overwriting unseen work. ([#15736](https://github.com/QwenLM/qwen-code/pull/15736)) by @wenshao +- Adds administrator-driven reclamation and reuse of idle resident CSI Workspaces with cleanup verification and atomic release. ([#15729](https://github.com/QwenLM/qwen-code/pull/15729)) by @doudouOUC +- OpenTUI terminals now draw the Mods band above the prompt and run Mod ui.render hooks there, matching the Ink terminal experience. ([#15699](https://github.com/QwenLM/qwen-code/pull/15699)) by @qqqys +- ACP live frames, persisted replays, SDK normalization, and transcript reduction now preserve subagent identity for thoughts, messages, and tool frames. ([#15657](https://github.com/QwenLM/qwen-code/pull/15657)) by @yiliang114 +- Archived conversations are now managed in Settings → Archived conversations in Web Shell, keeping archive groups, filters, and lifecycle actions. ([#15557](https://github.com/QwenLM/qwen-code/pull/15557)) by @deggs7 +- Managed Agent can now be embedded opt-in in a Java host's JVM and Spring context, with dedicated connection pooling or a supplied DataSource. ([#15718](https://github.com/QwenLM/qwen-code/pull/15718)) by @yiliang114 +- /verify-pr now supports a failures ledger and rejects findings or blocked verdicts unless failures are attributed to the PR. ([#15764](https://github.com/QwenLM/qwen-code/pull/15764)) by @wenshao +- Adds the qwen-managed-agent-admin launcher for running managed-agent offline maintenance commands by name, with help and unknown-command handling. ([#15772](https://github.com/QwenLM/qwen-code/pull/15772)) by @wenshao +- feat(generated-ui): check a view's initial calculation before display ([#15790](https://github.com/QwenLM/qwen-code/pull/15790)) by @qqqys + +#### Bug Fixes + +- Recovers valid XML tool calls when parameters quote other call markup, isolates malformed blocks, and prevents quoted examples from being dispatched. ([#13579](https://github.com/QwenLM/qwen-code/pull/13579)) by @yiliang114 +- Carries rewind identity through ACP/daemon clients so retries, cancellations, checkpoint resolution, recording, and notifications stay consistent. ([#15441](https://github.com/QwenLM/qwen-code/pull/15441)) by @yiliang114 +- Fixes release publishing checkout failures by using the run-scoped GitHub token for read-only checkout while keeping the bot PAT for write steps. ([#15728](https://github.com/QwenLM/qwen-code/pull/15728)) by @qwen-code-dev-bot +- Pins pull request CI retries to the head SHA from the triggering event so reruns test the intended commit. ([#15734](https://github.com/QwenLM/qwen-code/pull/15734)) by @yiliang114 +- fix(cli): forward workflow switches into the CLI sandbox (#15740) ([#15743](https://github.com/QwenLM/qwen-code/pull/15743)) by @qwen-code-dev-bot +- Recognizes invite-only qwen-latest-series- model aliases as 1M-context models in the context-window detector. ([#15758](https://github.com/QwenLM/qwen-code/pull/15758)) by @wenshao +- Makes daemon-managed channels honor approvalMode even when no webhooks are configured, matching standalone startup. ([#15754](https://github.com/QwenLM/qwen-code/pull/15754)) by @qqqys +- Shows the exception class in managed-agent scan summaries for non-first row failures that have no message. ([#15725](https://github.com/QwenLM/qwen-code/pull/15725)) by @yiliang114 +- Workspace Skill inventory queries now return a retained snapshot immediately and refresh in the background, while cold reads, deletion, and enablement wait for fresh data. ([#14890](https://github.com/QwenLM/qwen-code/pull/14890)) by @callmeYe +- qwen4 model identifiers are now sized with a 1,000,000-token context window by default when no upstream catalog entry defines them. ([#15762](https://github.com/QwenLM/qwen-code/pull/15762)) by @wenshao +- Acceptance tests now wait for the focused shell Tab byte to reach the child process before asserting exact input bytes, preventing flaky failures. ([#15763](https://github.com/QwenLM/qwen-code/pull/15763)) by @yiliang114 +- Managed runtime distribution now publishes verified server artifacts to GitHub Releases and OSS without requiring Maven Central, keeping CLI downloads unchanged. ([#15782](https://github.com/QwenLM/qwen-code/pull/15782)) by @doudouOUC +- Generated UI is now offered only to sessions whose client can render MCP Apps, while unsupported clients receive a clear unsupported-client reason. ([#15720](https://github.com/QwenLM/qwen-code/pull/15720)) by @qqqys +- In Web Shell, live Generated UI cards now attach and show their interactive view immediately when tool results arrive instead of requiring reopen. ([#15787](https://github.com/QwenLM/qwen-code/pull/15787)) by @qqqys +- Desktop release preparation now resets Git metadata on self-hosted runners before checkout to avoid stale-workspace failures. ([#15761](https://github.com/QwenLM/qwen-code/pull/15761)) by @yiliang114 + +#### Documentation + +- Documents managed-agent shared-Session queue behavior and advises giving slow-probed gated definitions their own Session. ([#15724](https://github.com/QwenLM/qwen-code/pull/15724)) by @yiliang114 + +#### Internal Changes + +- Internal tests now compare git worktree prune dry-run results as an unordered set, making them insensitive to git output order. ([#15767](https://github.com/QwenLM/qwen-code/pull/15767)) by @yiliang114 +- Managed snapshot pool test setup now allows a three-second database connection lease so schema creation on busy CI runners is less likely to fail. ([#15749](https://github.com/QwenLM/qwen-code/pull/15749)) by @yiliang114 +- Installed npm and private Bun acceptance tests now run nightly and by manual dispatch instead of on every PR update, while source TUI PR behavior stays unchanged. ([#15719](https://github.com/QwenLM/qwen-code/pull/15719)) by @yiliang114 +- Improved Managed Agent reliability testing by adding terminal recovery fault gates for failover scenarios. ([#15756](https://github.com/QwenLM/qwen-code/pull/15756)) by @wenshao +- Improved CLI test stability for review cleanup orphan-process handling by making process liveness deterministic. ([#15751](https://github.com/QwenLM/qwen-code/pull/15751)) by @yiliang114 +- Hosted gates now use a shared zombie-aware liveness check, improving consistency when waiting for processes to stop. ([#15777](https://github.com/QwenLM/qwen-code/pull/15777)) by @wenshao + +**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.26.2...v0.26.3 + +## [0.26.2](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.2) - 2026-10-27 + +### Highlights + +_See the complete change list below._ + +### Breaking Changes + +- fix(managed-agent)!: simplify Web Shell controls and retire cwd submissions ([#15616](https://github.com/QwenLM/qwen-code/pull/15616)) by @doudouOUC + +### Complete Change List + +#### Features + +- feat(managed): add session permission rules and Auto approval ([#15448](https://github.com/QwenLM/qwen-code/pull/15448)) by @doudouOUC +- feat(core): add an experimental shared agent loop and run headless on it ([#15369](https://github.com/QwenLM/qwen-code/pull/15369)) by @qqqys +- Adds X-DashScope-Source-Config to matching QwenCloud and Qianwen AI Platform requests to identify site and client, without changing authentication or retries. ([#15041](https://github.com/QwenLM/qwen-code/pull/15041)) by @yiliang114 +- Resumed OpenTUI sessions now retain recorded text, images, tool results, and prompt identities, while rewind restores transcript, request, and draft. ([#15384](https://github.com/QwenLM/qwen-code/pull/15384)) by @chiga0 +- OpenTUI now captures complete checkpoints at edit approvals and restores them safely, preserving conversation metadata and blocking stale or cancelled owners. ([#15389](https://github.com/QwenLM/qwen-code/pull/15389)) by @chiga0 +- Adds automation.scanner-threads to run managed automation scanner rows on a dedicated pool and leases rows from claim time. ([#15442](https://github.com/QwenLM/qwen-code/pull/15442)) by @yiliang114 +- Adds Continue analysis with current parameters so Generated UI users can review and send frozen parameters to the original session. ([#15333](https://github.com/QwenLM/qwen-code/pull/15333)) by @qqqys +- Adds an opt-in external-scratch Runtime provider that lets an operator-run HTTP service place Session workers while preserving attestation and recovery. ([#15261](https://github.com/QwenLM/qwen-code/pull/15261)) by @yiliang114 +- Adds a prompt argument hint for /verify-pr so users can see the PR number and supported flags such as --publish and --watch. ([#15463](https://github.com/QwenLM/qwen-code/pull/15463)) by @wenshao +- Adds a durable queue for messages sent during a Managed Turn, allowing queued text and file references to be delivered, edited, withdrawn, or resumed safely. ([#15449](https://github.com/QwenLM/qwen-code/pull/15449)) by @doudouOUC +- Adds /autofix-pr status to list all watches in a clone and lets users resume a watch in a later session. ([#15510](https://github.com/QwenLM/qwen-code/pull/15510)) by @wenshao +- Adds a Git URL source to the Web Shell Add-workspace dialog so users can clone a repository and open it as a workspace in the browser. ([#15361](https://github.com/QwenLM/qwen-code/pull/15361)) by @wenshao +- Enables image and PDF delivery for managed runtime providers with a 768 KiB base64 media limit and a 896 KiB tool-result limit. ([#13606](https://github.com/QwenLM/qwen-code/pull/13606)) by @water-in-stone +- feat(managed-agent): give the Hosted Harness its Broker through the environment ([#15244](https://github.com/QwenLM/qwen-code/pull/15244)) by @wenshao +- Adds an operator endpoint to register immutable, idempotent EXTERNAL Workspace storage ownership with provider identity and tenant alias. ([#15516](https://github.com/QwenLM/qwen-code/pull/15516)) by @yiliang114 +- UserPromptSubmit hooks no longer re-append byte-identical additionalContext within a session, avoiding duplicate context and keeping transcripts accurate. ([#14129](https://github.com/QwenLM/qwen-code/pull/14129)) by @yiliang114 +- Mods can now observe turn.start and turn.complete events in headless and terminal runs, and use $.turn.abort to end a running turn. ([#15443](https://github.com/QwenLM/qwen-code/pull/15443)) by @qqqys +- Web Shell Settings now lets you list, add, and remove extra workspace directories, applying changes to live sessions and informing the model of new roots. ([#15393](https://github.com/QwenLM/qwen-code/pull/15393)) by @wenshao +- feat(autofix-pr): run the helpers through a tool, and let a confirmed watch's fixes run unattended ([#15548](https://github.com/QwenLM/qwen-code/pull/15548)) by @wenshao +- feat(managed-agent): let the bootstrap set the service account and MySQL up ([#15543](https://github.com/QwenLM/qwen-code/pull/15543)) by @wenshao +- feat(managed-agent): add pinned extension package management ([#15490](https://github.com/QwenLM/qwen-code/pull/15490)) by @doudouOUC +- feat(managed-agent): support Workspace file uploads ([#15586](https://github.com/QwenLM/qwen-code/pull/15586)) by @doudouOUC +- feat(core): Add durable terminal identity lookup ([#15566](https://github.com/QwenLM/qwen-code/pull/15566)) by @doudouOUC +- When experimental.unifiedAgentLoop is enabled, fresh ACP user prompts run their model round on the shared agent loop while ACP keeps its own tool-step handling. ([#15509](https://github.com/QwenLM/qwen-code/pull/15509)) by @qqqys +- The Mod runtime adds ui.render, $.ui.resolve, and $.ui.invalidate support, enabling render-site hooks and terminal element resolution before hosts draw UI. ([#15591](https://github.com/QwenLM/qwen-code/pull/15591)) by @qqqys +- feat(web-shell): add plain-text and Markdown reply copying ([#15345](https://github.com/QwenLM/qwen-code/pull/15345)) by @ytahdn +- feat(cli): connect OpenTUI voice input to the shared pipeline ([#15617](https://github.com/QwenLM/qwen-code/pull/15617)) by @chiga0 +- feat(ci): canary-split the PR review model by PR number ([#15538](https://github.com/QwenLM/qwen-code/pull/15538)) by @yiliang114 +- feat(daemon): add cached workspace artifact queries and batch removal ([#15594](https://github.com/QwenLM/qwen-code/pull/15594)) by @BZ-D +- feat(managed-agent): add native snapshots and HTTP recovery ([#15569](https://github.com/QwenLM/qwen-code/pull/15569)) by @doudouOUC +- feat(cli): admit Managed Runtime boot-v2 with external storage and publication surface in container mode ([#15522](https://github.com/QwenLM/qwen-code/pull/15522)) by @yiliang114 +- feat(tools): let an explicitly configured search backend name its host ([#15638](https://github.com/QwenLM/qwen-code/pull/15638)) by @yiliang114 +- feat(cli): align OpenTUI foreground shell controls with Ink ([#15642](https://github.com/QwenLM/qwen-code/pull/15642)) by @chiga0 +- feat(acp): run the tool batches of fresh prompts on the shared agent loop ([#15627](https://github.com/QwenLM/qwen-code/pull/15627)) by @qqqys +- feat(serve): allow secondary sessions to cd within their workspace ([#15323](https://github.com/QwenLM/qwen-code/pull/15323)) by @rockybot2026 +- feat(mods): draw the band above the prompt in the Ink terminal ([#15619](https://github.com/QwenLM/qwen-code/pull/15619)) by @qqqys +- feat(autofix-pr): run an unattended watch's fix agent in the background ([#15578](https://github.com/QwenLM/qwen-code/pull/15578)) by @wenshao +- feat(core): add local managed journal snapshot recovery ([#15626](https://github.com/QwenLM/qwen-code/pull/15626)) by @doudouOUC +- feat(verify-pr): add a local preflight and a verdict check ([#15614](https://github.com/QwenLM/qwen-code/pull/15614)) by @wenshao +- feat(web-shell): show /autofix-pr watches and stop them from the Web Shell ([#15668](https://github.com/QwenLM/qwen-code/pull/15668)) by @wenshao +- feat(mods): compile JSX in .tsx and .jsx Mod modules ([#15624](https://github.com/QwenLM/qwen-code/pull/15624)) by @qqqys +- feat(cli): bind the PR named by a /verify-pr prompt ([#15625](https://github.com/QwenLM/qwen-code/pull/15625)) by @wenshao +- feat(cli): complete OpenTUI extension discovery and source management ([#15671](https://github.com/QwenLM/qwen-code/pull/15671)) by @chiga0 +- feat(managed-agent): add the external-workspace provisioning prerequisite (#15512) ([#15654](https://github.com/QwenLM/qwen-code/pull/15654)) by @yiliang114 +- feat(web-shell): nest derived sessions under their parent in the sidebar ([#15714](https://github.com/QwenLM/qwen-code/pull/15714)) by @wenshao +- feat(web-shell): name new sessions from the first prompt ([#15689](https://github.com/QwenLM/qwen-code/pull/15689)) by @wenshao +- feat(managed-agent): check Harness compatibility, leftover workers and a live service in preflight ([#15564](https://github.com/QwenLM/qwen-code/pull/15564)) by @wenshao +- feat(web-shell): stream BTW answers with reconnect and scroll following ([#15709](https://github.com/QwenLM/qwen-code/pull/15709)) by @ytahdn +- feat(cli): run only the tests related to the diff in review build-test ([#15547](https://github.com/QwenLM/qwen-code/pull/15547)) by @wenshao + +#### Bug Fixes + +- fix(cli): harden serve token resolution against project env injection ([#15228](https://github.com/QwenLM/qwen-code/pull/15228)) by @yiliang114 +- fix(cli): align OpenTUI cancellation and retry with Ink ([#15283](https://github.com/QwenLM/qwen-code/pull/15283)) by @chiga0 +- test(managed-agent): assert web_fetch in the Hosted workspace gate fixture ([#15451](https://github.com/QwenLM/qwen-code/pull/15451)) by @yiliang114 +- fix(vscode): leave edit mode after a refused edit ([#15439](https://github.com/QwenLM/qwen-code/pull/15439)) by @yiliang114 +- fix(rewind): resolve legacy turns by source identity ([#13737](https://github.com/QwenLM/qwen-code/pull/13737)) by @yiliang114 +- Retires registered Linux zombie workers before startup waits, preventing a 30-second delay and returning a prompt terminal conflict instead. ([#15459](https://github.com/QwenLM/qwen-code/pull/15459)) by @doudouOUC +- Preserves arrival order of direct-mode stream-json startup messages so pipelined client requests execute and return results in the intended order. ([#15378](https://github.com/QwenLM/qwen-code/pull/15378)) by @yiliang114 +- Restores managed-agent module and CLI build lanes by correcting the schema inventory column total after new permission-rule columns were added. ([#15455](https://github.com/QwenLM/qwen-code/pull/15455)) by @yiliang114 +- Makes the team-lead Hosted MySQL integration boot retry when the harness loses its reserved port, reducing flaky test timeouts. ([#15224](https://github.com/QwenLM/qwen-code/pull/15224)) by @qwen-code-dev-bot +- CLI checkpoint restores now preserve ownership, Read permissions, and prompt history so cold resume and later rewind behave consistently. ([#15386](https://github.com/QwenLM/qwen-code/pull/15386)) by @chiga0 +- Eligible npm sessions using OpenTUI now run on private Bun 1.4.4 while preserving Node for npm subprocesses and existing encrypted settings. ([#15387](https://github.com/QwenLM/qwen-code/pull/15387)) by @chiga0 +- The main CI failure reporter now files issues even when a newer main run is still pending, adding a provisional note instead of dropping the failure. ([#15471](https://github.com/QwenLM/qwen-code/pull/15471)) by @yiliang114 +- Web Shell permission-rule declarations are now self-contained so package publishing no longer fails on unresolvable core-module imports. ([#15501](https://github.com/QwenLM/qwen-code/pull/15501)) by @yiliang114 +- CLI preserves OpenTUI clipboard source attribution and collapsed large pastes during draft recovery, queue restoration, cancellation, and recall. ([#15396](https://github.com/QwenLM/qwen-code/pull/15396)) by @chiga0 +- Hosted MySQL test isolation preserves the original table-truncation error when foreign-key restore also fails. ([#15505](https://github.com/QwenLM/qwen-code/pull/15505)) by @yiliang114 +- CLI cost estimates no longer double-count reasoning tokens for OpenAI Chat Completions, OpenAI Responses, and Qwen OAuth usage. ([#15464](https://github.com/QwenLM/qwen-code/pull/15464)) by @yiliang114 +- Release automation limits release-note summary requests to three concurrent waves, keeps result order, and refreshes release PRs from main before automated approvals. ([#15500](https://github.com/QwenLM/qwen-code/pull/15500)) by @yiliang114 +- Managed Web Shell prevents duplicate message composers and keeps drafts stable during Session polling or permission-rule rerenders. ([#15496](https://github.com/QwenLM/qwen-code/pull/15496)) by @doudouOUC +- On Linux, Web Shell no longer shows the native directory Browse button when the X11 display is unreachable. ([#15434](https://github.com/QwenLM/qwen-code/pull/15434)) by @yiliang114 +- Raises timeout budgets for the standalone Node/Bun regression suite on the release pool and cleans up outdated exemptions. ([#15102](https://github.com/QwenLM/qwen-code/pull/15102)) by @qwen-code-dev-bot +- Refuses to start pull-request autofix watch from a checkout that cannot run it and reports the required working directory. ([#15506](https://github.com/QwenLM/qwen-code/pull/15506)) by @wenshao +- Simplifies Web Shell status display when loading phrases are hidden and improves status text contrast. ([#15423](https://github.com/QwenLM/qwen-code/pull/15423)) by @deggs7 +- Keeps incremental lint history fetching lightweight and surfaces fallback as a CI warning and step summary. ([#15511](https://github.com/QwenLM/qwen-code/pull/15511)) by @yiliang114 +- Gives eligible main push SHAs separate CI concurrency groups so independent main validations are not displaced by newer pending runs. ([#15508](https://github.com/QwenLM/qwen-code/pull/15508)) by @yiliang114 +- Authenticates managed-runtime OSS preflight checks so release publishing can verify existing artifacts and avoid failed uploads. ([#15504](https://github.com/QwenLM/qwen-code/pull/15504)) by @yiliang114 +- Runs the self-hosted SDK Java test suite before taking the host lock to reduce timeouts and lock contention in CI. ([#15254](https://github.com/QwenLM/qwen-code/pull/15254)) by @qwen-code-dev-bot +- Prevents the main CI failure tracker from filing issues for post-merge PR reruns that do not reflect the merged tree. ([#15250](https://github.com/QwenLM/qwen-code/pull/15250)) by @qwen-code-dev-bot +- Hides unavailable Managed background tools so Background Shell and Monitor appear only when the session can run them. ([#15497](https://github.com/QwenLM/qwen-code/pull/15497)) by @doudouOUC +- Fixes a managed-agent shutdown error caused by lifecycle renewal scheduling. ([#15231](https://github.com/QwenLM/qwen-code/pull/15231)) by @qwen-code-dev-bot +- Unblocks independent same-tenant resident workspaces blocked by an unreclaimed binding and fixes hosted file tool admission. ([#15457](https://github.com/QwenLM/qwen-code/pull/15457)) by @doudouOUC +- In Web Shell standalone sessions, Language: UI is now a user setting, so changing it no longer fails with workspace-scope errors. ([#15521](https://github.com/QwenLM/qwen-code/pull/15521)) by @wenshao +- Failed queued automatic memory extraction now logs a debug warning instead of causing an unhandled rejection. ([#15338](https://github.com/QwenLM/qwen-code/pull/15338)) by @wenshao +- Credential-free release preparation jobs now run on shared ECS runners in the official repository, with existing fallbacks preserved. ([#15517](https://github.com/QwenLM/qwen-code/pull/15517)) by @yiliang114 +- Headless CLI retries now show a retry classification on stderr, making transport failures and restart reasons easier to diagnose. ([#15515](https://github.com/QwenLM/qwen-code/pull/15515)) by @yiliang114 +- In Web Shell, /autofix-pr watches now persist as durable tasks, so they survive idle session reclamation and appear in Scheduled tasks. ([#15523](https://github.com/QwenLM/qwen-code/pull/15523)) by @wenshao +- Deletes a turn's memory catalog snapshot record when the turn is removed, preventing orphaned snapshots while preserving catalog continuity. ([#15540](https://github.com/QwenLM/qwen-code/pull/15540)) by @yiliang114 +- Fixes ARM64 CI lane pricing so self-hosted lanes get correct ceilings: lint 45 to 90 minutes and no-AK integration 30 to 60 minutes. ([#15529](https://github.com/QwenLM/qwen-code/pull/15529)) by @yiliang114 +- Hosted sessions now persist Todo and project committed task lists, showing them in Managed WebShell so later turns use the latest state. ([#15155](https://github.com/QwenLM/qwen-code/pull/15155)) by @yiliang114 +- Incremental lint CI now deepens history until the merge base is provable, reducing unnecessary full-repository scans on long-lived pull requests. ([#15525](https://github.com/QwenLM/qwen-code/pull/15525)) by @yiliang114 +- Incremental Prettier checks now group files by the nearest configuration directory, so formatting rules and ignores apply correctly across touched paths. ([#15531](https://github.com/QwenLM/qwen-code/pull/15531)) by @yiliang114 +- Kubernetes Harness pool now passes qwen serve the managed runtime broker URL and token environment pair, with clearer errors for missing, invalid, or non-HTTPS Broker URLs. ([#15394](https://github.com/QwenLM/qwen-code/pull/15394)) by @wenshao +- The tui-parity Check TUI impact gate now runs on the persistent ECS pool for trusted PRs, while untrusted fork PRs stay on GitHub-hosted runners. ([#15561](https://github.com/QwenLM/qwen-code/pull/15561)) by @yiliang114 +- UserPromptSubmit hook context is no longer duplicated on the next turn after automatic compaction, and compression checkpoints are protected from later rescue mutations. ([#15575](https://github.com/QwenLM/qwen-code/pull/15575)) by @yiliang114 +- The Mod runtime now targets Claude Code 2.1.310, accepts tool.check answers with setting fields, and reports that version from $.session.version(). ([#15560](https://github.com/QwenLM/qwen-code/pull/15560)) by @qqqys +- UserPromptSubmit hook context remains deduplicated after memory catalog snapshots or added message parts, preventing repeated hook context copies in CLI requests. ([#15556](https://github.com/QwenLM/qwen-code/pull/15556)) by @yiliang114 +- fix(ci): resolve the pool's Maven dependencies through a domestic mirror ([#15610](https://github.com/QwenLM/qwen-code/pull/15610)) by @yiliang114 +- fix(ci): stop failing the Maven bootstrap on a rate-limited pool egress ([#15539](https://github.com/QwenLM/qwen-code/pull/15539)) by @yiliang114 +- fix(managed-agent): retry read-only attestation after EOF ([#15608](https://github.com/QwenLM/qwen-code/pull/15608)) by @doudouOUC +- fix(ci): resolve libfaketime for the host architecture ([#15609](https://github.com/QwenLM/qwen-code/pull/15609)) by @yiliang114 +- fix(ci): bound setup-java on the pool and fall back to the tool cache ([#15605](https://github.com/QwenLM/qwen-code/pull/15605)) by @yiliang114 +- fix(web-shell): match cancellation duration to turn summary ([#15615](https://github.com/QwenLM/qwen-code/pull/15615)) by @ytahdn +- fix(web-shell): hide turn navigation without sufficient gutters ([#15604](https://github.com/QwenLM/qwen-code/pull/15604)) by @ytahdn +- fix(core): stop warning about a placeholder in the attribution header ([#15636](https://github.com/QwenLM/qwen-code/pull/15636)) by @yiliang114 +- fix(ci): resolve the pool's Maven dependencies from the intranet mirror ([#15641](https://github.com/QwenLM/qwen-code/pull/15641)) by @yiliang114 +- fix: preserve interleaved reasoning in TUI and Chat replay ([#15621](https://github.com/QwenLM/qwen-code/pull/15621)) by @DragonnZhang +- fix(cli): preserve previews and verify installed OpenTUI distribution ([#15655](https://github.com/QwenLM/qwen-code/pull/15655)) by @chiga0 +- fix(ci): Isolate Managed runtime release test classes ([#15676](https://github.com/QwenLM/qwen-code/pull/15676)) by @doudouOUC +- fix(ci): deepen until the lint scope's own acceptance holds ([#15644](https://github.com/QwenLM/qwen-code/pull/15644)) by @yiliang114 +- fix(release): extend standalone runtime download retries ([#15673](https://github.com/QwenLM/qwen-code/pull/15673)) by @qwen-code-dev-bot +- fix(ci): preserve setup-java JDKs during pool fallback ([#15623](https://github.com/QwenLM/qwen-code/pull/15623)) by @yiliang114 +- fix(ci): keep the sdk-java cache write-back on a failed suite ([#15555](https://github.com/QwenLM/qwen-code/pull/15555)) by @wenshao +- fix(serve): Fix Hosted local state and foreground Shell validation ([#15670](https://github.com/QwenLM/qwen-code/pull/15670)) by @doudouOUC +- fix(release): close the failure issue once a re-run passes ([#15711](https://github.com/QwenLM/qwen-code/pull/15711)) by @yiliang114 +- fix(ci): refresh the stale ci.yml workflow-size baseline ([#15717](https://github.com/QwenLM/qwen-code/pull/15717)) by @wenshao +- fix(web-shell): keep session rename responsive ([#15688](https://github.com/QwenLM/qwen-code/pull/15688)) by @wenshao +- fix(cli): preserve OpenTUI extension update state ([#15716](https://github.com/QwenLM/qwen-code/pull/15716)) by @chiga0 +- fix(ci): Preserve canonical artifacts across job retries ([#15713](https://github.com/QwenLM/qwen-code/pull/15713)) by @doudouOUC + +#### Performance + +- Runs web-shell and standalone tests concurrently with the main CLI test suite to shorten required Linux CI time. ([#15438](https://github.com/QwenLM/qwen-code/pull/15438)) by @wenshao +- Keeps unchanged legacy memory catalog snapshots stable in history to improve DashScope cache reuse, resume, and retry behavior. ([#15246](https://github.com/QwenLM/qwen-code/pull/15246)) by @yiliang114 + +#### Documentation + +- Updates scheduled-task gate examples so copied first-run commands include required repository and PR options. ([#15452](https://github.com/QwenLM/qwen-code/pull/15452)) by @sxl-lucky7 +- docs(design): external Hosted Workspace profile (phase 2) ([#15520](https://github.com/QwenLM/qwen-code/pull/15520)) by @yiliang114 +- docs(managed): describe gate scanner capacity under the row pool ([#15571](https://github.com/QwenLM/qwen-code/pull/15571)) by @wenshao +- docs: clarify usage statistics across authentication methods ([#15554](https://github.com/QwenLM/qwen-code/pull/15554)) by @sxl-lucky7 +- docs(qwencloud): document the forced attribution header and pin the admission split ([#15633](https://github.com/QwenLM/qwen-code/pull/15633)) by @yiliang114 + +#### Internal Changes + +- test(cli): cover remaining Managed Workspace activation refusals ([#15460](https://github.com/QwenLM/qwen-code/pull/15460)) by @doudouOUC +- Corrects the managed-agent CSI inventory column count so test suites pass after permission-rule columns were added. ([#15469](https://github.com/QwenLM/qwen-code/pull/15469)) by @yiliang114 +- test(ci): pin the disk-pressure collector revision and FULL_PAYLOAD run bodies ([#15475](https://github.com/QwenLM/qwen-code/pull/15475)) by @yiliang114 +- Adds an integration test verifying the removeScratchDir retry budget removes busy scratch directories without logging cleanup warnings. ([#15488](https://github.com/QwenLM/qwen-code/pull/15488)) by @yiliang114 +- Tests now capture expected disk-full log output so real CI disk-full failures are easier to distinguish from deliberate test errors. ([#8549](https://github.com/QwenLM/qwen-code/pull/8549)) by @EthDing +- Managed Agent tests now wait for a dead worker to become unusable before checking lease identity reuse or reconciling its loss. ([#15503](https://github.com/QwenLM/qwen-code/pull/15503)) by @yiliang114 +- SDK Java CI retains Surefire and Failsafe failure reports and preserves original SQL causes in channel ownership race tests. ([#14910](https://github.com/QwenLM/qwen-code/pull/14910)) by @Aling33 +- Adds CI regression coverage to keep JUnit report paths where CI collects them. ([#15502](https://github.com/QwenLM/qwen-code/pull/15502)) by @barry166 +- Applies the slow-host policy to hosted approval test waits so slow runners get longer approval-test timeouts. ([#15183](https://github.com/QwenLM/qwen-code/pull/15183)) by @qwen-code-dev-bot +- Adjusts hosted approval test waits based on runner load policies to improve CI reliability on slower hosts. ([#15101](https://github.com/QwenLM/qwen-code/pull/15101)) by @qwen-code-dev-bot +- Adds automated checks for memory recovery and hard-rescue catalog accounting to prevent regressions. ([#15535](https://github.com/QwenLM/qwen-code/pull/15535)) by @yiliang114 +- Adds automated checks for managed-agent row pool fan-out and corrects design documentation on shutdown and task limits. ([#15528](https://github.com/QwenLM/qwen-code/pull/15528)) by @yiliang114 +- Adds a repeatable Harness Kubernetes qualification workflow for deploying, verifying, and exercising shared pools. ([#15519](https://github.com/QwenLM/qwen-code/pull/15519)) by @doudouOUC +- test(cli): pin the lifecycle-protocol refusal on the load route ([#15536](https://github.com/QwenLM/qwen-code/pull/15536)) by @yiliang114 +- Adds tests verifying the lint --setup guard installs only required linters and installs nothing when no lane is selected. ([#15533](https://github.com/QwenLM/qwen-code/pull/15533)) by @yiliang114 +- Consolidates catalog visibility rules so curated history reads consistently hide catalog snapshots when legacy memory is suppressed. ([#15534](https://github.com/QwenLM/qwen-code/pull/15534)) by @yiliang114 +- Centralizes QwenCloud MaaS host list usage for telemetry classification and DashScope admission to keep host handling consistent. ([#15532](https://github.com/QwenLM/qwen-code/pull/15532)) by @yiliang114 +- Hosted harness tests now use longer adaptive wait budgets for interrupted-model checks, reducing flaky timeouts without changing asserted behavior. ([#15551](https://github.com/QwenLM/qwen-code/pull/15551)) by @yiliang114 +- test(managed-agent): align S1 acceptance fixtures with runtime contracts ([#15585](https://github.com/QwenLM/qwen-code/pull/15585)) by @doudouOUC +- Managed Agent workspace upload admission tests now use valid binary upload parameters and upload-status queries, and expect precise not-found errors for unknown uploads. ([#15588](https://github.com/QwenLM/qwen-code/pull/15588)) by @doudouOUC +- test(runtime-broker): build the extension-profile fixture paths portably ([#15598](https://github.com/QwenLM/qwen-code/pull/15598)) by @yiliang114 +- test(cli): budget the skill-recovery poll through the settle policy ([#15589](https://github.com/QwenLM/qwen-code/pull/15589)) by @yiliang114 +- test(lint): pin the .gitignore grouping arm and the batch stride ([#15640](https://github.com/QwenLM/qwen-code/pull/15640)) by @yiliang114 +- Pin unknown sed long options ([#15223](https://github.com/QwenLM/qwen-code/pull/15223)) by @waybarrios +- ci: run every suite but CLI and core in the side test lane ([#15570](https://github.com/QwenLM/qwen-code/pull/15570)) by @wenshao +- test(cli): read xn-- hosts by the running URL parser in the Broker URL fixture ([#15544](https://github.com/QwenLM/qwen-code/pull/15544)) by @wenshao +- test(integration): widen the ready-prompt budget on GitHub-hosted macOS x64 ([#15692](https://github.com/QwenLM/qwen-code/pull/15692)) by @yiliang114 +- chore(goal): remove an unused evidence helper and stale /goal hook comments ([#15693](https://github.com/QwenLM/qwen-code/pull/15693)) by @qqqys +- test(web-shell): raise the 200-record history-viewport smoke budget to 300s ([#15656](https://github.com/QwenLM/qwen-code/pull/15656)) by @yiliang114 +- refactor(managed-agent): close the scanner row-pool residues (#15527) ([#15632](https://github.com/QwenLM/qwen-code/pull/15632)) by @yiliang114 +- test(managed-agent): initialize the snapshot pool connection synchronously ([#15715](https://github.com/QwenLM/qwen-code/pull/15715)) by @doudouOUC +- test(cli): close the deferred review Suggestions from #15625 ([#15710](https://github.com/QwenLM/qwen-code/pull/15710)) by @wenshao + +### New Contributors + +- @sxl-lucky7 made their first contribution in [#15452](https://github.com/QwenLM/qwen-code/pull/15452) +- @EthDing made their first contribution in [#8549](https://github.com/QwenLM/qwen-code/pull/8549) +- @Aling33 made their first contribution in [#14910](https://github.com/QwenLM/qwen-code/pull/14910) + +**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.26.1...v0.26.2 + ## [0.26.1](https://github.com/QwenLM/qwen-code/releases/tag/v0.26.1) - 2026-10-24 ### Highlights From 35fd1f44ff58b29d4e9eaf1b6f1e5472b6d3de20 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 29 Oct 2026 02:58:31 +0800 Subject: [PATCH 005/166] feat(ci): bound autofix takeover with a round budget (#15388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): bound autofix takeover with a round budget `@qwen-code /takeover` engages a persistent loop capped at 100 rounds, so a maintainer who wants one bounded pass has to spend the whole cap in a single Critical-only round and accept a "reached its round cap" pause, or watch the run and stop it by hand. Add ` for N` (1-99), which engages takeover and records a window-scoped round budget on the acknowledge comment. The scan lowers the effective cap to N for that window; once the budget is spent, a human-authored PR is released — one bilingual note, the label removed, no needs-human escalation — while a bot-authored PR keeps the paused path, since releasing it would hand the PR back to standard management. Failed, timed-out and crashed rounds consume budget, so N means N attempts. The workflow sits against its 470 KB start-runs gate, so the duplicated env commentary moves into the rationale record (af-160), leaving a one-line summary plus a pointer per constant. * fix(ci): count attempts and keep breaker stops in the takeover budget Three review findings on the round budget: - A no-op round writes its eval marker with the round UNCHANGED, so a budget riding on ROUND never spent for a PR whose rounds kept finding nothing — with `for 1` it stayed managed indefinitely. Spend is now the number of eval markers in the window: one per attempt, no-op included. - The same reading separates a breaker stop from real exhaustion: a breaker stamps its terminal marker at round=MAX_ROUNDS after far fewer attempts, and that sentinel jump adds no marker, so a twenty-round window that hit five failures stays paused and escalated instead of being released with a "nothing is waiting" note. - A queued conflict round carries the OLD cap, and conflicts were exempt from the superseded-window check, so after a smaller re-arm it could run past the new limit. A budgeted live window makes it stale too; the next scan re-selects the conflict under the new window. The workflow sits on its 470 KB gate, so the env commentary these reads pushed past the limit moves into the rationale record the same way as af-160. * fix(ci): keep the budget bounded across audit re-arms and queued rounds Two review findings on the round budget: - The growth audit appends `` to open a fresh window for its own round accounting. A window opened that way carries no budget marker, so an automatic re-arm turned a `for 2` engagement back into a 100-round one with no maintainer asking. The report script now skips that automatic re-arm while the current window is budgeted, so the audit round stays inside the budget it was given. Both copies of the helper (script and the failure-path inline one) change together, which the existing equality test pins. - A queued round can outlive the sibling that spent the window's last attempt: a no-op leaves ROUND at 0 and the window key unchanged, so neither the superseded-window check nor the round cap discarded it and with fresh feedback it ran one more attempt. `review-address` now recounts the window's eval markers and discards on a spent budget. The new tests also caught a real defect in the first draft of the re-arm guard: a `[[ ... ]] && echo` as the function's last command returned 1 under `set -e` and would have aborted the report. * fix(ci): scope the budget to the engagement, not the round window Third review round. Three correctness findings, all real: - Suppressing the growth-audit re-arm (the previous round's fix) also froze the growth baseline: it is the only automatic mover of that key, so after one over-budget round every later round recomputed growth against the pinned baseline and went Critical-only. The re-arm is unconditional again. The budget survives it because budget and spend are now read per ENGAGEMENT: a maintainer command opens one, while an automatic re-arm (whose comment also carries that round's eval marker) does not. - The attempt census was a raw marker count with no identity, so a GitHub "Re-run failed jobs" re-posted a round's report and charged that round twice — releasing the PR early. Attempts are counted as distinct (ts, round) markers in both the scan and prepare. - The release fired on a spent budget alone, so a circuit breaker that tripped exactly on the last budgeted attempt was reported as a finished engagement with "nothing here is waiting on a human". A trailing un-evaluated-failure streak of at least TIMEOUT_WINDOW_CAP now skips the release and lands on the paused path with autofix/needs-human; a provenance-verified forced dispatch likewise falls through to its own refusal instead of being preempted. Design decision 7, its Chinese twin and the operator guide now describe the carry-forward instead of the suppression. * fix(ci): end the budget with the engagement that opened it A spent budget marker outlived the label it belonged to. `stop` posts a `takeover-ack released` ack, which is not one of the window-key anchors, so the marker kept reading back: on a bot-authored PR the scan re-entered the cap branch on every tick and re-applied `autofix/needs-human` at N/N, and a hand-removed label had the same effect. The budget is now honoured only while `autofix/takeover` is present, which ends it in both cases. The regression harnesses now set HAS_TAKEOVER for the managed case. * fix(ci): repair the two checks the last commit broke Both failures were introduced by the label-gate commit and are now fixed: - The pointer suite stopped recognising `autofix.yml`'s env pointers because they had been shortened to `→ af-NNN`. Restored the full `→ qwen-autofix.md#af-NNN` form the suite matches, so every section is pointed at again. - `HAS_TAKEOVER` was read unguarded while a pre-existing replay harness runs its shell with `set -u`, which aborted that harness with "unbound variable". The read is `${HAS_TAKEOVER:-false}` now: callers that never set it keep the pre-budget behaviour. Verified locally: 72 focused workflow/size cases pass; the file is 469,982 bytes, under the 470,000 gate. * fix(ci): attribute budget attempts to the window that dispatched them The engagement census filtered markers by publication time (`.at >= floor`), so a round dispatched under the previous engagement and posted after a fresh `for N` ack was charged to the new allowance: `for 1` could be spent before its own work ran. Markers carry the window they ran under, so the census now selects `.win >= floor` (and still skips the legacy `win=none` markers), which associates an attempt with its originating engagement while keeping the automatic child windows of that engagement inside it. Verified: 73 focused workflow/size cases pass; 469,943 bytes. * test(ci): pin the budget's lifetime to the takeover label Audit finding: the label gate that ends a budget when `autofix/takeover` goes away had no regression. The budget-read harness now takes the label state as a parameter, and a fixture asserts that the same anchor yields the takeover cap (100) once the label is absent — the shape that used to re-park a bot-authored PR at N/N with autofix/needs-human on every scan. * fix(ci): read the carried budget per engagement in prepare too `review-address` still resolved `LIVE_ROUND_BUDGET` from the latest growth window, so after an audit re-arm a queued task read `live_budget=0` and the attempt cap could not see the engagement's spend. The leg now uses the scan's engagement scope: a maintainer command opens one, an automatic re-arm (whose comment also carries an eval marker) does not. That also makes the superseded-conflict clause see the real budget instead of the audit window's empty one. * docs(ci): align the budget record with what the loop now does Three claims had drifted from the implementation: the effective-cap bullet said nothing else reads a budget (prepare and the report script both do), the growth-audit paragraph still described the suppressed re-arm (it is unconditional; budget and spend are read per engagement), and the queued-round paragraph said a no-op leaves the round "at 0" (it leaves it UNCHANGED). * fix(ci): let the shepherd see the parameterized takeover commands The auto-release lever builds its resume candidates by exact body match against the bare takeover and retry commands, so a paused PR re-engaged with `@qwen-code /takeover for 2` (or `from N`) matched neither and the release could fire after the pause window despite a fresh engagement. The predicate now also accepts the two parameterized forms as prefixes of the configured command, interpolating TAKEOVER_COMMAND rather than adding a literal. * docs(ci): name every site that carries budget logic in af-159 The site index listed the three original steps while the prepare leg and the report script's guard read and enforce the same budget. * docs(ci): name each af-160 entry by the constant it records The section's own preamble promises that every entry names the constant it documents; none did, so a maintainer following an env pointer had to read all eight to find the one they came for. Each header now leads with its constant. The command entry also drops its "ONE parameterized form" claim, which this PR's second form falsified, and names both. * docs(ci): nest the seed sections under their own heading The guide was retitled for both forms, but the seed's sections stayed at H2, so a document-wide reading of 'does not shorten the round cap' sat directly above the form that does. The seed's sections are now nested and its Picking N no longer collides with the budget one's anchor. * docs(ci): complete the budget record's inventory and risk list The files-affected inventory omitted the report script and the prepare leg's reads, and the risk list only covered the DELETE-failure direction. Both now name the release's actual shape: the label gate ends the budget, and the note-then-DELETE order means a failed note still releases. * docs(ci): bring the Chinese budget record in line with the English one The previous commit's script aborted after writing only the English file, so the pair disagreed. This lands the same inventory and risk additions in zh-CN. * fix(ci): keep the budget release note ahead of the label, and read dispatch identity correctly The release note is the only terminal comment the budget path ever posts; once the label is gone the PR is in neither selection pool, so a failed post could never be retried. Skip the DELETE when the note fails, so the next scan retries both. The release gate keyed on FORCED_PR being empty, but route publishes pr_number for every targeted event, not just workflow_dispatch. A pull_request or pull_request_review run that reached the spend-to-release window therefore fell through to the round-cap copy and applied autofix/needs-human to a PR that hit no cap. Test the dispatch identity the way the sibling refusal gate does. * docs(ci): correct the budget record against the shipped mechanism The record and the guide disagreed with the code in four ways, all of them in the direction that misleads whoever debugs it next: - the budget is read per engagement (the eval-marker exclusion is what tells a command from the automatic re-arm), not by window-key equality; - the re-arm is not the only thing that re-anchors the growth baseline — a base-branch update and the scan's heal ack do too, so the exclusivity claim is gone while the per-engagement conclusion stays; - a cancelled run that reaches the finalizer DOES spend an attempt, so the error direction is an early release, not a late one; - a crash, timeout or breaker stamps the cap, ending a budgeted window at once rather than spending the rest of the budget. Also: the label — not the command — is what engages management (a hand-applied label cannot be bounded), the 470 KB number is this repo's gate rather than GitHub's limit, /retry does nothing on a released human PR, and the queued conflict discard is this change's new clause rather than the window-key check. af-160 gains the TRUSTED_ASSOC entry the prose move deleted, af-007 names both parameterized readers, and the guide index advertises both forms. * docs(ci): label the takeover input by its scope, not by a window * test(ci): stop calling the seed the one parameterized form * fix(ci): read prepare's budget only while the takeover label is present The scan zeroes a spent budget once the label is gone, but prepare still read it from the comment history alone. A bot-authored PR whose label was removed after its budget ran out was therefore dispatched by the scan and discarded by prepare on every tick, with no marker written - a livelock rather than a release. Gate the read the same way the scan does, on the same live label re-read prepare already performs. * fix(ci): count attempts per run, park breaker stops, keep a spent budget The budget census keyed on `ts|round`, so two executions reporting the same watermark and round — the conflict no-op, which re-dispatches every tick — collapsed into one attempt and a for-N engagement could never spend. The eval marker now carries `run=GITHUB_RUN_ID`, the dedup identity the growth marker already uses, and both censuses key on it with `ts|round` as the legacy fallback, so a re-run of one run still charges once. A breaker stop also spent the budget on its way out. The consecutive-failure breaker jumps the round to MAX_ROUNDS with a real timestamp, the cumulative timeout breaker counts timeouts across the window with pushes between them, and the convergence breaker only pushes — none leaves the trailing sentinel run the release gate tested. The gate now reads BUDGET_HALTED: a sentinel anywhere in the engagement, a round past ROUND_START + attempts, or a convergence-break notice newer than the window key all park the PR instead of releasing it. Finally, the shepherd's three-day auto-release strips the label from a paused bot PR. The budget used to die with it, dropping the effective cap back to 10 and re-selecting the PR for rounds N+1..10. A spent budget now survives for a bot-authored engagement; an unspent one still dies with the label, and a human release keeps no cap at all. * test(ci): pin the budget's lower-only clamp and both census filters The cap clamp's -lt half was exercised by no fixture: the replay hardcoded EFF_MAX_ROUNDS=100 and every budget is <=99, so deleting the comparison left the suite green while a stale marker could raise a strict cap. Parameterize the incoming cap and replay a 20-round marker against a 10-round cap. The census filters were equally unpinned: BUDGET_FLOOR was frozen at '' and no fixture carried win=none, so dropping either clause stayed green. jq compares window keys as strings, so 'none' sorts above every ISO timestamp and only the win != none clause keeps pre-takeover rounds out. Thread the floor through and add the two cases plus an in-engagement one. * chore(ci): re-baseline after merging main * fix(ci): describe a budget stop as a budget, and record the release only once it happens Two operator-facing copies lied at the boundary. A bot-authored PR whose BUDGET_SPENT brought it there fell through to the round-cap body and printed 'hit the round cap (0/3)' - a round count of zero against a cap of three, on a PR that never reached a cap. It now gets its own line and fleet state naming the attempts used. The fleet row claiming a release was written before the block could skip it five ways (dry run, unreadable labels, changed consent, PAT identity, failed DELETE), so a dry-run scan reported every budgeted PR as released. The row now follows the DELETE that actually happened. Reclaimed the bytes from three pointer comments whose rationale lives in their sections (af-045, af-118, af-120). * test(ci): replay the budget boundary end to end, bot path included The release harness stubbed fleet_row to a no-op and extracted from the inner if, so neither the fleet row nor the bot-authored half of the boundary was executed: a dry-run scan could report a release it never performed, and a restructure that stopped the bot check reaching the branch would release a bot PR's label with the suite green. The stub now records rows, every skipped arm asserts no release row, and a second replay runs the whole outer block with a bot author: no DELETE, and the notice names the budget instead of a round cap the PR never reached. The outer entry condition is replayed on its own for the no-op-only window, whose ROUND never advances past 0 - the only reason a for-2 PR that found nothing twice can still end. * fix(ci): state the ack's one exception instead of promising an unconditional release The budget ack said 'the loop removes the label' for every reader, but a bot-authored PR keeps the label and pauses for a human - the exact case a maintainer reading their own ack would mispredict. Both languages now carry the exception in the same sentence, so the ack is accurate whichever PR it lands on without a second authorship read at the ack site. * test(ci): feed the parameterized commands to the shepherd's resume detector The widened matcher was exercised by nothing: deleting either startswith arm kept both suites green, and the anti-drift cross-check pins only the two exact forms. A future grammar change would leave the shepherd blind between a maintainer's command and the ack landing, auto-releasing a just-re-engaged PR - the regression the cross-check exists to prevent. Add the two forms plus a near-miss that must not count. * fix(ci): match the shepherds resume commands as strictly as the router does The startswith arms accepted bodies the router rejects - for 0, for 100, for 3 please, from 3 for 2 - so a command that did nothing (no label, no ack, no log line) vetoed the auto-release for the whole grace window, renewable by any further malformed variant, and three such bodies from distinct authors in one window tripped the two-read budget into a permission-fault deferral. The matcher now strips the prefix and requires a 1-2 digit tail, with the for-arm also requiring a value >= 1 - the same grammar the parser enforces, including leading zeros read as decimal. * docs(ci): correct the prose move's warranty and the inventory's map af-160 claimed the block was moved verbatim, but the TAKEOVER_COMMAND entry is the one rewrite - and the rewrite had dropped the base's assurance that the captured value reaches nothing but an integer comparison. The preamble now names that exception and the entry restores the clause. The inventory named the report script, which this change does not touch, and omitted the fleet shepherd, which it does; the scope boundary repeated the omission. af-159's site index pointed at the script instead of the shepherd's resume detector, and still credited the script with carrying the budget. * test(ci): pin each language of the budget ack separately Both ack bodies are single printfs with positional arguments, and the only assertions on the budget copy searched the whole posted body - so the exact mutation this file documents elsewhere (swapping the EN/ZH arguments, shipping the Chinese sentence inside the English paragraph) stayed green. Split at the details summary and assert each language names its own sentence, for both the engage and the re-arm ack. * test(ci): replay the prepare budget reads instead of injecting them Both discard tests handed LIVE_ROUND_BUDGET and LIVE_ATTEMPTS in as literals, so the two jq programs decision 8 rests on were executed by nothing: a wrong window key or a dropped marker scan stayed green while a queued round either ran past the budget or was discarded on an unbudgeted PR. Replay both reads against a real census: a budgeted ack plus an eval-carrying re-arm must still yield the budget (the re-arm does not open an engagement), a human release must zero it, and a spent budget on a bot PR must survive that removal. * test(ci): read the report script through the module constant The re-arm test resolved the script path a second way (resolve/dirname/ fileURLToPath) where ~35 sibling sites use the module-scope read, so renaming or moving the script meant finding a second spelling whose stale literal would only surface as a lone ENOENT in this test. * fix(ci): keep legacy eval markers distinct and migrate the last reader Two defects in the run= marker format, both from readers that assumed the old shape: - The marker builder stored an absent run= as "", and jq's `//` does NOT fall back for an empty string, so two DIFFERENT pre-change reports collapsed onto one dedup key: a for-2 window under-counted its attempts and could run one more round. Both builders now keep a missing run null, so `//` falls back to the ts|round key as intended, and a replay of two distinct legacy reports asserts 2 (a re-posted one still asserts 1). - OVER_BUDGET_AUTHORS still required ` -->` immediately after the optional win=, so it rejected every new report and left the per-maintainer regular- feedback batch limit disabled during Critical-only rounds. Its scan now accepts the optional run= field like the other readers. * test(ci): pin the eval marker's run field where the assertions read it Appending `run=${GITHUB_RUN_ID}` to the eval marker moved five expectations out from under the tests: the report step's terminal-handoff marker and the report script's two pushed/no-op markers. The assertions still pinned the pre-`run=` text, so `Test (ubuntu-latest)` failed on `posts a human-handoff marker…` and `stamps the push marker…` while the writers were correct. The markers themselves are unchanged — only the expected literals now carry the field the writers emit. * fix(ci): let an explicit release close the budget, and keep idle wedges out of the halt Two defects at the engagement floor, both reported against the shipped code: - The spent-budget survival clause read label absence plus bot authorship, so it could not tell the shepherd's silent removal from a maintainer's `stop` or a hand-removal. A bot PR that spent `for 2` stayed parked at 2/2 after `stop`: the release leg was skipped by the bot check and the paused leg re-applied needs-human, contradicting the ack's "standard management continues". The floor now treats `takeover-ack released` as a boundary, so an explicit release ends the engagement while a silent removal still keeps a spent budget. - The halt census counted idle (silent-sandbox) sentinels, which the cumulative breaker deliberately excludes, so three infra wedges parked a bounded engagement instead of releasing it. Their runs are mapped from the idle headlines and subtracted, mirroring the breaker's own `TIMEOUT_N - IDLE_N`. Reclaimed ~2 KB from three pointer comments (af-155, af-149, af-152) whose rationale stays in their sections, taking the file from 113 bytes of headroom to 1,451. * docs(ci): state the budget's real lifetime, halt thresholds and prose warranty Four records told the operator the opposite of what shipped: - the design record (both languages) and af-159 said the shepherd's auto-release ends the budget and a paused bot PR returns to ten-round management. A SPENT budget now survives any removal that leaves no `takeover-ack released` marker, and only an explicit `stop` posts one — so `stop` is the release that ends it. - af-159 and the guide said any sentinel halts the release, and that the first crashed or timed-out round parks the PR. The halt needs TIMEOUT_WINDOW_CAP (3) sentinels (idle ones subtracted), or a round past ROUND_START + attempts, or a convergence notice; one crash only spends its attempt. - af-160's warranty promised ONE exception; TRUSTED_ASSOC is a second, now marked at its entry and counted in the preamble. - af-157's quoted block had lost its first line, leaving an orphaned fragment under a heading that still quotes it. The pointer convention now says a summary line may be rewritten once its block was compressed (the section is authoritative), and af-159 points at the growth-audit record for the re-arm invariants it depends on. * test(ci): round-trip the run field and witness both release suppressors Two gaps the last round opened: - The `run=` format was pinned only on the writer side. Both builders now get a round-trip fixture: a body carrying `run=1111` parses round, window and run through the real jq, a legacy body still parses round and window with run absent (null, not ""), and the census keeps two distinct runs apart. Dropping the optional group from either reader now reds it instead of silently emptying the census. - The release gate's two suppressors had no failing witness: `runBoundary` hardcoded `BUDGET_HALTED: 'false'` and `runRelease` omitted the variable. Both are parameterised now, with a halted engagement and a real forced dispatch asserting no label DELETE, a passive event carrying the same pr_number still releasing, and `runRelease` passing the literal production always has. The halt census also gains its false side: one sentinel is not a halt (the threshold is TIMEOUT_WINDOW_CAP), and a convergence notice older than the window key is not one either. * fix(ci): retry the handoff post once The handoff comment is the only carrier for a round that errors, times out or crashes: its gate excludes fixed/noop rounds, and the always() finalizer only patches the status comment. It posted single-shot, so one transient 5xx during a comment-API blip left the round uncounted — the census did not move and the next scan re-dispatched the same round, a free attempt. It now retries once, five seconds apart, like the re-arm ack. A sustained outage still costs the attempt, which the guide already states: a lost marker delays the release, it never ends the engagement early. * test(ci): watch the seed beside the budget, and both notice-dedup filters Three assertions that could not fail before: - the route replay printed only TAKEOVER_CMD and TAKEOVER_FOR, so "the two forms never co-emit" was pinned in one direction only. It now prints the seed too: `for N` must leave the seed empty and `from N` the budget. - the budget-spent dedup fixture was newer AND bot-authored, so dropping either filter stayed green. A human-quoted marker and an older bot notice must both fail to suppress the note now. - the growth marker's window prefers GROWTH_BASE_WIN over WINDOW; every existing case passed the same value for both, so swapping them was invisible. * docs(ci): record what happens when a breaker meets the spending round The boundary table covered a breaker that stops the loop BEFORE the budget is spent and said nothing about its condition being met exactly on the attempt that reaches N. That attempt is already counted, so the PR takes the release path and no breaker diagnosis is posted: the consecutive-failure gate cannot fire on the cap round. Stated in both languages rather than left to be inferred from code. * test(ci): witness a failed label DELETE without aborting the scan The release harness's gh stub exited 0 on every write, so the DELETE's failure arm was dead in the suite: unwrapping the 'if !' would have shipped green, and in production one transient 502 would abort the scan job — costing every other PR in that tick its round. The stub can now fail a label write; the case asserts the warning, no fleet row claiming a release, and that the block still reaches its continue. * test(ci): inline the round-trip inspection command A single-use constant left over from iterating on the fixture; the comment above it already says why the command is what it is. * docs(ci): finish removing the claim that the budget ends with the label Two sites still carried the old rule. The guide told the operator that the shepherd's auto-release returns a paused bot PR to ten-round management, and af-160's TAKEOVER_MAX_ROUNDS entry promised that removing the label restores the strict cap — which a still-live for-N budget keeps clamping, exactly in the spent-bot case af-159 now documents. Both now say what the code does. Found on an audit pass over this round's diff; no behaviour change. * docs(ci): list the released ack among the floor's boundaries Decision 2 enumerated two of the three markers that move the engagement floor; the explicit-release marker, which is what tells `stop` apart from a silent removal, was missing in both languages. * fix(ci): clear the escalation a deferred budget release applied A forced dispatch that meets a spent budget answers the dispatch on the paused path, so the release lands one scan later — and left the needs-human label that pause applied on a PR whose release note says nothing is waiting on a human. The release now deletes it too, 404 tolerant and only after the takeover DELETE landed. The notices on that deferred pass quote the attempts the budget counts: a no-op-only engagement reaches the gate at round 0, and 0/N named a cap the PR never reached. * docs(ci): record the forced-dispatch deferral of a budget release The guide, af-159 and both design languages promised an unconditional release with no escalation label. A forced dispatch is the one pass that cannot release, so the deferral, the cleanup it now owes, and the attempts numerator are written down where a maintainer reads them. * docs(ci): stop claiming the breaker is the only thing that withholds a release The guide said the release is withheld ONLY on the three breaker thresholds, and read any pause as 'a human should look'. A forced dispatch withholds it too, for one scan, and that pause means the budget finished. Both design languages lose the same exclusivity claim. * docs(ci): spell out the label pointer like every other one The compressed TAKEOVER/SKIP comment kept a bare 'af-160' while the other 184 pointers in the file name the record they live in. --- .github/scripts/autofix-push-and-report.sh | 10 +- .github/workflows/.size-baseline | 2 +- .github/workflows/qwen-autofix-round-seed.md | 174 ++- .github/workflows/qwen-autofix.md | 278 +++- .github/workflows/qwen-autofix.yml | 411 +++--- .github/workflows/qwen-fleet-shepherd.yml | 8 +- ...026-10-24-autofix-takeover-round-budget.md | 236 +++ ...-24-autofix-takeover-round-budget.zh-CN.md | 101 ++ scripts/tests/qwen-autofix-workflow.test.js | 1314 ++++++++++++++++- .../qwen-fleet-shepherd-workflow.test.js | 26 + 10 files changed, 2280 insertions(+), 280 deletions(-) create mode 100644 docs/design/2026-10-24-autofix-takeover-round-budget.md create mode 100644 docs/design/2026-10-24-autofix-takeover-round-budget.zh-CN.md diff --git a/.github/scripts/autofix-push-and-report.sh b/.github/scripts/autofix-push-and-report.sh index cb55640a272..2382acc2d95 100755 --- a/.github/scripts/autofix-push-and-report.sh +++ b/.github/scripts/autofix-push-and-report.sh @@ -679,7 +679,7 @@ if [[ "${OUTCOME}" == "fixed" ]]; then echo "---" echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}" echo - echo "" + echo "" echo "" # Regression accounting (af-157). Two distinct facts, both keyed to the # eval marker's window so the consecutive-failure walk reads them with @@ -742,7 +742,7 @@ else echo "---" echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}" echo - echo "" + echo "" echo "" # No autofix-push marker: a no-op round pushed nothing that could # regress. The regression it OBSERVED still has to be recorded here. @@ -798,9 +798,9 @@ for attempt in 1 2 3; do # no number makes the NEXT round reuse it (the brake's # regression-round walk then charges two rounds as one). if [[ -n "${PUSHED_HEAD:-}" ]]; then - echo "" + echo "" else - echo "" + echo "" fi if [[ -n "${PUSHED_HEAD:-}" ]]; then echo "" @@ -838,7 +838,7 @@ if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]] [.[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) ] | map(.[0] // "none")) as $wins + ([ ((.body // "") | scan("")) ] | map(.[0] // "none")) as $wins | ($wins | length) > 0 and (($wins | last) == $win))] | sort_by(.created_at) | .[] | (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)" diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index aa5296c5596..2f30371f6cc 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -39,7 +39,7 @@ 6495 pr-self-report-label.yml 9646 qwen-autofix-fork-bridge.yml 5942 qwen-autofix-fork-signal.yml -466739 qwen-autofix.yml +469584 qwen-autofix.yml 7061 qwen-ci-flaky-rerun.yml 272192 qwen-code-pr-review.yml 107075 qwen-fleet-shepherd.yml diff --git a/.github/workflows/qwen-autofix-round-seed.md b/.github/workflows/qwen-autofix-round-seed.md index da417f27453..31146e6fa99 100644 --- a/.github/workflows/qwen-autofix-round-seed.md +++ b/.github/workflows/qwen-autofix-round-seed.md @@ -1,11 +1,15 @@ -# Round seeding — `@qwen-code /takeover from N` +# Parameterized takeover commands -Operator guide for the one parameterized takeover command. For the -implementation rationale behind each gate, see the design record: +Operator guide for the two parameterized takeover forms — `from N` (round seed, +below) and `for N` (round budget, further down). For the implementation +rationale behind each gate, see the design record: [`qwen-autofix.md`](./qwen-autofix.md) — [af-007](./qwen-autofix.md#af-007) -(the parser) and [af-016](./qwen-autofix.md#af-016) (the marker). +(the parser), [af-016](./qwen-autofix.md#af-016) (the seed marker) and +[af-159](./qwen-autofix.md#af-159) (the budget). -## The problem it solves +## Round seed — `@qwen-code /takeover from N` + +### The problem it solves The autofix loop stops implementing non-Critical feedback once a PR has completed `CRITICAL_ONLY_AFTER_ROUND` (5) change-producing rounds. From there @@ -22,7 +26,7 @@ diff on nice-to-haves at exactly the point it should have been converging. `from N` lets you state what the loop cannot infer: how much review this PR has already been through. -## Usage +### Usage ``` @qwen-code /takeover from 4 @@ -49,7 +53,7 @@ The same refusals apply — a non-`main` base, `autofix/skip`, a fork without maintainer-edit access, and a fork whose author lacks write+ on this repository are all declined out loud. -### Picking N +#### Picking N (seed) N is "how many review rounds has this PR already had", counted the way the loop counts: **rounds that produced changes**, not individual comments or reviews. A @@ -70,7 +74,7 @@ seed. | `from 4` | 4 | 1 | | `from 5` | 5 | 0 — Critical-only from the first managed round | -## Semantics +### Semantics **The seed is a floor for an empty window, not an offset added to every round.** The counter is `max(this window's round markers)`, falling back to the seed when @@ -96,7 +100,7 @@ the loop instead of starting it — so it is clamped to `cap - 1`. When that happens, the Critical-only audit record cites the number you **typed**, plus a note naming the clamp, so it never quotes a command nobody sent. -## What it does not do +### What it does not do **It does not seed the growth brake.** `CRITICAL_ONLY_AFTER_ROUND` is only one of two brakes; the other trips when the diff grows past @@ -112,34 +116,148 @@ base-conflict resolution all keep flowing. Only the suggestion channel stops. **It does not shorten the round cap in any meaningful way.** Under takeover the cap is 100, so a seed of 4 leaves 96. +## Round budget — `@qwen-code /takeover for N` + +The bounded opposite of the seed. `from N` decides how much _suggestion_ budget +this window has; `for N` decides how many _rounds_ the loop may run at all. + +``` +@qwen-code /takeover for 2 +``` + +Engages takeover and records a window round budget of 2 on the engage ack: + +``` + + +``` + +The effective cap for this window becomes `N` (it only ever lowers the takeover +cap of 100). When the counter reaches `N`, the loop **releases** the PR: + +- it posts one bilingual "budget spent — released" note, per window; +- it removes `autofix/takeover`; +- it does **not** apply `autofix/needs-human` and does **not** post the paused + "reached its round cap" notice — a finished budget leaves nothing stuck, so + both would be false. + +**A forced dispatch defers that release to the next non-forced scan.** If the +fleet shepherd dispatches a conflict round at the moment the budget finishes, +that pass has to answer the dispatch out loud, so it takes the paused path +(paused notice plus `autofix/needs-human`) and the next scheduled scan releases +— clearing that escalation label with it. The notices on the deferred pass +quote the attempts spent (N/N), not the change-producing round counter, which a +no-op-only engagement leaves at 0. + +**Every finished attempt counts.** The budget is measured by the round's eval +marker, one per attempt: a no-op round writes one with its round number +unchanged, and a round that is cancelled or dies in setup writes one with the +round advanced by one and a sentinel timestamp. So a PR whose rounds keep +finding nothing still ends after N — that is the case a round counter cannot +express. The guarantee is one attempt per marker, and it assumes the marker +lands: the report step retries its post, but a comment-API outage that outlasts +those retries leaves a round uncounted, which delays the release rather than +ending it early. + +**A crash or a timeout spends its attempt; a breaker parks.** A crashed or +timed-out round stamps the sentinel timestamp and only advances the round by +one, so it consumes an attempt and nothing else — one such round does not stop +anything. A breaker withholds the release (`autofix/needs-human` plus the paused +notice) when the engagement holds `TIMEOUT_WINDOW_CAP` (3) sentinel-stamped +rounds with silent-sandbox (idle) ones subtracted, when a round is past +`ROUND_START + attempts`, or when a convergence-break notice is newer than the +window key. Those are the breaker's own thresholds, unchanged by `for N`; read a +lasting pause on a budgeted PR as "a human should look", never as "the budget +finished" — the one transient pause is the deferred release above. + +**A breaker stop is not a spent budget.** When the consecutive-failure, timeout +or convergence breaker stops the loop early, it stamps a terminal sentinel round +far past the real attempts and posts no extra marker — so the window is not +"spent", and the release note is not posted. A breaker asks for a human. + +**An automatic re-arm rebases the growth window, and the budget rides along.** +A growth-audit round appends `autofix-rearm` for its own round accounting, and +suppressing it would pin the growth baseline and make every later round +Critical-only — so it is unconditional. It is not the only thing that re-anchors +that baseline (a base-branch update and the scan's heal ack do too). What keeps +the budget honest is its key: the budget and the attempts spent are counted per +engagement — a command opens one, that automatic re-arm does not — so the audit +rebases while the remaining budget still applies. + +**A queued round from the same window is discarded once the budget is spent.** A +no-op leaves the round number UNCHANGED and the window key unchanged, so neither the +superseded-window nor the round-cap check would catch a round that was queued +before it; the address leg recounts the window's attempts and drops it. + +**The budget is engagement-scoped, not window-scoped.** It is read from every +ack or re-arm at or after the anchor that carries it, excluding the automatic +growth-audit re-arm (whose comment carries that round's eval marker). So +`/retry` or a bare `/takeover` opens a new engagement with no budget (unbounded +takeover, as before), and a budgeted re-arm (`@qwen-code /takeover for 3` on an +already-managed PR) starts a fresh one at round 0 with a 3-round budget. Round +accounting is the read that stays window-keyed. + +**Bot-authored PRs are the one exception.** They are managed without the label, +so releasing it would hand them back to standard ten-round management and the +budget would be a lie. They land on the existing paused path instead: paused +notice plus `autofix/needs-human`, and the fleet shepherd's auto-release ages the +label out. The budget stops them at N, and a SPENT one outlives that auto-release +(and a hand-removal, which leaves no marker either): the PR stays parked at N/N +until a new command moves the engagement. Only an explicit `stop` ends it, which +is why its ack says standard management continues. + +**A budgeted re-arm also cancels queued work, conflicts included.** A conflict +round is normally exempt from the superseded-window discard because it stays +actionable, but a queued job carries the old cap: with a smaller budget it would +run past the new limit. The next scan re-selects the conflict under the new +window. + +### Picking N + +| You type | Rounds the loop may run | Typical use | +| -------- | ----------------------- | --------------------------------------------------- | +| `for 1` | 1 | "clear this batch of review comments, then stop" | +| `for 2` | 2 | one round plus a follow-up after CI comes back | +| `for 5` | 5 | a small, deliberately bounded engagement | +| `for 99` | 99 | effectively unbounded; use bare `/takeover` instead | + +`for 0` is **not** accepted: a zero-round engagement is what `/takeover stop` +already does, so it fails closed with the rest of the malformed bodies. + ## Accepted and rejected forms The literal prefix must match `@qwen-code /takeover` byte-for-byte and the tail -must be a bare 1–2 digit integer. The command has to be the very first thing in -the comment: **no leading whitespace of any kind** — space, tab, or blank line. +must be either `from N` or `for N` with a bare 1–2 digit integer. The command +has to be the very first thing in the comment: **no leading whitespace of any +kind** — space, tab, or blank line. The router prefilters on the _raw_ comment body with `startsWith`, so a body with leading whitespace never starts a job at all, and the trim that runs inside that job never gets the chance ([af-004](./qwen-autofix.md#af-004)). Trailing whitespace is harmless. Anything -else fails closed — no label, no seed, no partial effect, and, when the router -never started, not even a log line. +else fails closed — no label, no seed, no budget, no partial effect, and, when +the router never started, not even a log line. -| Body | Result | -| ------------------------------------ | ------------------------------------------- | -| `@qwen-code /takeover from 4` | engage, seed 4 | -| `@qwen-code /takeover from 04` | engage, seed 4 (read as decimal, not octal) | -| `@qwen-code /takeover from 0` | engage, no seed — the explicit spelling | -| `@qwen-code /takeover` | engage, no seed | -| `@qwen-code /takeover stop` | release | -| `@qwen-code /takeover stop from 4` | **nothing** — neither releases nor engages | -| `@qwen-code /takeover from 100` | **nothing** — 3 digits rejected | -| `@qwen-code /takeover from 4` | **nothing** — double space | -| `please @qwen-code /takeover from 4` | **nothing** — must start the comment | -| ` @qwen-code /takeover from 4` | **nothing** — leading spaces | -| blank line, then the command | **nothing** — leading newline | -| `@qwen-code /takeover from 4 please` | **nothing** — must end the comment | +| Body | Result | +| ------------------------------------ | ----------------------------------------------- | +| `@qwen-code /takeover from 4` | engage, seed 4 | +| `@qwen-code /takeover from 04` | engage, seed 4 (read as decimal, not octal) | +| `@qwen-code /takeover from 0` | engage, no seed — the explicit spelling | +| `@qwen-code /takeover for 2` | engage, 2-round budget | +| `@qwen-code /takeover for 02` | engage, 2-round budget (decimal, not octal) | +| `@qwen-code /takeover` | engage, no seed, no budget | +| `@qwen-code /takeover stop` | release | +| `@qwen-code /takeover stop from 4` | **nothing** — neither releases nor engages | +| `@qwen-code /takeover for 0` | **nothing** — a zero-round engagement is `stop` | +| `@qwen-code /takeover from 4 for 2` | **nothing** — the two forms do not combine | +| `@qwen-code /takeover from 100` | **nothing** — 3 digits rejected | +| `@qwen-code /takeover for 100` | **nothing** — 3 digits rejected | +| `@qwen-code /takeover from 4` | **nothing** — double space | +| `please @qwen-code /takeover from 4` | **nothing** — must start the comment | +| ` @qwen-code /takeover from 4` | **nothing** — leading spaces | +| blank line, then the command | **nothing** — leading newline | +| `@qwen-code /takeover from 4 please` | **nothing** — must end the comment | -## Reading the result +### Reading the result Once the brake engages, the round report carries a `Deferred non-Critical feedback` section whose preamble names the seed explicitly, for example: diff --git a/.github/workflows/qwen-autofix.md b/.github/workflows/qwen-autofix.md index 12266ce5195..129e7a650c1 100644 --- a/.github/workflows/qwen-autofix.md +++ b/.github/workflows/qwen-autofix.md @@ -91,16 +91,20 @@ plus a pointer: # Full rationale → qwen-autofix.md#af-030 ``` -Each section below is the **verbatim** text of the block that pointer replaced, -titled with the job and step it belongs to. Editing rules: keep the pointer and -the section id in sync, put new long-form reasoning here rather than in the -YAML, and never delete a section without deleting its pointer. +Each section below carries the text of the block that pointer replaced, titled +with the job and step it belongs to. A summary line may be rewritten after the +block was compressed (the section is then authoritative, not the comment), and +af-160 marks the entries whose text is new rather than moved. Editing rules: +keep the pointer and the section id in sync, put new long-form reasoning here +rather than in the YAML, and never delete a section without deleting its +pointer. This file records _why the code is the way it is_, indexed by code site. For task-oriented guides — what a maintainer types and what happens next — see: -- [`qwen-autofix-round-seed.md`](./qwen-autofix-round-seed.md) — seeding the - round counter with `@qwen-code /takeover from N`. +- [`qwen-autofix-round-seed.md`](./qwen-autofix-round-seed.md) — the two + parameterized commands: seeding the round counter with `@qwen-code /takeover +from N`, and bounding an engagement with `@qwen-code /takeover for N`. ## Contents @@ -110,7 +114,7 @@ task-oriented guides — what a maintainer types and what happens next — see: - [4. route — The issue_comment clause is a cheap expression-level prefilter: the overwhelming…](#af-004) - [5. route — Concurrency is keyed by TARGET, not shared and not fully unique:](#af-005) - [6. route · Decide phases — Fork PR — decline, and say so. This event carries NO repository secrets: GitHub…](#af-006) -- [7. route · Decide phases — ' from N' — the ONE parameterized form, and the only place this workflow reads a…](#af-007) +- [7. route · Decide phases — ' from N' and ' for N' — the two parameterized forms, and the only places this…](#af-007) - [8. issue-autofix — Secret-bearing and executes agent-driven code, but the agent runs inside the docker…](#af-008) - [9. issue-autofix — route.issue_number is only set for forced dispatches; label events carry the issue in…](#af-009) - [10. issue-autofix · Sanitize workspace git config — The runner USER's global config is the same exec surface as the workspace config below:…](#af-010) @@ -259,6 +263,8 @@ task-oriented guides — what a maintainer types and what happens next — see: - [156. review-address · Triage and address — In-round self-review A/B: one bounded delta review before the round's commit,…](#af-156) - [157. review-address · Prepare branch and feedback — Classify the live head: a round that pushes onto a fully GREEN head and leaves…](#af-157) - [158. review-address · Stage trusted test-surface instrument — The test-weakening gate's instrument: the counter from the same trusted…](#af-158) +- [159. route · Takeover round budget — ' for N' bounds a window's rounds and releases the PR when the budget is spent…](#af-159) +- [160. (top level) · Env constant commentary — Moved out of the workflow verbatim to keep the file under GitHub's start-runs gate…](#af-160) --- @@ -487,13 +493,14 @@ which already declines forks for exactly this reason. -### 7. route · Decide phases — ' from N' — the ONE parameterized form, and the only place this workflow reads a… +### 7. route · Decide phases — ' from N' and ' for N' — the two parameterized forms, and the only places this… In `route` · `Decide phases`. ```text -' from N' — the ONE parameterized form, and the only -place this workflow reads a value out of a comment body. +' from N' and ' for N' — the two parameterized +forms, and the only places this workflow reads a value out +of a comment body. Kept inside the constants discipline above: the literal prefix must still match TAKEOVER_COMMAND byte-for-byte, the tail is a bounded integer, and the captured value only ever @@ -4400,6 +4407,10 @@ failed or incomplete pass as review-failed and pushes regardless. In `review-address` · `Prepare branch and feedback`. +The lane names in `LOOP_OWNED_LANES` are pinned against the workflows' own +`name:` lines by a test, so adding a lane means adding the workflow — not just +the JSON entry. + ```text Classify the live head: a round that pushes onto a fully GREEN head and leaves it red introduced the red. Until this @@ -4567,3 +4578,250 @@ presence branch, so a tree that has the counter but no installed typescript fails the step loudly instead of producing a round that quietly measures nothing. ``` + + + +### 159. route · Takeover round budget — ' for N' bounds a window's rounds and releases the PR when the budget is spent… + +In `route` · `Decide phases`, `takeover-command` · `Toggle takeover label`, +`review-scan` · `Scan for PRs with new feedback`, `review-address` · `Prepare +branch and feedback`, and the fleet shepherd's resume detector +(`compute_resume_ts`), which has to recognise the same two command forms. + +```text +'@qwen-code /takeover' engages a persistent engagement capped at +TAKEOVER_MAX_ROUNDS. Two workarounds existed for "handle this once": +'from 99' (spends the cap in one change-producing round, but that round +is Critical-only and the stop applies needs-human plus a "reached its +round cap" notice, which reads as a failure — and a no-op round leaves +the counter untouched, so it does not even bound a PR that keeps finding +nothing) and a manual 'stop' (needs a human watching the run). Neither +states "N rounds". + +'for N' is the second parameterized form, and it is a separate marker +rather than a field on the ack literal for the same reason the seed is +(af-016): the ack is matched by substring everywhere. The re-arm invariants +this relies on are recorded in docs/design/autofix-growth-audit.md — read +that before changing how the audit re-arms. + +The budget is enforced in two steps, and the second is why spend is +counted in eval markers rather than in ROUND: + +- The scan lowers EFF_MAX_ROUNDS to N for the ENGAGEMENT that carries + the marker, so the existing cap gate bounds DISPATCH exactly as it + always did. The effective cap's other reader is prepare's attempt + recount. +- Spend is the number of eval markers in the engagement, not ROUND. A + no-op round writes its marker with the round UNCHANGED — that is why + a no-op never advanced the cap — but it is still an attempt, and a + budget counted in ROUND would never end for a PR whose rounds keep + finding nothing. The census keys on `run=` (the run id, the dedup + identity the growth marker already carries), falling back to + `ts|round` for markers written before it existed: two executions + that report the same watermark and round — the conflict no-op, which + re-dispatches every tick — are two attempts, while a re-run of one + run still charges once. A run that is cancelled or dies in setup is + NOT free: the report step runs on always() and writes its marker with + a sentinel timestamp, so the census charges it. +- A breaker stop is NOT a spent budget. Three shapes reach the cap gate + with the budget spent anyway: the consecutive-failure breaker jumps + the round to MAX_ROUNDS with a real timestamp (no trailing sentinel), + the cumulative timeout breaker counts timeouts across the window with + pushes between them (so the trailing run stays short), and the + convergence breaker only pushes. The release is gated on + BUDGET_HALTED — TIMEOUT_WINDOW_CAP (3) sentinel-stamped rounds in the + engagement with idle (silent-sandbox) rounds subtracted (af-073), a + round past ROUND_START + attempts, or a convergence-break notice newer + than the window key — so all three park on the paused path instead of + releasing. ONE crash or timeout does not halt anything: it only spends + its attempt. +- A SPENT budget outlives the label's AUTOMATIC removal: the shepherd + auto-releases a paused bot PR after three days, and if the budget died + with that label the effective cap would fall back to 10 and rounds + N+1..10 would run on a PR the dashboard still shows as awaiting a + human. A spent budget therefore survives for a bot-authored + engagement. An UNSPENT one still dies with the label, and a human + release keeps no cap at all (af-160). An explicit 'stop' DOES end a + spent budget: its 'takeover-ack released' marker is a floor boundary, + so the engagement closes and standard management resumes — the one + release the loop can tell apart from a silent label removal. +- It is ENGAGEMENT-scoped, and that is not the same key as the window. + The budget is read from every ack/re-arm at or after the anchor whose + comment carries it — BUDGET_FLOOR: a 'takeover-ack engaged', a + 'takeover-ack released' (which CLOSES the engagement), or an + 'autofix-rearm' that does NOT also carry an 'autofix-eval ' marker. + Round accounting (af-105) stays window-keyed instead: equality with + the ack comment's created_at. So /retry or a bare /takeover opens an + unbudgeted engagement (af-104), and re-arming with a budget starts a + fresh one at round 0 — but an automatic growth-audit re-arm, whose + comment carries the round's eval marker, does not. Collapsing this + read into the window-key equality would drop the budget and the spend + census on every automatic re-arm. + +A budgeted window supersedes queued work INCLUDING a conflict round. +Conflicts are normally exempt from the superseded-window check because +they stay actionable, but a queued job runs on the OLD cap and would +cross the new engagement limit, so the live window's budget makes it +stale too and the next scan re-selects the conflict under the new +window. + +Two automatic paths need the same care. A growth audit appends +`` to rebase the round window for its own account- +ing, and suppressing it would pin the growth baseline so every later +round went Critical-only — so it stays unconditional. It is NOT the only +thing that re-anchors that baseline: a base-branch update (which filters +the earlier growth-base markers out of the baseline read, af-119/af-120) +and the scan's first-pickup heal ack move it too. What keeps a budget +honest is not the re-arm's exclusivity but its key — budget and spend are +read per ENGAGEMENT — so no rebase can unbound a bounded engagement. And +a queued round can outlive the sibling that spent the last attempt: a +no-op leaves ROUND UNCHANGED and the window key unchanged, so neither +the superseded-window check nor the round cap sees it — prepare and the +scan both recount the engagement's eval markers. + +What happens at the boundary differs by authorship, and that difference +is the point. A HUMAN-authored PR is simply released: nothing is stuck, +so the escalation label and the paused notice would both be false — the +branch posts one bilingual note (dedup'd per window by its own marker) +and DELETEs the label. A BOT-authored PR is managed without the label, +so releasing it would hand the PR back to standard ten-round management +and the budget would be a lie; it keeps the existing paused path +(needs-human, shepherd auto-release) and is stopped at N for as long as +the engagement is live. A spent budget outlives every removal that leaves +no 'takeover-ack released' marker — the shepherd's three-day auto-release +and a hand-removal alike — so the PR stays parked at N/N until a new +command moves the engagement. Only an explicit 'stop' carries that +marker, and that is what the ack promises. + +The release re-reads live labels and fails closed on an unreadable +snapshot, honors autofix/skip, and verifies the PAT identity before any +write — the same consent and identity doctrine as the cap notice +(af-106). The note posts BEFORE the DELETE, and a failed note SKIPS the +DELETE: once the label is gone the PR is in neither selection pool, so +nothing would ever re-post it, and "retry next scan" would be a promise +no branch can keep. + +A FORCED dispatch defers that release to the next non-forced scan. The +shepherd's conflict dispatch has to be answered out loud (af-031), so the +pass that meets a spent budget takes the paused path instead — cap-refused +note, paused notice, needs-human — and the next passive scan releases. Two +consequences are load-bearing. The release DELETEs needs-human as well +(404-tolerant, and only after the takeover DELETE landed): the note says +nothing is waiting on a human, so the escalation its own deferral applied +must not outlive it. And the notices on that deferred pass quote the +ATTEMPTS, not ROUND: a no-op-only engagement reaches this gate at round 0, +and '0/N' would name a cap the PR never reached — the same reason the log +line and the fleet row already speak of the budget rather than the cap. + +'for 0' is rejected rather than aliased to the bare command: a +zero-round engagement is what 'stop' already means (af-007). +``` + + + +### 160. (top level) — Env constant commentary, moved out of the workflow… + +In the workflow `env:` block. + +The workflow file sits against this repository's own 470 KB gate +(`.github/scripts/check-workflow-size.sh`, env-overridable, ~42 KB below +GitHub's 512 KB start-runs limit), so the long-form commentary on the constants +below lives here verbatim — with TWO exceptions, each marked at its entry: the +`TAKEOVER_COMMAND` entry was updated to name the second parameterized form, and +the `TRUSTED_ASSOC` entry gained the note on where the constant is read and what +widening it would do. Neither is a byte-for-byte copy of the base block. Each +entry names the constant it documents; the workflow keeps a one-line summary +plus a pointer. + +```text +# FIXED_RULING_FILTER — The review bot's thread lifecycle replies + # The review bot's thread lifecycle replies `R fixed by ` into a + # thread — a NOTE, never a finding: it carries its own marker + # (FIXED_RULING_MARKER in lib/review-footer.ts) at the end of its first + # line, and counting it selected a just-approved PR for a round with + # nothing to address (#9940 review, round 28). Matched by its posted SHAPE, + # anchored at the start, so a real Critical that QUOTES the marker (a + # review of the file defining it) leads with its severity marker and keeps + # counting — and so does one whose first line QUOTES a whole ruling and + # then carries on, which the start anchor alone let through under + # attribution off, where no severity marker leads the post (#9940 + # review, round 30). The anchor spans the WHOLE body: a comment that + # quotes the note in full and then states its own finding underneath was + # dropped from the census wholesale, the live Critical with it — a real + # note is the note and, under attribution on, its footer, and anything + # carrying further prose fails closed toward COUNTING (#9940 review, + # round 31). `lib/review-footer.ts` exports the same shape as + # FIXED_RULING_SHAPE_RE for the TypeScript readers. Defined ONCE and handed to every jq site as `--arg frf`: an + # `--arg` value is the regex verbatim, so the class is spelled `[^\n]` with + # ONE backslash here — the `[^\\n]` a jq string literal needs compiled, as a + # shell value, to "neither backslash nor n" and let a `by` clause with an + # `n` in it count (#9940 review, round 29). + +# NON_BLOCKING_CHECKS — Checks the "wait for checks to settle" gate does NOT wait for. + # Checks the "wait for checks to settle" gate does NOT wait for. The gate + # exists so a FAILED check can be read as feedback, which is why build/test/ + # lint are still waited for. `review-pr` is the LLM code review: its output + # is a REVIEW, delivered by its own real-time pull_request_review trigger and + # counted by the review path — the check conclusion carries nothing the loop + # acts on. Blocking on it bought nothing and cost a median 49 minutes per + # round (p90 123, max 158, over 32 completed runs), during which the PR was + # invisible to the scan even when it already had unaddressed feedback. + # Names must match job ids in qwen-code-pr-review.yml; a test pins that. + +# MAX_ROUNDS — Hard cap on automated review-address rounds per PR. + # Hard cap on automated review-address rounds per PR. After this the bot stops + # and leaves the PR for a human. Raised from 5: across the last 40 bot PRs + # only 3 ever reached the cap and all 3 merged AT it (one having spent two of + # its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling + # was near enough to bind on a bad day without any headroom for one. The cap + # exists to stop an unproductive LOOP, not to ration ordinary iteration — + # a genuinely stuck PR still stops, just later. + +# TAKEOVER_COMMAND — Comment-command sugar over TAKEOVER_LABEL + # Comment-command sugar over TAKEOVER_LABEL ('' applies it, ' + # stop' removes it). Matched EXACTLY against the trimmed comment body — + # with TWO parameterized forms: ' from N' seeds this window's round + # counter at N (af-002), and ' for N' records a round budget which the + # scan reads back as a ceiling (af-159). The literal prefix still has to + # match this constant byte-for-byte; only a bounded 1-2 digit integer is + # read out of the body, and it reaches nothing but an integer comparison. + +# TAKEOVER_MAX_ROUNDS — Round cap while TAKEOVER_LABEL is present. + # Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely + # need dozens of feedback rounds — that is the point of takeover — so the + # unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays + # (a bot↔review-bot ping-pong is still bounded), it is just sized for + # explicitly delegated work; removing the label restores the strict cap — + # EXCEPT while a 'for N' budget is still live (af-159: a spent one on a + # bot-authored PR outlives a silent removal and keeps clamping) — and + # re-engaging opens a fresh counting window (see REARM_KEY below). + +# RETRY_COMMAND — Re-arm sugar. Recovering a stranded PR + # Re-arm sugar. Recovering a stranded PR previously meant DELETING the + # bot's autofix-eval marker comment by hand (undiscoverable, destructive, + # and it erases the audit trail). This command instead posts an + # 'autofix-rearm' marker that supersedes the earlier evaluation markers: + # the scan re-reads the feedback from scratch and the round counter resets. + +# NEEDS_HUMAN_LABEL — Escalation label: applied when the loop STOPS + # Escalation label: applied when the loop STOPS on a PR (round cap, + # consecutive-failure or time-budget breaker) and a human must act — + # re-arm, split, merge, or close. It makes paused PRs filterable and lets + # the fleet shepherd age out unanswered stops. Removed wherever management + # resumes (engage/re-arm) or a human releases the PR. + +# TAKEOVER_LABEL / SKIP_LABEL — Maintainer-facing engagement labels + # Maintainer-facing engagement labels (applying labels requires GitHub + # triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR — + # including a human-authored one — into the loop; SKIP opts any PR out + # everywhere, and wins when both are present. + +# TRUSTED_ASSOC — Human feedback: maintainers only (prompt-injection gate) + # Human reviews/comments only count when the author is a real maintainer. This + # is the prompt-injection trust gate: feedback from anyone else is ignored so a + # hostile commenter cannot steer the agent. + # NOT moved verbatim: the two sentences below are new in this change (af-160). + # It is matched against `author_association` at every census site; widening it + # (NONE, FIRST_TIME_CONTRIBUTOR) hands untrusted text to an agent that runs + # with a write PAT. +``` diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index c9eb24947d9..503b07a283f 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -71,161 +71,62 @@ permissions: contents: 'read' env: - # Identity of the autofix bot. All open in-repo PRs authored by this bot are - # eligible for the review phase — not limited to autofix/issue-* branches. + # Autofix bot identity: ALL its open in-repo PRs are eligible, not just autofix/issue-*. AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}" - # Branch-name prefix used by the issue phase when creating PRs. Also used - # for duplicate-PR detection and issue-number extraction from branch names. + # Branch prefix for issue-phase PRs; also drives duplicate detection and issue-number extraction. BRANCH_PREFIX: 'autofix/issue-' - # The automated Qwen PR reviewer posts as this account; its review counts as - # actionable feedback even though it is not a human collaborator. + # The Qwen PR reviewer: its review counts as actionable feedback without collaborator status. REVIEW_BOT: 'qwen-code-ci-bot' - # Human reviews/comments only count when the author is a real maintainer. This - # is the prompt-injection trust gate: feedback from anyone else is ignored so a - # hostile commenter cannot steer the agent. + # Human feedback: maintainers only (prompt-injection gate). → qwen-autofix.md#af-160 TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]' - # Every lane this loop owns whose check runs attach to the PR head: - # excluded from the head-state classifiers (charge + wake) by name, or - # the loop reads its own red as the round's regression (af-157, R27-23). - # A test pins the names against the workflows' `name:` lines. + # Loop-owned lanes excluded from the head-state classifiers. → qwen-autofix.md#af-157 LOOP_OWNED_LANES: '["Qwen Autofix","🧐 Qwen Pull Request Review","Qwen CI Failure Patrol","Qwen Autofix Fork Bridge","Qwen Autofix Fork Signal","Qwen Triage","Qwen Triage Finalize","PR self-report label","Qwen PR Safety Precheck","Comment Attachment Guard","PR Asset Branch Cleanup"]' - # The review bot's thread lifecycle replies `R fixed by ` into a - # thread — a NOTE, never a finding: it carries its own marker - # (FIXED_RULING_MARKER in lib/review-footer.ts) at the end of its first - # line, and counting it selected a just-approved PR for a round with - # nothing to address (#9940 review, round 28). Matched by its posted SHAPE, - # anchored at the start, so a real Critical that QUOTES the marker (a - # review of the file defining it) leads with its severity marker and keeps - # counting — and so does one whose first line QUOTES a whole ruling and - # then carries on, which the start anchor alone let through under - # attribution off, where no severity marker leads the post (#9940 - # review, round 30). The anchor spans the WHOLE body: a comment that - # quotes the note in full and then states its own finding underneath was - # dropped from the census wholesale, the live Critical with it — a real - # note is the note and, under attribution on, its footer, and anything - # carrying further prose fails closed toward COUNTING (#9940 review, - # round 31). `lib/review-footer.ts` exports the same shape as - # FIXED_RULING_SHAPE_RE for the TypeScript readers. Defined ONCE and handed to every jq site as `--arg frf`: an - # `--arg` value is the regex verbatim, so the class is spelled `[^\n]` with - # ONE backslash here — the `[^\\n]` a jq string literal needs compiled, as a - # shell value, to "neither backslash nor n" and let a `by` clause with an - # `n` in it count (#9940 review, round 29). + # Shape of the review bot's 'R fixed by' note, which never counts as feedback (#9940). → qwen-autofix.md#af-160 FIXED_RULING_FILTER: '^R[0-9]+-[0-9]+ fixed(?: by [^\r\n\x{2028}\x{2029}]*)? [ \t]*(\r?\n[ \t\r\n]*_— ((?! via Qwen Code /review)[^\r\n\x{2028}\x{2029}])* via Qwen Code /review( \(v[A-Za-z0-9._+-]{0,200}…?\)?)?_[ \t]*)?([\r\n][ \t\r\n]*)?$' - # Hard cap on automated review-address rounds per PR. After this the bot stops - # and leaves the PR for a human. Raised from 5: across the last 40 bot PRs - # only 3 ever reached the cap and all 3 merged AT it (one having spent two of - # its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling - # was near enough to bind on a bad day without any headroom for one. The cap - # exists to stop an unproductive LOOP, not to ration ordinary iteration — - # a genuinely stuck PR still stops, just later. + # Hard cap on automated review-address rounds per PR; a stuck PR stops later, not never. → qwen-autofix.md#af-160 MAX_ROUNDS: '10' - # Suggestions may improve a PR, but continuing to implement them after five - # change-producing rounds expands the diff and creates fresh review churn. - # Full rationale → qwen-autofix.md#af-002 + # Suggestions stop after CRITICAL_ONLY_AFTER_ROUND change-producing rounds. → qwen-autofix.md#af-002 CRITICAL_ONLY_AFTER_ROUND: '5' # Per-author tail budget inside Critical-only mode. # Full rationale → qwen-autofix.md#af-074 CRITICAL_ONLY_HUMAN_BATCHES: '2' - # Net-diff growth budgets per counting window — the SIZE sibling of the - # round brake above. CRITICAL_ONLY_AFTER_ROUND counts rounds, but one round - # Full rationale → qwen-autofix.md#af-003 + # Net-diff growth budgets per counting window — the SIZE sibling of the round brake. → qwen-autofix.md#af-003 GROWTH_BUDGET_SRC_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_SRC_LINES || 400 }}' GROWTH_BUDGET_TEST_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_TEST_LINES || 400 }}' - # Growth audit: a budget breach engages Critical-only AND makes the round a - # growth-audit round. - # Full rationale → qwen-autofix.md#af-075 + # A budget breach engages Critical-only and makes the round a growth-audit round. → qwen-autofix.md#af-075 API_AUTH_MAX_ROUNDS: '3' - # Checks the "wait for checks to settle" gate does NOT wait for. The gate - # exists so a FAILED check can be read as feedback, which is why build/test/ - # lint are still waited for. `review-pr` is the LLM code review: its output - # is a REVIEW, delivered by its own real-time pull_request_review trigger and - # counted by the review path — the check conclusion carries nothing the loop - # acts on. Blocking on it bought nothing and cost a median 49 minutes per - # round (p90 123, max 158, over 32 completed runs), during which the PR was - # invisible to the scan even when it already had unaddressed feedback. - # Names must match job ids in qwen-code-pr-review.yml; a test pins that. + # Checks the settle gate skips: 'review-pr' carries nothing the loop acts on. → qwen-autofix.md#af-160 NON_BLOCKING_CHECKS: '["review-pr"]' - # Failed-check annotation patterns that mean the INFRASTRUCTURE died, not the - # code — a self-hosted runner losing the server, the disk filling, a runner - # shutdown, a git fetch/clone dying mid-transfer, the hosted pool never - # handing the job a runner (#13622: "repeatedly failed to be acquired"), or a - # shared-host test lock starving past its per-acquisition budget (#14550: - # "sdk-java host lock not acquired within 40 minutes"). - # Full rationale → qwen-autofix.md#af-076 + # Failed-check patterns that mean INFRASTRUCTURE died, not the code. → qwen-autofix.md#af-076 INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed|failed to be acquired|lock not acquired within' - # Upper bound on review targets emitted per scan (fan-out defense-in-depth; - # excess is logged and deferred to the next scan). - # Full rationale → qwen-autofix.md#af-077 + # Max review targets per scan (fan-out defense). → qwen-autofix.md#af-077 MAX_TARGETS_PER_SCAN: '${{ vars.QWEN_AUTOFIX_MAX_TARGETS_PER_SCAN || 30 }}' - # Upper bound on candidates INSPECTED per scan: idle candidates consume - # serial API calls even when they emit nothing, and takeover widens the - # candidate pool. + # Upper bound on candidates INSPECTED per scan. # Full rationale → qwen-autofix.md#af-078 MAX_CANDIDATE_INSPECTIONS: '${{ vars.QWEN_AUTOFIX_MAX_CANDIDATE_INSPECTIONS || 60 }}' - # Maintainer-facing engagement labels (applying labels requires GitHub - # triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR — - # including a human-authored one — into the loop; SKIP opts any PR out - # everywhere, and wins when both are present. + # TAKEOVER opts in; SKIP opts out and wins. → qwen-autofix.md#af-160 TAKEOVER_LABEL: 'autofix/takeover' SKIP_LABEL: 'autofix/skip' - # Commit-status context stamped PENDING on a PR head when a scan dispatches - # a review-address leg for it, and re-stamped SUCCESS by the leg on - # checkout. - # Full rationale → qwen-autofix.md#af-079 + # PENDING marker stamped on a PR head when a scan dispatches. → qwen-autofix.md#af-079 DISPATCH_STATUS_CONTEXT: 'qwen-autofix/dispatch-pending' DISPATCH_STATUS_TTL_MINUTES: '30' - # Comment-command sugar over TAKEOVER_LABEL ('' applies it, ' - # stop' removes it). Matched EXACTLY against the trimmed comment body — - # with ONE parameterized form, ' from N', which applies the label and - # additionally seeds this window's round counter at N (see - # CRITICAL_ONLY_AFTER_ROUND). The literal prefix still has to match this - # constant byte-for-byte; only a bounded 1-2 digit integer is read out of - # the body, and it reaches nothing but an integer comparison. + # Comment-command sugar: '', 'stop', 'from N', 'for N' — exact match only. → qwen-autofix-round-seed.md TAKEOVER_COMMAND: '@qwen-code /takeover' - # Escalation label: applied when the loop STOPS on a PR (round cap, - # consecutive-failure or time-budget breaker) and a human must act — - # re-arm, split, merge, or close. It makes paused PRs filterable and lets - # the fleet shepherd age out unanswered stops. Removed wherever management - # resumes (engage/re-arm) or a human releases the PR. + # Escalation label applied when the loop STOPS and a human must act. → qwen-autofix.md#af-160 NEEDS_HUMAN_LABEL: 'autofix/needs-human' - # Re-arm sugar. Recovering a stranded PR previously meant DELETING the - # bot's autofix-eval marker comment by hand (undiscoverable, destructive, - # and it erases the audit trail). This command instead posts an - # 'autofix-rearm' marker that supersedes the earlier evaluation markers: - # the scan re-reads the feedback from scratch and the round counter resets. + # Re-arm sugar: an 'autofix-rearm' marker supersedes earlier eval markers. → qwen-autofix.md#af-160 RETRY_COMMAND: '@qwen-code /retry' - # Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely - # need dozens of feedback rounds — that is the point of takeover — so the - # unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays - # (a bot↔review-bot ping-pong is still bounded), it is just sized for - # explicitly delegated work; removing the label restores the strict cap, - # and re-engaging opens a fresh counting window (see REARM_KEY below). + # Round cap while TAKEOVER_LABEL is present; 'for N' may lower it. → qwen-autofix.md#af-160 TAKEOVER_MAX_ROUNDS: '100' - # Consecutive-failure sub-cap, distinct from the total round cap above. The - # total cap bounds how many PRODUCTIVE rounds a PR may take; this bounds how - # many rounds may fail IN A ROW with nothing pushed. - # Full rationale → qwen-autofix.md#af-080 + # Consecutive-failure sub-cap: rounds that may fail in a row with nothing pushed. → qwen-autofix.md#af-080 CONSECUTIVE_FAILURE_CAP: '5' - # Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for - # the failure shape it cannot see: timeouts INTERLEAVED with successful - # rounds. - # Full rationale → qwen-autofix.md#af-081 + # Cumulative agent-timeout sub-cap. → qwen-autofix.md#af-081 TIMEOUT_WINDOW_CAP: '3' - # Convergence-signal circuit breaker — the off-ramp the round and growth - # brakes cannot provide (#10107): N consecutive review rounds carrying a - # machine-readable non-convergence code, with no trusted-human response - # in between, pause the loop. Tunable without a code change; a malformed - # value falls back to its default at the read site. - # Full rationale → qwen-autofix.md#af-151 + # Consecutive non-convergence rounds before the loop pauses. → qwen-autofix.md#af-151 CONVERGENCE_BREAK_ROUNDS: '${{ vars.QWEN_AUTOFIX_CONVERGENCE_BREAK_ROUNDS || 3 }}' - # Which codes count as the signal (jq-ready JSON array). The vocabulary - # is the review CLI's RECOMMENDATION_CODES, published per round as `rec` - # in the posted ledger marker; a test pins this list inside it. - # 'land-and-defer' is deliberately absent — see af-151. + # Convergence signal codes. → qwen-autofix.md#af-151 CONVERGENCE_SIGNAL_CODES: '["root-cause-triage","successor-chain","batch-fixes","stem-surface"]' - # In-round self-review A/B: off (default) | ab (odd PRs armed) | on — - # ONE bounded delta review before the round's commit, never a loop. - # Full rationale → qwen-autofix.md#af-156 + # In-round self-review A/B: off (default) | ab (odd PRs armed) | on. → qwen-autofix.md#af-156 SELF_REVIEW: "${{ vars.QWEN_AUTOFIX_SELF_REVIEW || 'off' }}" # Agent budget (ms) for an ARMED round; clamped in the address step. SELF_REVIEW_TIMEOUT_MS: '${{ vars.QWEN_AUTOFIX_SELF_REVIEW_TIMEOUT_MS || 10800000 }}' @@ -268,6 +169,7 @@ jobs: ack_base: '${{ steps.decide.outputs.ack_base }}' takeover_cmd: '${{ steps.decide.outputs.takeover_cmd }}' takeover_from: '${{ steps.decide.outputs.takeover_from }}' + takeover_for: '${{ steps.decide.outputs.takeover_for }}' retry_pr: '${{ steps.decide.outputs.retry_pr }}' cmd_pr: '${{ steps.decide.outputs.cmd_pr }}' review_sender: '${{ github.event.review.user.login }}' @@ -328,6 +230,8 @@ jobs: # parser below). Empty on every other path, which reads as "start # this window at round 0" — the pre-existing behaviour. TAKEOVER_FROM='' + # Engagement round budget from ' for N'; empty = takeover cap. + TAKEOVER_FOR='' CMD_PR='' RETRY_PR='' DRY_RUN="${DRY_RUN_INPUT:-false}" @@ -420,8 +324,8 @@ jobs: CMD='' [[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND}" ]] && CMD='add' [[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND} stop" ]] && CMD='remove' - # ' from N' — the ONE parameterized form, and the only - # place this workflow reads a value out of a comment body. + # ' from N' / 'for N' — the two parameterized forms, + # the only places this reads a value out of a comment body. # Full rationale → qwen-autofix.md#af-007 # Operator guide → qwen-autofix-round-seed.md if [[ "${BODY_TRIMMED}" =~ ^(.*)\ from\ ([0-9]{1,2})$ ]]; then @@ -440,6 +344,18 @@ jobs: TAKEOVER_FROM="$((10#${BASH_REMATCH[2]}))" fi fi + # ' for N' — the round-budget form. Unlike the seed, 0 is + # not a meaningful spelling (that is 'stop'), so it fails closed. + # Full rationale → qwen-autofix.md#af-159 + if [[ "${BODY_TRIMMED}" =~ ^(.*)\ for\ ([0-9]{1,2})$ ]]; then + if [[ "${BASH_REMATCH[1]}" == "${TAKEOVER_COMMAND}" ]]; then + FOR_CAND="$((10#${BASH_REMATCH[2]}))" + if [[ "${FOR_CAND}" -ge 1 ]]; then + CMD='add' + TAKEOVER_FOR="${FOR_CAND}" + fi + fi + fi # Re-arm shares the takeover command's authorization exactly: # both summon bot activity on a managed PR, so inventing a # second policy would only add surface. @@ -646,6 +562,7 @@ jobs: # the only shape the marker reader accepts is a bare 1-2 digit # integer. sanitize_number rejects (and warns about) anything else. echo "takeover_from=$(sanitize_number "${TAKEOVER_FROM}")" >> "${GITHUB_OUTPUT}" + echo "takeover_for=$(sanitize_number "${TAKEOVER_FOR}")" >> "${GITHUB_OUTPUT}" echo "retry_pr=${RETRY_PR}" >> "${GITHUB_OUTPUT}" echo "cmd_pr=${CMD_PR}" >> "${GITHUB_OUTPUT}" echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' pr='#${PR_NUMBER_EVENT:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}" @@ -1732,6 +1649,7 @@ jobs: REPO: '${{ github.repository }}' CMD: '${{ needs.route.outputs.takeover_cmd }}' CMD_FROM: '${{ needs.route.outputs.takeover_from }}' + CMD_FOR: '${{ needs.route.outputs.takeover_for }}' PR: '${{ needs.route.outputs.cmd_pr }}' GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}' steps: @@ -1846,12 +1764,22 @@ jobs: FROM_NOTE_REARM="$(printf ' This window'"'"'s round counter restarts at %s (rounds already spent on this PR), so the Critical-only brake engages after %s more change-producing round(s) instead of a full fresh %s.' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" FROM_NOTE_REARM_ZH="$(printf '本窗口轮次计数从 %s 重启(即本 PR 已消耗的轮次),因此再经过 %s 个产生改动的轮次即进入 Critical-only,而非重新计满 %s 轮。' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" fi + # Round budget carried by ' for N': its own marker on the seed's + # window anchor, and a CEILING the scan lowers EFF_MAX_ROUNDS to. + FOR_MARKER='' + FOR_NOTE='' + FOR_NOTE_ZH='' + if [[ -n "${CMD_FOR}" && "${CMD_FOR}" =~ ^[0-9]{1,2}$ && "${CMD_FOR}" != '0' ]]; then + FOR_MARKER="$(printf '\n' "${CMD_FOR}")" + FOR_NOTE="$(printf ' Budget: %s round(s). After round %s — pushed, no-op or failed — the loop removes the `%s` label (a bot-authored PR keeps it and pauses for a human instead).' "${CMD_FOR}" "${CMD_FOR}" "${TAKEOVER_LABEL}")" + FOR_NOTE_ZH="$(printf '本次托管预算 %s 轮:第 %s 轮结束(推送、无改动或失败)后自动移除 `%s` 标签(bot 创建的 PR 保留标签并转为人工作业)。' "${CMD_FOR}" "${CMD_FOR}" "${TAKEOVER_LABEL}")" + fi if [[ "${CMD}" == 'add' ]]; then if [[ "${HAS}" == 'true' ]]; then # Already managed: repeating the command is the ROUND-COUNTER # RESET. A fresh engage ack starts a new counting window (only # Full rationale → qwen-autofix.md#af-017 - REARM_BODY="$(printf '🔄 Takeover re-armed: the round counter starts a fresh window%s; management continues.%s\n\n
\n中文说明\n\n🔄 已重新武装:轮次计数开启新窗口%s,托管继续。%s\n\n
\n\n%s' "${REARM_FRESH_CLAUSE}" "${FROM_NOTE_REARM}" "${REARM_FRESH_CLAUSE_ZH}" "${FROM_NOTE_REARM_ZH}" "${FROM_MARKER}")" + REARM_BODY="$(printf '🔄 Takeover re-armed: the round counter starts a fresh window%s; management continues.%s%s\n\n
\n中文说明\n\n🔄 已重新武装:轮次计数开启新窗口%s,托管继续。%s%s\n\n
\n\n%s%s' "${REARM_FRESH_CLAUSE}" "${FROM_NOTE_REARM}" "${FOR_NOTE}" "${REARM_FRESH_CLAUSE_ZH}" "${FROM_NOTE_REARM_ZH}" "${FOR_NOTE_ZH}" "${FROM_MARKER}" "${FOR_MARKER}")" gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}" \ || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}"; } \ || { echo "::error::re-arm ack comment failed on #${PR} after one retry — the round window was NOT reset and no seed landed; re-run the command"; exit 1; } @@ -1887,7 +1815,7 @@ jobs: # only copy lives in this body, and the heal ack has no slot # to recover it — a transient 5xx must not silently un-seed # the window. - ENGAGE_BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s%s Remove the `%s` label (or comment `%s stop`) to release.\n\n
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n%s' "${FORK_NOTE}" "${FROM_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${FROM_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FROM_MARKER}")" + ENGAGE_BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s%s%s Remove the `%s` label (or comment `%s stop`) to release.\n\n
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s%s%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n%s%s' "${FORK_NOTE}" "${FROM_NOTE}" "${FOR_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${FROM_NOTE_ZH}" "${FOR_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FROM_MARKER}" "${FOR_MARKER}")" gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}" \ || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}"; } \ || echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it" @@ -2876,31 +2804,20 @@ jobs: # Eval markers the bot left after a previous evaluation carry the # newest feedback timestamp it already considered, plus the round. # Only our own comments are trusted, so a spoofed marker is ignored. - # NOTE: this regex is POSITIONAL — group .[0]=ts, .[2]=round — and must - # match the marker string emitted at every write site verbatim (search - # `autofix-eval ts=`: the push/report success, noop, and handoff steps). - # Inserting or reordering a field here or at any write site silently - # corrupts round tracking; keep the `ts= acted= round=` order in lockstep. + # POSITIONAL: .[0]=ts .[2]=round .[3]=win .[4]=run; every write + # site must emit that order verbatim. → qwen-autofix.md#af-159 MARKERS="$(jq -c --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") - | [ scan("") ] | .[] - | {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")" - # The watermark is GLOBAL across all markers — feedback already - # evaluated stays evaluated, no matter how counting windows move. - # The terminal-handoff SENTINEL ts is excluded: it is a flag, not - # an evaluation time, and letting it into the watermark would - # filter all future feedback forever — making a re-arm after a - # terminal handoff dead on arrival (terminal skipping itself is - # round-based and window-scoped, so re-arm properly clears it). + | [ scan("") ] | .[] + | {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), run: .[4], at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")" + # GLOBAL watermark; the terminal-handoff sentinel ts is a flag, + # not an evaluation time. → qwen-autofix.md#af-159 REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) | .created_at ] | max // ""' "${WORKDIR}/ic.json")" - # The head this PR's red checks were last reported against. Carried - # as its OWN marker inside the eval comment rather than a new field - # on autofix-eval, so none of the ts/acted/round parsers change — - # and the comment still matches the eval filter, so the agent never - # sees it as feedback. + # Last reported red head: its own marker, and it still matches the + # eval filter so the agent never sees it. → qwen-autofix.md#af-159 RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") | [ scan("") ] | .[] @@ -2936,23 +2853,44 @@ jobs: | ((.body // "") | [ scan("") ] | .[] | .[0]) ] | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" [[ "${ROUND_START}" =~ ^[0-9]{1,2}$ ]] || ROUND_START=0 - # Clamp strictly below the effective cap. The seed must be able to - # bring the Critical-only brake forward; it must NEVER be able to - # park a PR at its round cap on the very round it is taken over - # (which would stop the loop instead of starting it), and a seed - # is honoured on any PR whose window anchor carries the marker — - # including one whose takeover label was later removed, dropping - # EFF_MAX_ROUNDS back to the strict 10. + # Per ENGAGEMENT, not per window. → qwen-autofix.md#af-159 + BUDGET_FLOOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") as $b | ($b | contains("")) or ($b | contains("")) or (($b | contains("")) and (($b | contains("") ] | .[] | .[0]) ] | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" + [[ "${ROUND_BUDGET}" =~ ^[0-9]{1,2}$ ]] || ROUND_BUDGET=0 + IS_BOT_AUTHOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '((.author.login // "") == $ab)' <<< "${PR_META}")" + # One ATTEMPT = one run= identity, else the legacy ts|round pair. + BUDGET_USED="$(jq -r --arg f "${BUDGET_FLOOR}" '[ .[] | select(.win != "none" and .win >= $f) | (.run // "\(.ts)|\(.round)") ] | unique | length' <<< "${MARKERS}" 2> /dev/null || echo 0)" + [[ "${BUDGET_USED}" =~ ^[0-9]+$ ]] || BUDGET_USED=0 + BUDGET_SPENT=false + [[ "${ROUND_BUDGET}" -gt 0 && "${BUDGET_USED}" -ge "${ROUND_BUDGET}" ]] && BUDGET_SPENT=true + # A SPENT budget outlives the label's automatic removal; unspent dies with it. + [[ "${HAS_TAKEOVER:-false}" == "true" || ( "${IS_BOT_AUTHOR}" == 'true' && "${BUDGET_SPENT}" == 'true' ) ]] || { ROUND_BUDGET=0; BUDGET_SPENT=false; } + if [[ "${ROUND_BUDGET}" -gt 0 && "${ROUND_BUDGET}" -lt "${EFF_MAX_ROUNDS}" ]]; then + EFF_MAX_ROUNDS="${ROUND_BUDGET}" + fi + # A seed clamps strictly below the cap: it must never park the PR on + # the round that takes it over. → qwen-autofix.md#af-105 if [[ "${EFF_MAX_ROUNDS:-0}" -gt 0 && "${ROUND_START}" -ge "${EFF_MAX_ROUNDS}" ]]; then echo "🔢 #${PR}: round seed ${ROUND_START} clamped to $(( EFF_MAX_ROUNDS - 1 )) (effective cap ${EFF_MAX_ROUNDS})" ROUND_START=$(( EFF_MAX_ROUNDS - 1 )) fi ROUND="$(jq -r --arg key "${REARM_KEY}" --argjson start "${ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${MARKERS}")" + # A breaker stop is NOT a spent budget: a sentinel in the engagement, + # a round past the attempts, or a convergence notice parks the PR. + # Idle (silent-sandbox) timeouts write the sentinel too, but the + # cumulative breaker excludes them (af-073): map their runs and + # drop them here as well. + BUDGET_IDLE_RUNS="$(jq -c --arg ab "${AUTOFIX_BOT}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("(idle-timeout")) | (.body // "") | scan("actions/runs/([0-9]+)") | .[0] ] | unique' "${WORKDIR}/ic.json" 2> /dev/null || echo '[]')" + BUDGET_SENTINELS="$(jq -r --arg f "${BUDGET_FLOOR}" --argjson idle "${BUDGET_IDLE_RUNS:-[]}" '[ .[] | select(.win != "none" and .win >= $f) | select(.ts == "9999-12-31T23:59:59Z") | select((.run // "") as $r | ($idle | index($r)) == null) ] | length' <<< "${MARKERS}" 2> /dev/null || echo 0)" + [[ "${BUDGET_SENTINELS}" =~ ^[0-9]+$ ]] || BUDGET_SENTINELS=0 + BUDGET_HALTED=false + [[ "${ROUND}" -gt "$(( ROUND_START + BUDGET_USED ))" ]] && BUDGET_HALTED=true + [[ "${BUDGET_SENTINELS}" -ge "${TIMEOUT_WINDOW_CAP}" ]] && BUDGET_HALTED=true + BUDGET_CONV="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${REARM_KEY}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)" + [[ "${BUDGET_CONV}" =~ ^[0-9]+$ ]] || BUDGET_CONV=0 + [[ "${BUDGET_CONV}" != '0' ]] && BUDGET_HALTED=true # No mention of the Critical-only threshold here, deliberately: the - # scan must stay ignorant of that brake. It keeps SELECTING fresh - # suggestions so a no-op report can still advance the watermark; - # only prepare hides them from the agent. A test pins the scan - # against the string. + # scan stays ignorant of that brake and only prepare hides it. [[ "${ROUND_START}" != '0' ]] && echo "🔢 #${PR}: window seeded at round ${ROUND_START} → effective round ${ROUND}/${EFF_MAX_ROUNDS}" # Effective watermark = what the agent has actually evaluated (its last @@ -2968,9 +2906,60 @@ jobs: EFF_WM="${CREATED_WM}" fi - if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then - echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human" - fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry" + if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" || "${BUDGET_SPENT}" == 'true' ]]; then + # Budget spent: human PR released, bot PR paused. → qwen-autofix.md#af-159 + if [[ "${BUDGET_SPENT}" == 'true' && "${IS_BOT_AUTHOR}" != 'true' && "${BUDGET_HALTED}" != 'true' && ( -z "${FORCED_PR}" || "${EVENT_NAME}" != 'workflow_dispatch' ) ]]; then + BUDGET_RT="${REARM_KEY}" + [[ "${BUDGET_RT}" == "none" ]] && BUDGET_RT='' + BUDGET_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${BUDGET_RT}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.body // "") | contains("")) + | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")" + if [[ "${DRY_RUN}" == "true" ]]; then + echo "🧪 DRY-RUN: would release ${TAKEOVER_LABEL} on #${PR} after its ${ROUND_BUDGET}-round budget" + continue + fi + LIVE_LABELS_JSON="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null || echo '')" + LIVE_LABELS="$(jq -r '[.labels[]?.name] | join(" ")' <<< "${LIVE_LABELS_JSON}" 2> /dev/null || echo '')" + if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then + SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')" + fi + if [[ -z "${LIVE_LABELS_JSON}" ]]; then + echo "🧭 budget release skipped: label state unreadable (fail closed) on #${PR}" + elif [[ " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* || " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* ]]; then + echo "🧭 budget spent on #${PR} but consent changed (labels: ${LIVE_LABELS:-none}) — nothing to release" + elif [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then + echo "::warning::budget release skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" + else + if [[ "${BUDGET_NOTICED}" == "0" ]]; then + gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🏁 Takeover budget spent: %s round(s), %s attempt(s) used — the loop is releasing the PR now (removing the `%s` label); nothing here is waiting on a human. No-ops, errors, timeouts and crashes all count.\n\n
\n中文说明\n\n🏁 托管预算已用完:%s 轮预算、已用 %s 次尝试 —— 循环正在释放本 PR(移除 `%s` 标签),这里没有任何事情在等人处理。无改动、报错、超时或崩溃同样计数。\n\n
\n\n' "${ROUND_BUDGET}" "${BUDGET_USED}" "${TAKEOVER_LABEL}" "${ROUND_BUDGET}" "${BUDGET_USED}" "${TAKEOVER_LABEL}")" \ + || { echo "::warning::budget-spent notice failed for #${PR}; will retry next scan"; continue; } + fi + # REST, URI-encoded segment, 404-tolerant — every release site. + if ! BUDGET_DEL_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${TAKEOVER_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${BUDGET_DEL_ERR}" == *"HTTP 404"* ]] || echo "::warning::${TAKEOVER_LABEL} removal after budget failed for #${PR} — ${BUDGET_DEL_ERR}" + else + echo "🏷️ released ${TAKEOVER_LABEL} from #${PR} after a ${ROUND_BUDGET}-round budget" + # A deferred release paused first: clear that escalation. + if ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed after budget — ${NH_ERR}" + fi + fleet_row "${PR}" 'budget-spent' "budget ${BUDGET_USED}/${ROUND_BUDGET} spent - released" + fi + fi + continue + fi + if [[ "${BUDGET_SPENT}" == 'true' ]]; then + echo "🚧 #${PR}: ${BUDGET_USED}/${ROUND_BUDGET} budget spent — leaving for a human" + fleet_row "${PR}" 'budget-spent' "budget ${BUDGET_USED}/${ROUND_BUDGET} spent - needs a human" + else + echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human" + fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry" + fi + # A spent budget arrives with ROUND below the cap (no-ops never + # advance it), so the notices quote the attempts instead. + CAP_ROUND="${ROUND}" + [[ "${BUDGET_SPENT}" == 'true' ]] && CAP_ROUND="${BUDGET_USED}" # A FORCED dispatch refused here answers OUT LOUD. Observed on # #7836: the fleet shepherd detected a merge conflict, posted # Full rationale → qwen-autofix.md#af-031 @@ -2997,7 +2986,7 @@ jobs: fi if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then echo "::warning::cap-refused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" - elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n
\n中文说明\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then + elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n
\n中文说明\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n
\n\n' "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then echo "::warning::cap-refused notice failed for #${PR}" fi fi @@ -3032,8 +3021,7 @@ jobs: # A bot-authored PR released from takeover returns to STANDARD # management (its ack says so) — it is still managed, so at the # strict cap it deserves the same notice + escalation label as - # any managed PR (R4-5). - IS_BOT_AUTHOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '((.author.login // "") == $ab)' <<< "${PR_META}")" + # any managed PR (R4-5). IS_BOT_AUTHOR is computed once above. if [[ "${DRY_RUN}" == "true" ]]; then echo "🧪 DRY-RUN: would post cap-paused notice and apply ${NEEDS_HUMAN_LABEL} on #${PR}" else @@ -3085,9 +3073,9 @@ jobs: fi if [[ "${CAP_NOTICED}" == "0" ]]; then if [[ "${HAS_TAKEOVER}" == "true" ]]; then - CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n
\n中文说明\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")" + CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n
\n中文说明\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n
\n\n' "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")" else - CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n
\n中文说明\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n
\n\n' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")" + CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n
\n中文说明\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n
\n\n' "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${CAP_ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")" fi if ! gh pr comment "${PR}" --repo "${REPO}" --body "${CAP_BODY}"; then echo "::warning::cap-paused notice failed for #${PR}; will retry next scan" @@ -3197,17 +3185,9 @@ jobs: if [[ "${CONV_STREAK}" -ge "${CONVERGENCE_BREAK_ROUNDS}" ]]; then CONV_PARKED='true' else - # Release census = the park's own enumeration: a posted notice - # HOLDS the park until the boundary itself moves (a trusted- - # human response or a re-arm). Any clean round resets the - # streak — including the review a maintainer push triggers via - # synchronize — but must not silently release: the dedup - # counts notices newer than the boundary, so a release-then- - # re-trip would re-park under the stale notice with no fresh - # one. If the signal resumes, the streak census above re-parks - # loudly; this hold only keeps the quiet middle honest. - # Mirrored live in prepare and in the report guard; keep in - # lockstep. Full rationale → qwen-autofix.md#af-152 + # Release census = the park's own enumeration: a posted notice HOLDS the park until + # the boundary moves. Mirrored in prepare and the report guard — keep in lockstep. + # Full rationale → qwen-autofix.md#af-152 CONV_NOTICES="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg since "${CONV_SINCE}" ' [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) @@ -4361,8 +4341,8 @@ jobs: STALE='false' LIVE_MARKS="$(jq -r --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") - | [ scan("") ] | .[] - | {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")" + | [ scan("") ] | .[] + | {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), run: .[4], at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")" LIVE_REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) @@ -4398,6 +4378,13 @@ jobs: LIVE_ROUND_START=$(( MAX_ROUNDS - 1 )) fi LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" --argjson start "${LIVE_ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${LIVE_MARKS}")" + LIVE_BUDGET_FLOOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '[ .[] | select((.user.login // "") == $ab) | select((.body // "") as $b | ($b | contains("")) or ($b | contains("")) or (($b | contains("")) and (($b | contains("") ] | .[] | .[0]) ] | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" + LIVE_ATTEMPTS="$(jq -r --arg f "${LIVE_BUDGET_FLOOR}" '[.[] | select(.win != "none" and .win >= $f) | (.run // "\(.ts)|\(.round)")] | unique | length' <<< "${LIVE_MARKS}" 2> /dev/null || echo 0)" + [[ "${LIVE_ATTEMPTS}" =~ ^[0-9]+$ ]] || LIVE_ATTEMPTS=0 + LIVE_BUDGET_SPENT=false + [[ "${LIVE_ROUND_BUDGET}" -gt 0 && "${LIVE_ATTEMPTS}" -ge "${LIVE_ROUND_BUDGET}" ]] && LIVE_BUDGET_SPENT=true + [[ "${LIVE_ROUND_BUDGET}" =~ ^[0-9]{1,2}$ && ( "${LIVE_TAKEOVER}" == 'true' || ( "${LIVE_AUTHOR}" == "${AUTOFIX_BOT}" && "${LIVE_BUDGET_SPENT}" == 'true' ) ) ]] || LIVE_ROUND_BUDGET=0 # The head a sibling last judged, mirrored from the scan's RED_HEAD # parse. A no-op sibling records this marker while leaving BOTH ts and # round UNCHANGED — so the watermark/round triggers below never fire, @@ -4412,7 +4399,7 @@ jobs: # the maintainer asked for a fresh start, so a queued old-window job # must discard instead of finishing and stamping an old-sequence # marker into the new window. Conflicts stay actionable. - if [[ "${STALE}" != "true" && "${WINDOW:-none}" != "${LIVE_REARM_KEY}" && "${CONFLICT}" != "true" ]]; then + if [[ "${STALE}" != "true" && "${WINDOW:-none}" != "${LIVE_REARM_KEY}" && ( "${CONFLICT}" != "true" || "${LIVE_ROUND_BUDGET}" -gt 0 ) ]]; then STALE='true' echo "🫥 window superseded: selected under key '${WINDOW:-none}' but the live window is '${LIVE_REARM_KEY}' (re-armed while queued) — discarding without action or marker" fi @@ -4467,6 +4454,10 @@ jobs: STALE='true' echo "⛔ live round ${ROUND} already at MAX_ROUNDS (${MAX_ROUNDS}) — discarding without action or marker" fi + if [[ "${STALE}" != "true" && "${LIVE_ROUND_BUDGET}" -gt 0 && "${LIVE_ATTEMPTS}" -ge "${LIVE_ROUND_BUDGET}" ]]; then + STALE='true' + echo "⛔ attempt budget spent (${LIVE_ATTEMPTS}/${LIVE_ROUND_BUDGET}) — discarding without action or marker" + fi # Growth brake: measure the PR's net size (insertions minus # deletions vs the merge base), split into test lines and source # Full rationale → qwen-autofix.md#af-043 @@ -4509,27 +4500,18 @@ jobs: NET_SRC=$(( NET_TOTAL - NET_TEST )) [[ "${NET_MEASURED}" != 'true' ]] && echo "📏 growth measurement UNAVAILABLE this round (no merge base or shadowed base) — brake skipped, no anchor written" - # The marker's window field is spelled `key=`, NOT `win=`: this - # marker can legitimately carry a different window key than its - # Full rationale → qwen-autofix.md#af-045 + # Window field spelled `key=`, NOT `win=`. → qwen-autofix.md#af-045 BASE_UPD_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" ' [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("") ] | .[] @@ -4723,7 +4705,7 @@ jobs: gsub("^(?:(?:\\s||$)|\\p{Cf}))+"; ""; "s") | startswith("**[Critical]**"); ([ ($ic | add)[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") - | [ scan("") ] | .[] + | [ scan("") ] | .[] | {ts: .[0], acted: .[1], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ] | map(select(.win == $key) | select(.ts != "9999-12-31T23:59:59Z")) | sort_by(.at)) as $ms @@ -5110,7 +5092,7 @@ jobs: PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" ' [ .[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) ] | map(.[0] // "none")) as $wins + | select(([ ((.body // "") | scan("")) ] | map(.[0] // "none")) as $wins | ($wins | length) > 0 and (($wins | last) == $key)) ] | sort_by(.created_at) | map((.body | gsub("\r"; "") | split("\n")[0])) @@ -5159,7 +5141,7 @@ jobs: # ${RUNNER_TEMP}/qwen-bin ahead of /usr/bin here — pin PATH from # the stage-time capture BEFORE the first command word resolves # (the R6-3 doctrine the sibling PAT steps apply; the loop - # re-pins from the same capture). Full rationale → af-149 + # re-pins from the same capture). Full rationale → qwen-autofix.md#af-149 export PATH="${TRUSTED_PATH}" # The gh calls in this step (deep-link lookup, comment upsert) and # the heartbeat loop launched here all carry the bot PAT: take the @@ -6399,18 +6381,8 @@ jobs: if [[ -n "${MAIN_HEAD_R}" && -n "${REPORT_HEAD}" ]]; then CMP_R="$(gh api "repos/${REPO}/compare/${MAIN_HEAD_R}...${REPORT_HEAD}" --jq '.status' 2> /dev/null || echo '')" if [[ "${CMP_R}" == 'behind' || "${CMP_R}" == 'diverged' ]]; then - # Review-in-flight hold for the loop's OWN report-time - # head move (#10110), fail-open on probe errors. Wider - # than the scan's dispatch gate (#8888), which holds - # only on IN_PROGRESS now that a queued review can sit - # out a whole closed `ecs-review` window; a hold here - # only defers one stale-base merge the next round - # retries. The probe sees LIFECYCLE runs only: a - # command run executes against the base branch, so its - # review-pr check never shows under this PR's rollup, - # and the runs fallback is event-scoped to - # pull_request_target — an in-flight command review - # does NOT hold the refresh (af-155). + # Review-in-flight hold for the report-time head move (#10110, af-155): + # LIFECYCLE runs only, and fail-open on probe errors. # Full rationale → qwen-autofix.md#af-155 ROLLUP_R="$(gh pr view "${PR}" --repo "${REPO}" --json statusCheckRollup --jq '.statusCheckRollup // []' 2> /dev/null || echo '[]')" REVIEW_LIVE_R="$(jq -r ' @@ -6535,7 +6507,7 @@ jobs: [.[] | select((.user.login // "") == $ab) | select((.body // "") | contains("")) ] | map(.[0] // "none")) as $wins + ([ ((.body // "") | scan("")) ] | map(.[0] // "none")) as $wins | ($wins | length) > 0 and (($wins | last) == $win))] | sort_by(.created_at) | .[] | (.body | gsub("\r"; "") | split("\n")[0])' <<< "${COMMENTS_JSON}" 2> /dev/null || true)" @@ -6717,7 +6689,7 @@ jobs: echo "---" echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`${CLI_DISPLAY}" echo - echo "" + echo "" # Per-round growth history the census counts — same marker # the push/no-op report paths write, so an over-budget # round that timed out or was gate-rejected is not a gap. run= @@ -6741,7 +6713,11 @@ jobs: echo "" fi } > "${WORKDIR}/report.md" - gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}" + # The handoff is the ONLY carrier for an error/timeout/crash round, + # so it retries like the re-arm ack: one lost post is a free attempt. + gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" \ + || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"; } \ + || echo "::warning::Failed to post handoff comment on PR #${PR} after one retry — that round was not recorded" fi # A failed round must not lose verified deferred findings: the # agent's analysis is independent of whether this round's commit @@ -6817,18 +6793,7 @@ jobs: if: |- ${{ always() && steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }} env: - # Startup-channel pins, the gate steps' doctrine: BASH_ENV is - # sourced by bash at process STARTUP, before the launch below, - # and SHELLOPTS is the sibling option-import channel — a - # body-side unset is one hop late, so both are pinned empty at - # step level, which outranks any $GITHUB_ENV plant. The LD_* - # family is mapped by ld.so at startup the same way. These pins - # protect the parent shell that executes the launch; the - # BASH_FUNC_%% channel no env: block can enumerate — the - # attacker chooses the name, and bash imports the entries as - # functions at startup even under --norc, ahead of builtins and - # PATH — is closed by the env -i clean child below (R9-1), the - # gate's R6-4 pattern. + # Startup-channel pins: BASH_ENV/SHELLOPTS/LD_* at step level, BASH_FUNC via the env -i child. # Full rationale → qwen-autofix.md#af-149 BASH_ENV: '' SHELLOPTS: '' diff --git a/.github/workflows/qwen-fleet-shepherd.yml b/.github/workflows/qwen-fleet-shepherd.yml index d118581dfa6..07bae0d2d7a 100644 --- a/.github/workflows/qwen-fleet-shepherd.yml +++ b/.github/workflows/qwen-fleet-shepherd.yml @@ -540,8 +540,12 @@ jobs: # R6-3). local cands cts cauthor cage cperm reads=0 cands="$(jq -r --arg tc "${TAKEOVER_COMMAND}" --arg rc "${RETRY_COMMAND}" ' - [ .[] | select(((.body // "") | gsub("^\\s+|\\s+$"; "")) == $tc - or ((.body // "") | gsub("^\\s+|\\s+$"; "")) == $rc) + [ .[] | ((.body // "") | gsub("^\\s+|\\s+$"; "")) as $b + | ($b | ltrimstr($tc + " for ")) as $fn + | ($b | ltrimstr($tc + " from ")) as $sn + | select($b == $tc or $b == $rc + or ($fn != $b and ($fn | test("^[0-9]{1,2}$")) and (($fn | tonumber) >= 1)) + or ($sn != $b and ($sn | test("^[0-9]{1,2}$")))) | {ts: (.created_at // ""), login: (.user.login // "")} ] | group_by(.login) | map(max_by(.ts)) | sort_by(.ts) | reverse | .[] | "\(.ts)\t\(.login)"' "${ic}")" while IFS=$'\t' read -r cts cauthor; do diff --git a/docs/design/2026-10-24-autofix-takeover-round-budget.md b/docs/design/2026-10-24-autofix-takeover-round-budget.md new file mode 100644 index 00000000000..9f81e5d0b48 --- /dev/null +++ b/docs/design/2026-10-24-autofix-takeover-round-budget.md @@ -0,0 +1,236 @@ +# Autofix takeover round budget + +[English](2026-10-24-autofix-takeover-round-budget.md) | [简体中文](2026-10-24-autofix-takeover-round-budget.zh-CN.md) + +Status: implemented in this change, 2026-10-24. + +## Problem statement + +The `autofix/takeover` label is what hands a human-authored PR to the autofix +loop — applied by `@qwen-code /takeover` or by hand, since the scan posts a +first-pickup ack for a manual label too. It is deliberately persistent: it +raises the round cap from 10 to 100, and the ten-minute scan keeps managing the +PR until the label is removed or a circuit breaker fires. + +Maintainers routinely want something narrower — "look at this feedback, fix it, +then leave the PR alone" — and today they have to express it through the +persistent mechanism: + +- `@qwen-code /takeover from 99` spends the whole cap in one change-producing + round, so the next scan pauses the PR. The counter is already past + `CRITICAL_ONLY_AFTER_ROUND`, so that round is Critical-only and suggestions + are merely recorded; the pause also applies `autofix/needs-human` and posts a + "reached its round cap" notice, which reads as a failure rather than as a + finished, bounded engagement. A no-op round leaves the counter untouched, so + on a PR whose rounds keep finding nothing this workaround does not bound + anything at all. +- Bare `/takeover` followed by `/takeover stop` gives a full-scope round, but it + requires a human to watch the run and remember to stop it. Forgetting costs up + to 100 rounds. + +Neither workaround can state the actual intent, and neither bounds more than one +round. + +## Proposed change + +Add one parameterized form to the existing command: + +``` +@qwen-code /takeover for N +``` + +`N` is a one- or two-digit decimal `>= 1`. It engages (or re-arms) takeover +exactly like the bare command and additionally records an **engagement-scoped +round budget of N** on the engage/re-arm ack: + +``` + + +``` + +The effective round cap for that window becomes `min(takeover cap, N)`, so the +loop runs at most N rounds and then stops touching the PR. The budget and the +attempts spent are read per **engagement** — a maintainer command opens one, an +automatic growth-audit re-arm does not — so the audit keeps rebasing its round +window while the remaining budget still applies. A hand-applied label opens an +engagement too, and it carries no budget: it is the one route into management +that `for N` cannot bound. + +## Behavior at the boundary + +| State | What happens | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Budget spent on a human-authored PR | One bilingual "budget spent — released" note, then `autofix/takeover` is removed. No `autofix/needs-human`, no paused notice. A FORCED dispatch defers this to the next non-forced scan: that pass answers the dispatch out loud on the paused path, and the release that follows deletes the `autofix/needs-human` that pause applied. The deferred pass's notices quote the attempts spent, not the round counter a no-op-only engagement leaves at 0. | +| Budget spent on a bot-authored PR | The label is NOT removed (the bot PR is managed without it), so it lands on the existing paused path: paused notice + `autofix/needs-human`. A SPENT budget outlives any removal that leaves no `takeover-ack released` marker — the shepherd's three-day auto-release and a hand-removal alike — and the PR stays parked at N/N. An explicit `stop` posts that marker, which moves the engagement floor, so the budget ends there and standard management resumes, exactly as the ack says. An UNSPENT budget ends with the label either way. | +| A breaker stops the loop BEFORE the budget is spent | The existing paused path runs: paused notice + `autofix/needs-human`. A breaker asks for a human, so it is never reported as a finished budget. | +| A breaker's condition met ON the attempt that spends the budget | The budget wins: that attempt is already counted, so the PR takes the release path and its note instead of the paused one. The consecutive-failure breaker cannot fire on the round that reaches N (its entry gate skips the cap round), so no breaker diagnosis is posted for a window that ended by design. | +| `/retry` or a bare `/takeover` while a budgeted window is live | Opens a new window with no budget — unbounded takeover, as today. | +| `/retry` after the budget released a human-authored PR | Nothing happens until the PR is re-engaged. The release removed the label, so the PR is in neither selection pool; `/retry` posts its re-arm ack and says the counter reset, but no scan enumerates the PR. Use `/takeover` (with or without `for N`) to bring it back. | +| Budget window superseded while a round is queued | The window-key check discards the queued job, and — new for a budget — also discards a queued CONFLICT round, which that check otherwise exempts: the queued job runs on the old cap, so a live window carrying a budget marks it stale and the next scan re-selects it under the new window. | + +## Key design decisions + +1. **The budget counts attempts, and attempts are counted from eval markers.** + A no-op round must consume a turn, or a PR whose rounds keep finding nothing + would stay managed forever on a one-round budget. It writes + `` with its round UNCHANGED, so `ROUND` + alone under-counts it; counting markers counts it exactly once. The same + choice fixes the opposite error: a circuit breaker stamps its terminal + marker with `round=MAX_ROUNDS` after far fewer real attempts, and a sentinel + jump adds no marker, so five failures inside a twenty-round budget read as + five attempts — the breaker's escalation is preserved instead of being + mistaken for a finished budget. + +2. **The budget is engagement-scoped, and carried on the ack.** It rides as its + own marker on the comment that anchors the engagement, and it is read back + from every ack/re-arm at or after that anchor — the scan's `BUDGET_FLOOR` + predicate: a `takeover-ack engaged`, a `takeover-ack released` (which closes + the engagement, so an explicit `stop` ends the budget), or an `autofix-rearm` + that does NOT also carry an `autofix-eval ` marker. That exclusion is the whole point: it is + what tells a maintainer's command from the automatic growth-audit re-arm, + whose comment carries the round's eval marker. Round accounting + (`autofix-round-start`) stays window-keyed — equality with the ack comment's + `created_at` — so the two reads are deliberately NOT the same shape, and + collapsing the budget read into the window-key equality would silently drop + the budget and the spend census on every automatic re-arm. + +3. **Exhaustion releases instead of escalating.** `autofix/needs-human` means + "the loop stopped and a human must unstick something"; a finished budget + leaves nothing stuck, so the label and the paused notice would both be false. + Release is just the label DELETE, so the PR leaves the managed pool on its + own. A forced dispatch cannot release either: it has to answer the dispatch + out loud, so it pauses and the next non-forced scan releases. That release + therefore deletes the escalation label too — the note denies that anything + is waiting on a human, so the label its own deferral applied must not outlive + it — and the deferred pass's notices quote the attempts rather than a round + counter that no-ops never advance. + +4. **Bot-authored PRs keep the paused path.** They are managed whether or not + the label is present, so releasing it would hand them back to standard + ten-round management and the budget would be a lie. They are the one case + where the existing paused outcome is accurate. + +5. **`for` and `from` are mutually exclusive.** Both are single parameterized + forms with different meanings (suggestion budget vs. round budget); a body + carrying both, a `for 0`, a three-digit value, or any trailing text stays + "not an exact command" and does nothing. + +6. **A queued conflict round is superseded by a budgeted window.** Conflicts are + normally exempt from the superseded-window discard because they stay + actionable. A budget is different: the queued job carries the old cap and + would run past the new engagement limit, so a live window that carries a + budget discards it too and the next scan re-selects the conflict under the + new window. + +7. **An automatic re-arm rebases the growth window, and the budget rides + along.** The growth audit appends `` to open a fresh + window for its own round accounting, and suppressing that marker would pin + the growth baseline after the first over-budget round and make every later + round Critical-only — so it stays unconditional. It is not the only thing + that re-anchors the growth baseline: a base-branch update + (`autofix-base-updated`, which filters the earlier `autofix-growth-base` + markers out of the baseline read) and the scan's first-pickup heal ack move + it too. What keeps the budget honest is not the re-arm's exclusivity but its + key: budget and spend are read per ENGAGEMENT rather than per window — a + maintainer command opens one, an automatic re-arm (its comment also carries + that round's eval marker) does not — so the audit rebases the baseline while + the remaining budget and the attempts already spent continue to apply. + +8. **Prepare mirrors the scan's attempt census.** A queued round can outlive the + sibling that spent the window's last attempt: a no-op leaves `ROUND` at 0 and + the window key unchanged, so neither the superseded-window check nor the + round cap discards it. `review-address` therefore recounts the window's eval + markers and discards when the budget is already spent. + +## Files affected + +- `.github/workflows/qwen-autofix.yml` — parser, route output wire, engage/re-arm + ack markers, the scan's budget and attempt reads, the cap override, the + cap-branch release, and prepare's mirror of the same census (the live budget + read, the attempt recount and the two discard guards). +- `.github/workflows/qwen-fleet-shepherd.yml` — resume detection accepts the + parameterized command forms, so a `for N` or `from N` body between the + maintainer's comment and the ack landing is still read as evidence of a live + engagement rather than as an idle paused PR. +- `.github/workflows/qwen-autofix-round-seed.md` — operator guide for both + parameterized forms. +- `.github/workflows/qwen-autofix.md` — rationale record (af-159; af-160 carries + the `env:` prose this change moves out of the workflow). +- `.github/workflows/.size-baseline` — ratchet entry. +- `scripts/tests/qwen-autofix-workflow.test.js` — parser, ack, cap-read and + release coverage. + +The workflow file sits against this repository's 470,000-byte gate +(`.github/scripts/check-workflow-size.sh`, env-overridable, and ~42 KB below +GitHub's own 512,000-byte start-runs limit), so this change also moves +duplicated `env:` commentary into the rationale record (af-160), leaving a +one-line summary plus a pointer per constant. That is a prose move only — no +constant, default, or behaviour changes. + +## Scope boundaries + +- No change to the round caps, the failure/convergence breakers, the + Critical-only brake, or the growth budgets. The fleet shepherd is touched in + exactly one place — its resume detector learns the two parameterized command + forms — and its release policy is unchanged. +- No new labels. Budget exhaustion deliberately does NOT leave + `autofix/needs-human` on human-authored PRs: a deferred release clears the + one its own pause applied. +- No change to admission: the same author/write+ gate, fork rules and + `autofix/skip` precedence apply to `for N` as to the bare command. + +## Risks and limitations + +- A run that is cancelled or dies in setup still spends an attempt: the + report step is gated on `always()`, so it reaches its neither-success-nor- + failure branch and writes an eval marker with a sentinel timestamp and the + round advanced by one. Only a run killed before that finalizer runs is free. + So the error direction is an EARLY release, not a delayed one, and the + alternative (a dedicated counter that the finalizer incremented) would not + change it. +- The "budget spent" note posts before the DELETE, and is deduplicated per + window: if the DELETE fails, the next scan retries only the DELETE. +- The release note must land before the label goes: a failed note leaves the + label in place and the next scan retries both, because once the label is gone + the PR is in neither selection pool and nothing would ever re-post it. +- A budget below `CRITICAL_ONLY_AFTER_ROUND` keeps every budgeted round + suggestion-capable. That is intended: the point of a budget is to shorten the + engagement, not to narrow it. +- The budget is live while `autofix/takeover` is present, or — for a bot-authored + PR whose budget is already SPENT — until a `takeover-ack released` marker moves + the engagement floor. So an explicit `stop` ends it, while the shepherd's + silent auto-release (and a hand-removal, which leaves no marker either) leaves + the PR parked at N/N with `autofix/needs-human` until a new command moves the + floor. An unspent budget dies with the label, which is what keeps `stop` able + to clear a bot PR that was paused mid-budget. +- The budget rides on the engage ack, so an ack that never lands loses it and the + engagement degrades to unbounded takeover. This is the seed marker's existing + exposure rather than a new failure mode: the command job retries the ack once + and warns loudly. The scan's heal ack has two triggers — no ack at all, or a + label event newer than the last ack — and neither carries the budget, so a + maintainer who re-applies the label by hand re-anchors the window and silently + loses the remaining budget. A hand-applied label never had one: the label, not + the command, is what engages management. Persisting the number anywhere else + would need a second writer for a case the label already makes visible. + +## Validation + +- Parser replay of every accepted and rejected body shape. +- Ack replay pinning the marker on both the fresh-engage and re-arm paths. +- Cap-read replay proving the budget lowers the effective cap, that the + engagement floor (not the window key) is what carries it across an automatic + re-arm, and that the census keeps `win=none` markers out. +- Cap-branch replay proving the release path posts one note, deletes the label, + and never APPLIES `autofix/needs-human` — while the deferred (forced-dispatch) + pass pauses, quotes the attempts in its notices, and has its escalation + cleared by the release that follows, 404 included. +- Not verifiable locally end to end: the command only runs in the upstream + repository with the bot PAT. The shell blocks are replayed verbatim from the + workflow file, so drift fails the suite. + +## Open questions + +- Should `for N` also be accepted together with `from M` (e.g. "this PR already + had 4 rounds, give it 2 more")? Deferred: the two knobs answer different + questions and combining them doubles the parser surface for a case no + maintainer has asked for. diff --git a/docs/design/2026-10-24-autofix-takeover-round-budget.zh-CN.md b/docs/design/2026-10-24-autofix-takeover-round-budget.zh-CN.md new file mode 100644 index 00000000000..3c855e66379 --- /dev/null +++ b/docs/design/2026-10-24-autofix-takeover-round-budget.zh-CN.md @@ -0,0 +1,101 @@ +# Autofix 托管的轮次预算 + +[English](2026-10-24-autofix-takeover-round-budget.md) | [简体中文](2026-10-24-autofix-takeover-round-budget.zh-CN.md) + +状态:本次改动中实现,2026-10-24。 + +## 问题陈述 + +`autofix/takeover` 标签才是把人工提交的 PR 交给 autofix 循环的入口 —— 既可由 `@qwen-code /takeover` 打上,也可手工贴;手工贴标签时扫描同样会发首次接管的 ack。它刻意是常驻的:把轮次上限从 10 抬到 100,之后每十分钟一次的扫描持续管理该 PR,直到标签被移除或熔断器触发。 + +维护者经常要的其实是更窄的一件事 —— "看一遍这批反馈,修掉,然后别再动这个 PR" —— 而现在只能用常驻机制去表达: + +- `@qwen-code /takeover from 99` 把整个上限在一轮**产出改动**的轮次里花掉,下一次扫描就把 PR 暂停。此时计数早已越过 `CRITICAL_ONLY_AFTER_ROUND`,那一轮是 Critical-only、Suggestions 只记录不修;暂停还会打上 `autofix/needs-human` 并发"已达到轮次上限"的通知,读起来像故障,而不是一次正常结束的有界托管。而 no-op 轮次不会推进计数,所以在"每轮都没发现要改"的 PR 上,这个绕法根本约束不住任何东西。 +- 裸 `/takeover` 再加 `/takeover stop` 可以得到完整范围的一轮,但需要人盯着运行并记得收手。忘了就要付最多 100 轮的代价。 + +两种绕法都说不出真实意图,而且都只能约束"一轮"。 + +## 拟议改动 + +在现有命令上增加一种参数化形式: + +``` +@qwen-code /takeover for N +``` + +`N` 是一位或两位十进制整数且 `>= 1`。它的接管语义与裸命令完全一致,只是在 engage / re-arm 的 ack 上额外记录一个**按"托管代"作用域的轮次预算 N**: + +``` + + +``` + +该窗口的有效轮次上限变为 `min(接管上限, N)`,于是循环最多跑 N 轮,之后不再触碰该 PR。预算与已耗尝试都按**托管代**读取 —— 维护者命令开启一代,自动的增长审计 re-arm 不开启 —— 因此审计照常换基其轮次窗口,而剩余额度继续生效。手工贴标签同样开启一代,且它不带预算:这是 `for N` 无法约束的唯一入口。 + +## 边界处的行为 + +| 状态 | 结果 | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 人工提交的 PR 预算耗尽 | 发一条中英双语"预算已用完 —— 已释放"通知,随后移除 `autofix/takeover`。不打 `autofix/needs-human`,也不发暂停通知。**强制 dispatch** 会把释放推迟到下一趟非强制扫描:那一趟必须出声回应本次调度,因此走暂停路径,随后的释放会连带删掉那次暂停打上的 `autofix/needs-human`。被推迟那一趟的通知引用的是已耗尝试数,而不是 no-op-only 托管代始终停在 0 的轮次计数。 | +| bot 提交的 PR 预算耗尽 | 不移除标签(bot PR 无标签也受管),因此走既有的暂停路径:暂停通知 + `autofix/needs-human`。**已耗尽**的预算会活过任何没有留下 `takeover-ack released` 标记的摘标签——shepherd 的三天自动释放与手工摘除都一样——该 PR 就停在 N/N;而显式 `stop` 会留下该标记、把托管代下限推后,预算随之结束、标准管理恢复,与 ack 的说法一致。**未耗尽**的预算在两种情形下都随标签结束。 | +| 熔断器在预算耗尽前停下循环 | 走既有暂停路径:暂停通知 + `autofix/needs-human`。熔断器要求人工介入,因此绝不会被当成"预算正常用完"。 | +| 熔断条件恰好命中"花完预算"的那一次尝试 | 预算优先:该尝试已经被计入,于是走释放路径与释放通知,而不是暂停。连续失败熔断器在到达 N 的那一轮无法触发(其入口门控跳过上限轮),因此按设计结束的窗口不会发布熔断诊断。 | +| 预算窗口仍生效时执行 `/retry` 或裸 `/takeover` | 开启无预算的新窗口 —— 即今天的无上限托管。 | +| 预算已释放人工 PR 之后再执行 `/retry` | 在被重新接管之前什么都不会发生。释放已移除标签,该 PR 不在任何选择池中;`/retry` 会发出 re-arm 的 ack 并声称计数已重置,但没有任何扫描会枚举这个 PR。要恢复管理请用 `/takeover`(可带可不带 `for N`)。 | +| 窗口被取代时仍有排队中的轮次 | 窗口键检查会丢弃排队作业,并且 —— 预算新增的一层 —— 连该检查原本豁免的 conflict 轮次也一并丢弃:排队作业带着旧上限,因此"带预算的当前窗口"会判它为过期,下一次扫描按新窗口重新选中它。 | + +## 关键设计决策 + +1. **预算计的是"尝试次数",而尝试次数数的是 eval 标记。** no-op 轮次也必须消耗一次,否则"每轮都没发现要改"的 PR 会在只给一轮预算时被永远托管下去。no-op 写的是 ``,轮次号不变,所以只看 `ROUND` 会漏计;数标记则恰好计一次。同一个选择也修掉了相反方向的错误:熔断器在真实尝试远少于上限时,用 `round=MAX_ROUNDS` 写下终止标记,而哨兵跳变不产生新标记 —— 二十轮预算里的五次失败因此读作五次尝试,熔断器要求的升级得以保留,不会被误判为预算耗尽。 + +2. **预算按"托管代"作用域,并寄存在 ack 上。** 它作为独立标记挂在锚定这一代的评论上,并从该锚点及其之后的每一条 ack / re-arm 读回 —— 即扫描侧的 `BUDGET_FLOOR` 谓词:一条 `takeover-ack engaged`、一条 `takeover-ack released`(它关闭托管代,因此显式 `stop` 会结束预算),或一条**不带** `autofix-eval ` 标记的 `autofix-rearm`。那个排除正是全部要点:它把维护者的命令与自动的增长审计 re-arm(其评论带该轮的 eval 标记)区分开。轮次记账(`autofix-round-start`)仍是窗口级的 —— 与 ack 评论的 `created_at` 相等 —— 所以两种读取刻意不是同一形状;把预算读取合流成窗口键相等,会在每次自动 re-arm 时静默丢掉预算与消耗计数。 + +3. **耗尽时释放,而不是升级。** `autofix/needs-human` 的语义是"循环停了,必须由人解锁";预算正常用完没有留下任何卡点,标签与暂停通知都会是假话。释放就是一次标签 DELETE,PR 自己离开托管池。强制 dispatch 同样不能释放:它必须出声回应本次调度,因此先暂停,由下一趟非强制扫描释放。所以那次释放也要删掉升级标签 —— 通知否认有任何事情在等人,它自己推迟时打上的标签就不能活过这条通知 —— 而被推迟那一趟的通知引用尝试数,而不是 no-op 永不推进的轮次计数。 + +4. **bot 提交的 PR 保留暂停路径。** 它们有没有标签都受管,移除标签等于交回标准十轮管理,预算就变成了谎话。只有这一类 PR 上,"暂停待人工"才是准确的结论。 + +5. **`for` 与 `from` 互斥。** 两者都是单一参数化形式但含义不同(Suggestions 预算 vs 轮次预算);同时携带、`for 0`、三位数、任何尾随文本都保持"不是精确命令"并什么都不做。 + +6. **有预算的新窗口连排队中的 conflict 轮次也作废。** conflict 通常豁免于"窗口被取代"的丢弃,因为它始终可处理。预算不同:排队的作业带着旧上限,会越过新的托管上限继续跑,因此"带预算的当前窗口"也会丢弃它,下一次扫描按新窗口重新选中该 conflict。 + +7. **自动 re-arm 照常换基增长窗口,预算随之延续。** growth audit 追加 `` 为自身记账开新窗口;抑制它会恰好在第一次超预算的轮次之后把增长基线钉死,之后每轮都变成 Critical-only —— 所以它保持无条件。但它并不是唯一会重锚增长基线的东西:base 分支更新(`autofix-base-updated`,它把更早的 `autofix-growth-base` 标记从基线读取中滤掉)以及扫描侧的首次接管补发 ack 同样会移动它。让预算保持诚实的不是 re-arm 的排他性,而是它的键:预算与花费按"托管代"读取,而非按窗口 —— 维护者命令开启一代,自动 re-arm(其评论同时带该轮的 eval 标记)不开启,于是审计照常换基,而剩余额度与此前已耗次数继续生效。 + +8. **prepare 复刻扫描的尝试计数。** 排队中的轮次可能比"花掉本窗口最后一次尝试"的兄弟活得更久:no-op 让 `ROUND` 停在 0、窗口键也没变,所以"窗口被取代"检查与轮次上限都拦不住它。`review-address` 因此自己重数本窗口的 eval 标记,预算已耗尽就丢弃。 + +## 影响文件 + +- `.github/workflows/qwen-autofix.yml` —— 解析器、route 输出接线、engage / re-arm ack 标记、scan 侧的预算与尝试计数读取、上限覆盖、cap 分支的释放,以及 prepare 对同一套计数的复刻(live 预算读取、尝试重数、两处丢弃守卫)。 +- `.github/workflows/qwen-fleet-shepherd.yml` —— 恢复探测接受参数化命令形式,因此维护者发命令到 ack 落地之间,`for N` 或 `from N` 的评论体仍被读作"托管代仍在进行"的证据,而不是闲置的暂停 PR。 +- `.github/workflows/qwen-autofix-round-seed.md` —— 两种参数化形式的操作指南。 +- `.github/workflows/qwen-autofix.md` —— 设计依据记录(af-159;af-160 承载本次移出 workflow 的 `env:` 说明)。 +- `.github/workflows/.size-baseline` —— 体积棘轮条目。 +- `scripts/tests/qwen-autofix-workflow.test.js` —— 解析、ack、上限读取与释放路径的覆盖。 + +workflow 文件紧贴**本仓库**的 470,000 字节门禁(`.github/scripts/check-workflow-size.sh`,可用环境变量覆盖,且比 GitHub 自身 512,000 字节的启动上限低约 42 KB),因此本次改动同时把 `env:` 里重复的说明搬进设计依据记录(af-160),每个常量只保留一行摘要加指针。这只是文字搬移,不改任何常量、默认值或行为。 + +## 范围边界 + +- 不改轮次上限、失败/收敛熔断器、Critical-only 刹车或增长预算。fleet shepherd 只动一处——其恢复探测认识两种参数化命令——释放策略不变。 +- 不新增标签。人工 PR 上预算耗尽刻意不**留下** `autofix/needs-human`:被推迟的释放会清掉它自己那次暂停打上的标签。 +- 不改准入:同样的作者/write+ 门禁、fork 规则与 `autofix/skip` 优先级都适用于 `for N`。 + +## 风险与限制 + +- 被取消或在 setup 阶段就失败的运行**仍会**消耗一次尝试:报告步骤挂在 `always()` 上,因此它会走到"既非成功也非失败"的分支,写下一个带哨兵时间戳、轮次 +1 的 eval 标记。只有在那一步收尾之前就被杀掉的运行才是免费的。所以误差方向是**提前**释放而非延后,替代方案(只由收尾步骤递增的专用计数器)也改不了这一点。 +- "预算已用完"通知先于 DELETE 发布,并按窗口去重:DELETE 失败时,下一次扫描只重试 DELETE。 +- 释放通知必须先落地、标签才能消失:通知失败就保留标签,下一次扫描两者都重试 —— 因为标签一旦消失,该 PR 不在任何选择池中,再也不会有谁去重发那条通知。 +- 预算低于 `CRITICAL_ONLY_AFTER_ROUND` 时,每一轮都保留 Suggestions 通道。这是有意的:预算要缩短的是托管时长,不是范围。 +- 预算在 `autofix/takeover` 存在时生效;对**已耗尽**预算的 bot 作者 PR,它还一直生效到一条 `takeover-ack released` 标记把托管代下限推后为止。所以显式 `stop` 能结束它;而 shepherd 的静默自动释放(以及同样不留标记的手工摘除)不会——该 PR 就停在 N/N 并挂着 `autofix/needs-human`,直到新的命令推后下限。未耗尽的预算则随标签一起结束,这正是 `stop` 能把预算中途被暂停的 bot PR 解出来的原因。 +- 预算寄存在 engage ack 上,ack 发不出去就等于丢了预算、退化成无上限托管。这不是新增的失败模式,而是 seed 标记本来就有暴露:命令 job 会重试一次并大声告警。扫描侧的补发 ack 有两种触发 —— 完全没有 ack,或出现晚于上一条 ack 的标签事件 —— 两者都不带预算,因此手工重新贴标签会重锚窗口并静默丢掉剩余额度。手工贴标签本来就不带预算:真正让 PR 进入托管的是标签,而不是命令。要在别处持久化这个数字,就得为"标签本身已经可见"的情形再加一个写入方。 + +## 验证 + +- 对每一种接受与拒绝的评论体做解析回放。 +- ack 回放,钉住全新 engage 与 re-arm 两条路径上的标记。 +- 上限读取回放,证明预算会压低有效上限、跨自动 re-arm 携带它的是托管代下限(而不是窗口键),且计数会排除 `win=none` 的标记。 +- cap 分支回放,证明释放路径只发一条通知、删除标签,且从不**打上** `autofix/needs-human` —— 同时覆盖被推迟(强制 dispatch)的那一趟:它暂停、通知里引用尝试数,并由随后的释放清掉升级标签,404 也算通过。 +- 无法在本地端到端验证:该命令只在上游仓库用 bot PAT 运行时才真正生效。测试是逐字回放 workflow 里的 shell 片段,因此一旦漂移就会失败。 + +## 未决问题 + +- 是否允许 `for N` 与 `from M` 同时使用(例如"这个 PR 已经过 4 轮,再给 2 轮")?暂缓:两个旋钮回答的是不同问题,组合会把解析面翻倍,而目前没有维护者提出这个需求。 diff --git a/scripts/tests/qwen-autofix-workflow.test.js b/scripts/tests/qwen-autofix-workflow.test.js index f9c03816ff0..2da64b33434 100644 --- a/scripts/tests/qwen-autofix-workflow.test.js +++ b/scripts/tests/qwen-autofix-workflow.test.js @@ -3562,6 +3562,1143 @@ describe('qwen-autofix workflow', () => { expect(loopHead).not.toContain('INSPECTED='); }); + it('counts one attempt per run, parks a breaker stop, and keeps a spent bot budget', () => { + // Three shapes the budget boundary got wrong: (1) the census key, (2) a + // breaker stop that spends the budget on its way out, (3) the label an + // automatic release takes away from a spent bot engagement. + const budgetBlock = reviewScanJob.match( + /(BUDGET_FLOOR="\$\(jq[\s\S]*?\n {12}fi)/, + )?.[1]; + expect(budgetBlock).toBeTruthy(); + const haltBlock = reviewScanJob.match( + /(BUDGET_SENTINELS="\$\(jq[\s\S]*?BUDGET_CONV}" != '0' \]\] && BUDGET_HALTED=true)/, + )?.[1]; + expect(haltBlock).toBeTruthy(); + const read = ({ + markers, + budget, + round = '0', + start = '0', + bot = 'false', + label = 'true', + notices = [], + }) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-budget-identity-')); + try { + writeFileSync( + join(dir, 'ic.json'), + JSON.stringify([ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T00:00:00Z', + body: `\n`, + }, + ...notices, + ]), + ); + const out = execFileSync( + 'bash', + [ + '-c', + [ + "AUTOFIX_BOT='qwen-code-dev-bot'", + `WORKDIR='${dir}'`, + "REARM_KEY='2026-07-18T00:00:00Z'", + `ROUND_BUDGET='${budget}'`, + "BUDGET_FLOOR=''", + "TIMEOUT_WINDOW_CAP='3'", + `PR_META='${JSON.stringify({ author: { login: bot === 'true' ? 'qwen-code-dev-bot' : 'maint-a' } })}'`, + `HAS_TAKEOVER='${label}'`, + `MARKERS='${JSON.stringify(markers)}'`, + "EFF_MAX_ROUNDS='100'", + budgetBlock.replace(/\n {12}/g, '\n'), + `ROUND='${round}'`, + `ROUND_START='${start}'`, + haltBlock.replace(/\n {12}/g, '\n'), + 'printf \'%s|%s|%s|%s\' "$ROUND_BUDGET" "$EFF_MAX_ROUNDS" "$BUDGET_SPENT" "$BUDGET_HALTED"', + ].join('\n'), + ], + { encoding: 'utf8' }, + ).trim(); + const [kept, eff, spent, halted] = out.split('|'); + return { kept, eff, spent, halted }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const mk = (round, run, ts = '2026-07-18T10:00:00Z') => ({ + ts, + round, + win: '2026-07-18T00:00:00Z', + run, + at: '', + }); + const SENTINEL = '9999-12-31T23:59:59Z'; + const notice = (body) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T11:00:00Z', + body, + }); + + // (1) Two executions that report the same watermark and round are two + // attempts; without a per-attempt identity they collapsed into one and a + // conflicted for-2 PR re-ran every tick forever. + expect( + read({ + markers: [mk(2, 'run-a'), mk(2, 'run-b')], + budget: '2', + round: '2', + }), + ).toEqual({ kept: '2', eff: '2', spent: 'true', halted: 'false' }); + // A legacy marker pair with no run= still dedupes, so a re-run cannot + // double-charge a budget written before the identity existed. + expect( + read({ markers: [mk(2, ''), mk(2, '')], budget: '2', round: '2' }).spent, + ).toBe('false'); + // Two distinct runs are also two attempts for the prepare-side census. + expect( + read({ + markers: [mk(1, 'run-a'), mk(1, 'run-b')], + budget: '2', + round: '1', + }).spent, + ).toBe('true'); + + // (2) A breaker stop is not a spent budget. The consecutive-failure breaker + // jumps the round to MAX_ROUNDS with a real timestamp, so the trailing + // sentinel streak stays 0 — the round/attempt comparison is what catches it. + expect( + read({ + markers: [mk(5, 'a'), mk(6, 'b'), mk(7, 'c'), mk(8, 'd'), mk(10, 'e')], + budget: '5', + round: '10', + }).halted, + ).toBe('true'); + // The cumulative timeout breaker spends the budget with no trailing streak. + expect( + read({ + markers: [ + mk(1, 'a', SENTINEL), + mk(1, 'b'), + mk(2, 'c', SENTINEL), + mk(2, 'd'), + mk(3, 'e', SENTINEL), + ], + budget: '5', + round: '3', + }).halted, + ).toBe('true'); + // The convergence breaker pushes with real timestamps; its notice is the + // only artifact, and its continue preempts the park, so the gate reads it. + expect( + read({ + markers: [mk(1, 'a'), mk(2, 'b'), mk(3, 'c')], + budget: '3', + round: '3', + notices: [notice('')], + }).halted, + ).toBe('true'); + // A genuinely spent budget is not halted. + expect( + read({ + markers: [mk(1, 'a'), mk(2, 'b')], + budget: '2', + round: '2', + }).halted, + ).toBe('false'); + // ONE sentinel is not a halt: the threshold is TIMEOUT_WINDOW_CAP, so a + // single crashed attempt spends its turn and the window goes on. Lowering + // the bound to `-ge 1` reds this. + expect( + read({ + markers: [mk(1, 'a', SENTINEL), mk(2, 'b')], + budget: '3', + round: '2', + }).halted, + ).toBe('false'); + // …and a convergence notice OLDER than the window key is not a halt + // either: only a notice inside the current window counts. + expect( + read({ + markers: [mk(1, 'a')], + budget: '2', + round: '1', + notices: [ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-17T23:00:00Z', + body: '', + }, + ], + }).halted, + ).toBe('false'); + + // (3) The shepherds 3-day auto-release takes the label away from a paused + // bot PR. A spent budget must survive that, or EFF falls back to 10 and the + // PR is re-selected for rounds N+1..10. + expect( + read({ + markers: [mk(1, 'a'), mk(2, 'b'), mk(3, 'c')], + budget: '3', + round: '3', + bot: 'true', + label: 'false', + }), + ).toEqual({ kept: '3', eff: '3', spent: 'true', halted: 'false' }); + // An UNSPENT budget still dies with the label… + expect( + read({ + markers: [mk(1, 'a')], + budget: '3', + round: '1', + bot: 'true', + label: 'false', + }).kept, + ).toBe('0'); + // …and a human release keeps no cap at all (af-160: removing the label + // restores the strict cap). + expect( + read({ + markers: [mk(1, 'a'), mk(2, 'b'), mk(3, 'c')], + budget: '3', + round: '3', + bot: 'false', + label: 'false', + }).kept, + ).toBe('0'); + }); + + it('bounds a for-N window and releases the PR when the budget is spent', () => { + // The budget is a CAP, not a counter: the scan reads its own marker off + // the window anchor and lowers EFF_MAX_ROUNDS, so every existing cap gate + // applies unchanged. Replay the read VERBATIM (drift fails) — window + // scoping is the property that keeps a spent budget from leaking into the + // next window, so it is worth a behavioral test rather than a substring. + const budgetRead = reviewScanJob.match( + /(BUDGET_FLOOR="\$\(jq[\s\S]*?\n {12}fi)/, + )?.[1]; + expect(budgetRead).toBeTruthy(); + const budget = (comments, key, hasLabel = 'true', cap = '100') => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-budget-read-')); + try { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); + return execFileSync( + 'bash', + [ + '-c', + `AUTOFIX_BOT='qwen-code-dev-bot'\nREARM_KEY='${key}'\nWORKDIR='${dir}'\nHAS_TAKEOVER='${hasLabel}'\nEFF_MAX_ROUNDS='${cap}'\n${budgetRead.replace(/\n {12}/g, '\n')}\nprintf '%s' "$EFF_MAX_ROUNDS"`, + ], + { encoding: 'utf8' }, + ).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const anchor = (at, body) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: at, + body, + }); + // The anchor's marker caps the window… + expect( + budget( + [ + anchor( + '2026-07-18T10:00:00Z', + '\n', + ), + ], + '2026-07-18T10:00:00Z', + ), + ).toBe('2'); + // …an anchored window without one keeps the takeover cap… + expect( + budget( + [anchor('2026-07-18T10:00:00Z', '')], + '2026-07-18T10:00:00Z', + ), + ).toBe('100'); + // …and a PREVIOUS window's budget never applies to the current one. + expect( + budget( + [ + anchor( + '2026-07-18T09:00:00Z', + '\n', + ), + anchor('2026-07-18T10:00:00Z', ''), + ], + '2026-07-18T10:00:00Z', + ), + ).toBe('100'); + // Only a bot-authored, bounded, single/double-digit marker counts: a + // maintainer cannot hand-edit a cap onto the anchor. + expect( + budget( + [ + { + user: { login: 'human-a' }, + created_at: '2026-07-18T10:00:00Z', + body: '', + }, + ], + '2026-07-18T10:00:00Z', + ), + ).toBe('100'); + expect( + budget( + [anchor('2026-07-18T10:00:00Z', '')], + '2026-07-18T10:00:00Z', + ), + ).toBe('100'); + // The budget dies with the label: `stop` posts a `takeover-ack released` + // ack that never moves the window anchor, so without this gate a + // bot-authored PR would sit at N/N and re-apply needs-human forever. + expect( + budget( + [anchor('2026-07-18T10:00:00Z', '')], + '2026-07-18T10:00:00Z', + 'false', + ), + ).toBe('100'); + // The budget only ever LOWERS the cap. Replay an incoming cap below the + // marker — the state the takeover label would restore after a release, and + // the shape a future cap change could reach — so the `-lt` half of the + // clamp is exercised: without it a stale marker would RAISE a strict 10 to + // 20, the inverse of the documented invariant. + expect( + budget( + [anchor('2026-07-18T10:00:00Z', '')], + '2026-07-18T10:00:00Z', + 'true', + '10', + ), + ).toBe('10'); + + // The boundary behavior: a spent budget RELEASES a human-authored PR + // (note + label DELETE, no escalation) and never promotes the PR to + // autofix/needs-human. Replay with a PATH-stubbed gh. + const release = reviewScanJob.match( + /(if \[\[ "\$\{BUDGET_SPENT\}" == 'true'[\s\S]*?\n {14}fi)/, + )?.[1]; + expect(release).toBeTruthy(); + const runRelease = ({ + labelsJson = '{"labels":[{"name":"autofix/takeover"}]}', + notices = [], + dryRun = 'false', + actor = 'qwen-code-dev-bot', + writeFails = false, + } = {}) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-budget-release-')); + try { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(notices)); + writeFileSync(join(dir, 'labels.json'), labelsJson); + writeFileSync( + join(dir, 'gh'), + [ + '#!/bin/bash', + 'if [[ "$1" == "pr" && "$2" == "view" ]]; then cat ' + + join(dir, 'labels.json') + + '; exit 0; fi', + 'if [[ "$1" == "api" && "$2" == "user" ]]; then printf "%s" "$STUB_ACTOR"; exit 0; fi', + 'echo "WRITE $*" >> ' + join(dir, 'writes.log'), + 'if [[ -n "${STUB_WRITE_FAIL:-}" && "$*" == *"labels/"* ]]; then echo "HTTP 502" >&2; exit 1; fi', + 'exit 0', + ].join('\n'), + ); + chmodSync(join(dir, 'gh'), 0o755); + writeFileSync(join(dir, 'writes.log'), ''); + writeFileSync(join(dir, 'fleet.log'), ''); + const stdout = execFileSync( + 'bash', + [ + '-c', + `fleet_row() { printf '%s|%s\\n' "$2" "$3" >> ${join(dir, 'fleet.log')}; }\nfor _ in 1; do\n${release.replace(/\n {14}/g, '\n')}\necho FELLTHROUGH\ndone`, + ], + { + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + WORKDIR: dir, + PR: '7165', + REPO: 'QwenLM/qwen-code', + TAKEOVER_LABEL: 'autofix/takeover', + SKIP_LABEL: 'autofix/skip', + NEEDS_HUMAN_LABEL: 'autofix/needs-human', + AUTOFIX_BOT: 'qwen-code-dev-bot', + REARM_KEY: '2026-07-18T10:00:00Z', + ROUND_BUDGET: '2', + ROUND: '2', + BUDGET_USED: '2', + BUDGET_STREAK: '0', + FORCED_PR: '', + TIMEOUT_WINDOW_CAP: '3', + BUDGET_SPENT: 'true', + BUDGET_HALTED: 'false', + DRY_RUN: dryRun, + IS_BOT_AUTHOR: 'false', + STUB_ACTOR: actor, + STUB_WRITE_FAIL: writeFails ? '1' : '', + }, + encoding: 'utf8', + }, + ); + return { + log: stdout, + writes: readFileSync(join(dir, 'writes.log'), 'utf8'), + fleet: readFileSync(join(dir, 'fleet.log'), 'utf8'), + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const released = runRelease(); + expect(released.log).not.toContain('FELLTHROUGH'); + expect(released.writes).toContain(''); + // The fleet row describes what happened: it is written by the DELETE's + // success branch, so it can only appear once the label is actually gone. + expect(released.fleet).toContain('budget-spent|'); + expect(released.fleet).toContain('released'); + // The release is the URI-encoded REST DELETE every other site uses, and + // it never APPLIES the escalation label — a deferred release deletes the + // one its own pause applied, which is the opposite write. + expect(released.writes).toContain('labels/autofix%2Ftakeover'); + expect(released.writes).not.toContain('labels[]=autofix/needs-human'); + expect(released.writes).toContain('labels/autofix%2Fneeds-human'); + // The note precedes the DELETE, so a failed removal retries next tick + // with the note already dedup'd. + expect(released.writes.indexOf('autofix-budget-reached')).toBeLessThan( + released.writes.indexOf('labels/autofix%2Ftakeover'), + ); + // Per-window dedup: a notice newer than the anchor suppresses the + // comment but NOT the DELETE (a failed removal must still retry). + const noticed = runRelease({ + notices: [ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T11:00:00Z', + body: '🏁 … ', + }, + ], + }); + expect(noticed.writes).not.toContain('autofix-budget-reached'); + expect(noticed.writes).toContain('labels/autofix%2Ftakeover'); + // The dedup has TWO filters, and the fixture above satisfies both: a + // newer notice from the bot. Each filter on its own must still be load + // bearing — a human-quoted marker must not suppress the note… + const quoted = runRelease({ + notices: [ + { + user: { login: 'maint-a' }, + created_at: '2026-07-18T11:00:00Z', + body: '🏁 … ', + }, + ], + }); + expect(quoted.writes).toContain('autofix-budget-reached'); + // …and a bot notice from BEFORE the anchor must not either. + const older = runRelease({ + notices: [ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T09:00:00Z', + body: '🏁 … ', + }, + ], + }); + expect(older.writes).toContain('autofix-budget-reached'); + // A failed DELETE only warns: the scan carries on to the next PR, and the + // fleet row stays absent because nothing was released. Unwrapping the + // `if !` here aborts the whole scan on one transient 502. + const failedDelete = runRelease({ writeFails: true }); + expect(failedDelete.log).toContain('after budget failed'); + expect(failedDelete.fleet).not.toContain('released'); + expect(failedDelete.log).not.toContain('FELLTHROUGH'); + // The PR is still managed, so its escalation (if any) stays too. + expect(failedDelete.writes).not.toContain('labels/autofix%2Fneeds-human'); + + // Fail-closed and consent gates: none of these write anything. + for (const variant of [ + { labelsJson: '' }, + { labelsJson: '{"labels":[{"name":"autofix/skip"}]}' }, + { labelsJson: '{"labels":[{"name":"autofix/managed"}]}' }, + { actor: 'somebody-else' }, + { dryRun: 'true' }, + ]) { + const skipped = runRelease(variant); + expect(skipped.writes).toBe(''); + expect(skipped.log).not.toContain('FELLTHROUGH'); + // …and a skipped release never claims one in the fleet table either. + expect(skipped.fleet).not.toContain('released'); + } + + // The OUTER entry condition is the only clause that lets a no-op-only + // window end: two no-op rounds leave ROUND at 0, so the cap half never + // fires on its own and the PR would stay managed forever. + const gate = reviewScanJob.match( + /(if \[\[ "\$\{ROUND\}" -ge "\$\{EFF_MAX_ROUNDS\}" \|\| "\$\{BUDGET_SPENT\}" == 'true' \]\]; then)/, + )?.[1]; + expect(gate).toBeTruthy(); + const enters = (round, eff, spent) => + execFileSync( + 'bash', + [ + '-c', + `ROUND='${round}'\nEFF_MAX_ROUNDS='${eff}'\nBUDGET_SPENT='${spent}'\n${gate}\necho ENTER\nfi`, + ], + { encoding: 'utf8' }, + ).includes('ENTER'); + expect(enters('0', '2', 'true')).toBe(true); + expect(enters('0', '2', 'false')).toBe(false); + + // The BOT-authored half of the same branch: the release is skipped and + // the copy names the budget rather than a round cap the PR never hit. + const boundary = reviewScanJob.match( + /(if \[\[ "\$\{ROUND\}" -ge "\$\{EFF_MAX_ROUNDS\}" \|\| "\$\{BUDGET_SPENT\}" == 'true' \]\]; then[\s\S]*?\n {12}fi)/, + )?.[1]; + expect(boundary).toBeTruthy(); + const runBoundary = ({ + botAuthor = 'true', + halted = 'false', + forcedPr = '', + eventName = 'schedule', + spent = 'true', + round = '0', + used = '3', + nhPresent = false, + nhDeleteFails = '', + } = {}) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-budget-boundary-')); + try { + writeFileSync(join(dir, 'ic.json'), '[]'); + writeFileSync( + join(dir, 'labels.json'), + JSON.stringify({ + labels: [ + { name: 'autofix/takeover' }, + ...(nhPresent ? [{ name: 'autofix/needs-human' }] : []), + ], + }), + ); + writeFileSync( + join(dir, 'gh'), + [ + '#!/bin/bash', + 'if [[ "$1" == "pr" && "$2" == "view" ]]; then cat ' + + join(dir, 'labels.json') + + '; exit 0; fi', + 'if [[ "$1" == "api" && "$2" == "user" ]]; then printf "%s" "$STUB_ACTOR"; exit 0; fi', + 'echo "WRITE $*" >> ' + join(dir, 'writes.log'), + 'if [[ -n "${STUB_NH_FAIL:-}" && "$*" == *"labels/autofix%2Fneeds-human"* ]]; then echo "${STUB_NH_FAIL}" >&2; exit 1; fi', + 'if [[ -n "${STUB_WRITE_FAIL:-}" && "$*" == *"labels/"* ]]; then echo "HTTP 502" >&2; exit 1; fi', + 'exit 0', + ].join('\n'), + ); + chmodSync(join(dir, 'gh'), 0o755); + writeFileSync(join(dir, 'writes.log'), ''); + writeFileSync(join(dir, 'fleet.log'), ''); + const stdout = execFileSync( + 'bash', + [ + '-c', + `fleet_row() { printf '%s|%s\\n' "$2" "$3" >> ${join(dir, 'fleet.log')}; }\nfor _ in 1; do\n${boundary.replace(/\n {12}/g, '\n')}\necho FELLTHROUGH\ndone`, + ], + { + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + WORKDIR: dir, + PR: '7165', + REPO: 'QwenLM/qwen-code', + TAKEOVER_LABEL: 'autofix/takeover', + SKIP_LABEL: 'autofix/skip', + NEEDS_HUMAN_LABEL: 'autofix/needs-human', + AUTOFIX_BOT: 'qwen-code-dev-bot', + REARM_KEY: 'K1', + ROUND_BUDGET: '3', + ROUND: round, + EFF_MAX_ROUNDS: '3', + BUDGET_USED: used, + BUDGET_HALTED: halted, + FORCED_PR: forcedPr, + EVENT_NAME: eventName, + TIMEOUT_WINDOW_CAP: '3', + BUDGET_SPENT: spent, + DRY_RUN: 'false', + IS_BOT_AUTHOR: botAuthor, + DISPATCH_SOURCE: 'scan', + STUB_ACTOR: 'qwen-code-dev-bot', + STUB_NH_FAIL: nhDeleteFails, + HAS_TAKEOVER: 'true', + TAKEOVER_COMMAND: '@qwen-code /takeover', + RETRY_COMMAND: '@qwen-code /retry', + }, + encoding: 'utf8', + }, + ); + return { + log: stdout, + writes: readFileSync(join(dir, 'writes.log'), 'utf8'), + fleet: readFileSync(join(dir, 'fleet.log'), 'utf8'), + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const bot = runBoundary(); + // No label DELETE on a bot-authored PR… + expect(bot.writes).not.toContain('autofix%2Ftakeover'); + // …and the notice names the budget, not a cap this PR never reached. + expect(bot.log).toContain('budget spent'); + expect(bot.log).not.toContain('hit the round cap'); + expect(bot.fleet).toContain('budget-spent'); + + // The two suppressors on the same gate, on their failing side: a halted + // engagement (the breaker fired on the round that spent the budget) and a + // real forced dispatch both skip the release and land on the paused path. + // Deleting either clause from the gate leaves these green without them. + const haltedNow = runBoundary({ botAuthor: 'false', halted: 'true' }); + expect(haltedNow.writes).not.toContain('autofix%2Ftakeover'); + expect(haltedNow.log).toContain('budget spent'); + expect(haltedNow.fleet).toContain('needs a human'); + + const forced = runBoundary({ + botAuthor: 'false', + forcedPr: '7165', + eventName: 'workflow_dispatch', + }); + expect(forced.writes).not.toContain('autofix%2Ftakeover'); + // The deferred release pauses FIRST, so the notices a maintainer reads + // must quote the attempts the budget counts (3/3) — ROUND is still 0 + // here, and '0/3' would name a cap this PR never reached. + expect(forced.writes).toContain('(3/3)'); + expect(forced.writes).not.toContain('(0/3)'); + expect(forced.writes).toContain('labels[]=autofix/needs-human'); + // An ordinary cap keeps quoting ROUND: the substitution is budget-only. + // The attempt count differs here, so always quoting it would show (1/3). + const capped = runBoundary({ + botAuthor: 'false', + spent: 'false', + round: '3', + used: '1', + forcedPr: '7165', + eventName: 'workflow_dispatch', + }); + expect(capped.writes).toContain('(3/3)'); + expect(capped.writes).not.toContain('(1/3)'); + expect(capped.writes).not.toContain('labels/autofix%2Fneeds-human'); + + // …and the same dispatch identity on a passive event still releases. + const targeted = runBoundary({ + botAuthor: 'false', + forcedPr: '7165', + eventName: 'pull_request', + }); + expect(targeted.writes).toContain('labels/autofix%2Ftakeover'); + + // The release that follows a deferred pause clears the escalation the + // pause applied: the note says nothing is waiting on a human, so the + // label must not survive it. 404 (never paused) is the common case. + const deferred = runBoundary({ botAuthor: 'false', nhPresent: true }); + expect(deferred.writes).toContain( + 'DELETE repos/QwenLM/qwen-code/issues/7165/labels/autofix%2Fneeds-human', + ); + expect(deferred.fleet).toContain('released'); + const neverPaused = runBoundary({ + botAuthor: 'false', + nhPresent: true, + nhDeleteFails: 'HTTP 404: Not Found', + }); + expect(neverPaused.fleet).toContain('released'); + expect(neverPaused.log).not.toContain('removal failed after budget'); + // A real failure warns but still releases — the takeover DELETE already + // landed, and holding the PR managed over a stale label would be worse. + const nhDown = runBoundary({ + botAuthor: 'false', + nhPresent: true, + nhDeleteFails: 'HTTP 502: bad gateway', + }); + expect(nhDown.fleet).toContain('released'); + expect(nhDown.log).toContain('removal failed after budget'); + }); + + it('counts no-op attempts for a for-N budget and keeps breaker stops escalated', () => { + // A no-op round writes its eval marker with the round UNCHANGED, so a + // budget riding on ROUND would never spend on no-ops (P1). The budget + // therefore counts markers. The same read fixes the breaker case: its + // sentinel jump to MAX_ROUNDS writes no extra marker, so five failures in + // a twenty-round window stay an escalation instead of a release (P2). + const used = reviewScanJob.match( + /(BUDGET_USED="\$\(jq[\s\S]*?BUDGET_SPENT=true)/, + )?.[1]; + expect(used).toBeTruthy(); + const census = (markers, budget, floor = '') => + execFileSync( + 'bash', + [ + '-c', + `REARM_KEY='K1'\nROUND_BUDGET='${budget}'\nBUDGET_FLOOR='${floor}'\nMARKERS='${JSON.stringify(markers)}'\n${used.replace(/\n {12}/g, '\n')}\nprintf '%s|%s' "$BUDGET_USED" "$BUDGET_SPENT"`, + ], + { encoding: 'utf8' }, + ).trim(); + const spent = (markers, budget, floor = '') => { + const [count, isSpent] = census(markers, budget, floor).split('|'); + return `${count} ${isSpent}`; + }; + const mk = (win, round, ts = '2026-07-18T10:00:00Z') => ({ + ts, + round, + win, + at: '', + }); + // One no-op attempt spends a one-round budget… + expect(spent([mk('K1', 0)], '1')).toBe('1 true'); + // …and is only one attempt of two. + expect(spent([mk('K1', 0)], '2')).toBe('1 false'); + // The breaker sentinel (round=20 after five failures) is NOT a spent + // twenty-round budget. + expect( + spent( + [mk('K1', 1), mk('K1', 2), mk('K1', 3), mk('K1', 4), mk('K1', 20)], + '20', + ), + ).toBe('5 false'); + // Per engagement: with no command floor every marker counts, and a + // re-posted round (same ts + round) charges once. + expect(spent([mk('OLD', 1)], '1')).toBe('1 true'); + expect(spent([mk('K1', 1), mk('K1', 1), mk('K1', 2)], '5')).toBe('2 false'); + // A PR without a budget never spends one. + expect(spent([mk('K1', 1)], '0')).toBe('1 false'); + // Both census filters are load-bearing, and jq compares window keys as + // STRINGS — where "none" sorts ABOVE every ISO timestamp. So a round from + // before the first takeover must be excluded by the `win != "none"` clause, + // not by the floor: without it a PR with prior autofix rounds would spend + // a fresh `for 2` before running a round of it. + const FLOOR = '2026-07-18T09:00:00Z'; + expect(census([mk('none', 1), mk('none', 2)], '2', FLOOR)).toBe('0|false'); + // …and an older engagement's marker must be excluded by the floor. + expect(census([mk('2026-07-18T08:00:00Z', 1)], '1', FLOOR)).toBe('0|false'); + // A marker inside the engagement counts once, whatever its timestamp form. + expect( + census( + [ + mk('2026-07-18T09:30:00Z', 1), + mk('2026-07-18T09:40:00Z', 2, '9999-12-31T23:59:59Z'), + ], + '2', + FLOOR, + ), + ).toBe('2|true'); + // The census is fed by the marker builder, so a legacy report (no `run=`) + // must stay distinguishable: `run: (.[4] // "")` produced an EMPTY STRING, + // and jq's `//` does not fall back for "" — so two DIFFERENT legacy reports + // collapsed onto one key and a `for 2` window under-counted its attempts. + const markersBuild = reviewScanJob.match( + /(MARKERS="\$\(jq[\s\S]*?ic\.json"\)")/, + )?.[1]; + expect(markersBuild).toBeTruthy(); + const legacyCensus = (bodies) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-legacy-census-')); + try { + writeFileSync( + join(dir, 'ic.json'), + JSON.stringify( + bodies.map((body, i) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: `2026-07-18T0${i + 1}:00:00Z`, + body, + })), + ), + ); + return execFileSync( + 'bash', + [ + '-c', + `AUTOFIX_BOT='qwen-code-dev-bot'\nWORKDIR='${dir}'\n${markersBuild}\nREARM_KEY='K1'\nROUND_BUDGET='2'\nBUDGET_FLOOR=''\n${used}\nprintf '%s' "$BUDGET_USED"`, + ], + { encoding: 'utf8' }, + ).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + expect( + legacyCensus([ + '', + '', + ]), + ).toBe('2'); + // A re-posted legacy marker (identical ts and round) still charges once. + expect( + legacyCensus([ + '', + '', + ]), + ).toBe('1'); + }); + + it('lets an explicit release close a spent budget, and keeps idle timeouts out of the halt', () => { + // Two behaviours at the engagement floor: + // - `takeover-ack released` (an explicit `stop`, or a hand-removal) moves + // the floor, so no budget is read afterwards: a SPENT budget on a bot PR + // must not outlive the maintainer's own release, even though it does + // outlive the shepherd's silent one. + // - idle (silent-sandbox) timeouts write the sentinel too, and the + // cumulative breaker excludes them (af-073), so the halt census must + // subtract the same rounds or three infra wedges park a for-N window. + const lines = reviewScanJob.split('\n'); + const slice = (from, count) => { + const start = lines.findIndex((l) => l.includes(from)); + expect(start).toBeGreaterThanOrEqual(0); + return lines + .slice(start, start + count) + .join('\n') + .replace(/\n {12}/g, '\n'); + }; + const floorAndBudget = slice('BUDGET_FLOOR="$(', 2); + const idleAndSentinels = slice('BUDGET_IDLE_RUNS="$(', 3); + const run = (script) => + execFileSync('bash', ['-c', script], { encoding: 'utf8' }).trim(); + + const dir = mkdtempSync(join(tmpdir(), 'autofix-floor-release-')); + try { + writeFileSync( + join(dir, 'ic.json'), + JSON.stringify([ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T10:00:00Z', + body: '\n', + }, + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T11:00:00Z', + body: '', + }, + ]), + ); + expect( + run( + `AUTOFIX_BOT='qwen-code-dev-bot'\nWORKDIR='${dir}'\n${floorAndBudget}\nprintf '%s|%s' "$BUDGET_FLOOR" "$ROUND_BUDGET"`, + ), + ).toBe('2026-07-18T11:00:00Z|0'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + const sentinels = (idleRuns) => { + const d2 = mkdtempSync(join(tmpdir(), 'autofix-idle-sentinel-')); + try { + writeFileSync( + join(d2, 'ic.json'), + JSON.stringify( + idleRuns.map((r) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T10:00:00Z', + body: + '⏸ AutoFix ran out of time before finishing (idle-timeout) ' + + `[View job](https://x/actions/runs/${r}/job/1)`, + })), + ), + ); + const markers = [1, 2, 3].map((i) => ({ + ts: '9999-12-31T23:59:59Z', + round: i, + win: 'T1', + run: `${1000 + i}`, + at: '', + })); + return run( + `AUTOFIX_BOT='qwen-code-dev-bot'\nWORKDIR='${d2}'\nBUDGET_FLOOR=''\nMARKERS='${JSON.stringify(markers)}'\n${idleAndSentinels}\nprintf '%s' "$BUDGET_SENTINELS"`, + ); + } finally { + rmSync(d2, { recursive: true, force: true }); + } + }; + expect(sentinels(['1001', '1002', '1003'])).toBe('0'); + expect(sentinels([])).toBe('3'); + }); + + it('parses the run field in both eval-marker builders', () => { + // The format change made `run=` mandatory at every write site and relaxed + // six reader regexes, but nothing fed a `run=` body back through a reader: + // reverting the optional group stayed green while production markers (all + // carrying `run=`) stopped matching — an empty census, silently. + const builders = { + scan: reviewScanJob.match(/(MARKERS="\$\(jq[\s\S]*?ic\.json"\)")/)?.[1], + prepare: workflow.match(/(LIVE_MARKS="\$\(jq[\s\S]*?ic\.json"\)")/)?.[1], + }; + const body = (run) => + ''; + for (const [name, builder] of Object.entries(builders)) { + expect(builder, name).toBeTruthy(); + const dir = mkdtempSync(join(tmpdir(), `autofix-run-${name}-`)); + try { + writeFileSync( + join(dir, 'ic.json'), + JSON.stringify([ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T10:00:00Z', + body: body('1111'), + }, + { + user: { login: 'qwen-code-dev-bot' }, + created_at: '2026-07-18T11:00:00Z', + body: body(null), + }, + ]), + ); + // Both builders print ONE array (compact for the scan, pretty for + // prepare), so the inspection reads it as a single value. + // Each builder assigns its own variable; the scan uses MARKERS, the + // prepare leg LIVE_MARKS. + const varName = name === 'scan' ? 'MARKERS' : 'LIVE_MARKS'; + const out = execFileSync( + 'bash', + [ + '-c', + `AUTOFIX_BOT='qwen-code-dev-bot'\nWORKDIR='${dir}'\n${builder.replace(/\n {10,12}/g, '\n')}\nprintf '%s' "$(jq -c 'map("\\(.round)|\\(.win)|\\(.run // "null")")' <<< "$${varName}")"`, + ], + { encoding: 'utf8' }, + ).trim(); + // The new marker parses its round, window and run; the legacy one still + // parses round and window, and its run stays absent (null, not ""). + expect(out, name).toBe('["2|K1|1111","2|K1|null"]'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('reads the prepare budget from the engagement, and only while it is live', () => { + // prepare mirrors the scan's census: the same engagement floor (a re-arm + // carrying an eval marker does NOT open one) and the same liveness rule + // (the label, or a bot PR whose budget is already spent). Both reads used + // to be injected as literals by the discard tests, so a wrong key or a + // dropped marker scan stayed green. + const prepareReads = workflow.match( + /(LIVE_BUDGET_FLOOR="\$\(jq[\s\S]*?LIVE_ROUND_BUDGET=0)/, + )?.[1]; + expect(prepareReads).toBeTruthy(); + const read = ({ + comments, + marks, + takeover = 'true', + author = 'maint-a', + }) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-prepare-budget-')); + try { + writeFileSync(join(dir, 'ic.json'), JSON.stringify(comments)); + return execFileSync( + 'bash', + [ + '-c', + `AUTOFIX_BOT='qwen-code-dev-bot'\nWORKDIR='${dir}'\nLIVE_TAKEOVER='${takeover}'\nLIVE_AUTHOR='${author}'\nLIVE_MARKS='${JSON.stringify(marks)}'\n${prepareReads.replace(/\n {10}/g, '\n')}\nprintf '%s|%s|%s' "$LIVE_ROUND_BUDGET" "$LIVE_ATTEMPTS" "$LIVE_BUDGET_SPENT"`, + ], + { encoding: 'utf8' }, + ).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const ack = (at, budget) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: at, + body: `\n`, + }); + const rearm = (at) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: at, + body: '\n', + }); + const mark = (win, round, run) => ({ + ts: '2026-07-18T09:00:00Z', + round, + win, + run, + }); + const T1 = '2026-07-18T10:00:00Z'; + const T2 = '2026-07-18T12:00:00Z'; + // The automatic re-arm advances the window key, but the budget rides on + // the engagement: floor = the ack, budget = 2, two attempts, spent. + expect( + read({ + comments: [ack(T1, '2'), rearm(T2)], + marks: [mark(T1, 1, 'r1'), mark(T2, 2, 'r2')], + }), + ).toBe('2|2|true'); + // A human release ends the budget: the label is gone, the author is not + // the bot, so the panel reads nothing even though the marker is present. + expect( + read({ + comments: [ack(T1, '2'), rearm(T2)], + marks: [mark(T1, 1, 'r1'), mark(T2, 2, 'r2')], + takeover: 'false', + }), + ).toBe('0|2|true'); + // …but a SPENT budget on a bot-authored PR survives that removal, which is + // what keeps rounds N+1..10 from running (R4-2). + expect( + read({ + comments: [ack(T1, '3'), rearm(T2)], + marks: [mark(T1, 1, 'r1'), mark(T2, 2, 'r2'), mark(T2, 3, 'r3')], + takeover: 'false', + author: 'qwen-code-dev-bot', + }), + ).toBe('3|3|true'); + }); + + it('discards a queued conflict round when the live window carries a budget', () => { + // Conflicts are exempt from the superseded-window discard because they + // stay actionable — but a NEW budgeted window must not admit work that + // would run on the old cap, past the new engagement limit (P2). + const stale = workflow.match( + /(if \[\[ "\$\{STALE\}" != "true" && "\$\{WINDOW:-none\}" != "\$\{LIVE_REARM_KEY\}" && \( "\$\{CONFLICT\}" != "true" \|\| "\$\{LIVE_ROUND_BUDGET\}" -gt 0 \) \]\]; then[\s\S]*?\n {10}fi)/, + )?.[1]; + expect(stale).toBeTruthy(); + const run = (conflict, budget) => + execFileSync( + 'bash', + [ + '-c', + `STALE=false\nWINDOW=old\nLIVE_REARM_KEY=new\nCONFLICT=${conflict}\nLIVE_ROUND_BUDGET=${budget}\n${stale.replace(/\n {10}/g, '\n')}\nprintf '%s' "$STALE"`, + ], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .at(-1); + // Unbudgeted re-arm: the conflict exemption is preserved. + expect(run('true', '0')).toBe('false'); + // Budgeted live window: the queued conflict round is discarded. + expect(run('true', '2')).toBe('true'); + // The non-conflict control was already discarded. + expect(run('false', '0')).toBe('true'); + }); + + it('inherits the budget across an automatic growth-audit re-arm', () => { + // The audit's re-arm is what rebases the growth baseline, so it must stay + // unconditional. The budget survives it because the scan reads budget and + // spend per ENGAGEMENT: a command anchor opens one, an automatic re-arm + // (that comment also carries an eval marker) does not. + const fn = pushAndReportScript.match( + /(emit_growth_audit_marker\(\) \{[\s\S]*?\n\})/, + )?.[1]; + expect(fn).toBeTruthy(); + const dir = mkdtempSync(join(tmpdir(), 'autofix-audit-rearm-')); + try { + writeFileSync( + join(dir, 'ic.json'), + JSON.stringify([ + { + user: { login: 'qwen-code-dev-bot' }, + created_at: 'W1', + body: '\n', + }, + ]), + ); + const out = execFileSync( + 'bash', + [ + '-c', + `KISS_AUDIT=true\nAUDIT_VERDICT='sound'\nGROWTH_BASE_WIN='W1'\nWINDOW='W1'\nWORKDIR='${dir}'\nAUTOFIX_BOT='qwen-code-dev-bot'\n${fn}\nemit_growth_audit_marker true`, + ], + { encoding: 'utf8' }, + ); + // A budgeted window still rebases: the carry is the scan's job. + expect(out).toContain(''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + // The scan's floor read: an automatic re-arm leaves it, a command moves it. + const reads = reviewScanJob.match( + /(BUDGET_FLOOR="\$\(jq[\s\S]*?ROUND_BUDGET="\$\(jq[\s\S]*?\)")/, + )?.[1]; + expect(reads).toBeTruthy(); + const budgetAt = (comments) => { + const d = mkdtempSync(join(tmpdir(), 'autofix-floor-')); + try { + writeFileSync(join(d, 'ic.json'), JSON.stringify(comments)); + return execFileSync( + 'bash', + [ + '-c', + `AUTOFIX_BOT='qwen-code-dev-bot'\nHAS_TAKEOVER='true'\nWORKDIR='${d}'\n${reads.replace(/\n {12}/g, '\n')}\nprintf '%s' "$ROUND_BUDGET"`, + ], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .at(-1); + } finally { + rmSync(d, { recursive: true, force: true }); + } + }; + const bot = (at, body) => ({ + user: { login: 'qwen-code-dev-bot' }, + created_at: at, + body, + }); + // Budget anchored by a command; an audit report re-arms after it. + expect( + budgetAt([ + bot( + 'T1', + '\n', + ), + bot( + 'T2', + '\n', + ), + ]), + ).toBe('2'); + // A command re-arm AFTER it opens a fresh, unbudgeted engagement. + expect( + budgetAt([ + bot( + 'T1', + '\n', + ), + bot( + 'T2', + '\n', + ), + bot('T3', ''), + ]), + ).toBe('0'); + }); + + it('discards a same-window queued round once the attempt budget is spent', () => { + // A sibling's no-op spends the last attempt without advancing ROUND or + // changing the window key, so neither the superseded-window nor the round + // cap sees it; prepare must mirror the scan's attempt census (P2). + const block = workflow.match( + /(if \[\[ "\$\{STALE\}" != "true" && "\$\{LIVE_ROUND_BUDGET\}" -gt 0 && "\$\{LIVE_ATTEMPTS\}" -ge "\$\{LIVE_ROUND_BUDGET\}" \]\]; then[\s\S]*?\n {10}fi)/, + )?.[1]; + expect(block).toBeTruthy(); + const run = (attempts, budget) => + execFileSync( + 'bash', + [ + '-c', + `STALE=false\nLIVE_ATTEMPTS='${attempts}'\nLIVE_ROUND_BUDGET='${budget}'\n${block.replace(/\n {10}/g, '\n')}\nprintf '%s' "$STALE"`, + ], + { encoding: 'utf8' }, + ) + .trim() + .split('\n') + .at(-1); + // One attempt spent of one: discard. + expect(run('1', '1')).toBe('true'); + // One of two: still admitted. + expect(run('1', '2')).toBe('false'); + // No budget: the ordinary paths decide. + expect(run('1', '0')).toBe('false'); + }); + it('applies the needs-human escalation label at the cap and removes it on resume/release', () => { // The label contract: one env definition, one idempotent create with a // fixed color (a REST add of a missing label would mint a random one). @@ -3721,14 +4858,15 @@ describe('qwen-autofix workflow', () => { 'DRY-RUN: would post cap-paused notice and apply ${NEEDS_HUMAN_LABEL}', ); // Removal at every resume/release point — the URI-encoded REST DELETE - // with 404 tolerance, same shape as the TAKEOVER_LABEL removal. Six + // with 404 tolerance, same shape as the TAKEOVER_LABEL removal. Seven // sites: takeover-command re-arm, fresh engage, and stop; the ack - // job's engage/release; the /retry marker; the scan's first-pickup ack. + // job's engage/release; the /retry marker; the scan's first-pickup ack; + // and the scan's budget release (a deferred release paused first). const removals = workflow.match( /gh api -X DELETE "repos\/\$\{REPO\}\/issues\/\$\{PR\}\/labels\/\$\(jq -rn --arg l "\$\{NEEDS_HUMAN_LABEL\}"/g, ) ?? []; - expect(removals).toHaveLength(6); + expect(removals).toHaveLength(7); // R9-16: the escalation POST rides the cap detection exactly ONCE — a // duplicate outside the gate would re-add the label on every cap // detection of a released human PR (the R3-1/R4-1 ping-pong with the @@ -3751,7 +4889,7 @@ describe('qwen-autofix workflow', () => { // Every removal tolerates the common 404 (never-paused PR). expect( workflow.match(/\$\{NEEDS_HUMAN_LABEL\} removal failed/g)?.length, - ).toBe(6); + ).toBe(7); // The ack job removes on a real engage or any release — but NOT on // base-refused (nothing changed), NOT on skip-blocked (management never // resumed), and NOT on a release onto a skip-frozen PR (nothing manages @@ -4244,6 +5382,7 @@ describe('qwen-autofix workflow', () => { postFails = '', deleteFails = '', cmdFrom = '', + cmdFor = '', commentFails = '', commentFailTimes = '', }) => { @@ -4307,6 +5446,7 @@ describe('qwen-autofix workflow', () => { TOGGLE_COMMENT_FAILS: commentFails, TOGGLE_COMMENT_FAIL_TIMES: commentFailTimes, CMD_FROM: cmdFrom, + CMD_FOR: cmdFor, CRITICAL_ONLY_AFTER_ROUND: '5', }, encoding: 'utf8', @@ -4424,6 +5564,53 @@ describe('qwen-autofix workflow', () => { 'autofix-round-start', ); } + // The BUDGET marker's WRITE side: a valid 'for N' appends its own marker + // after the untouched engage literal on BOTH ack paths, and the copy + // states the release-at-N contract so the maintainer is not surprised by + // the label disappearing. + const budgetedEngage = runToggle({ cmd: 'add', cmdFor: '2' }); + expect(budgetedEngage.writes).toContain( + '\n', + ); + expect(budgetedEngage.writes).toContain('Budget: 2 round(s)'); + expect(budgetedEngage.writes).toContain('the loop removes the'); + // The ack is one printf with twelve positional arguments, so a whole-body + // toContain cannot see an EN/ZH argument swap: splitting at the details + // summary makes each language assert its own sentence. + const byLang = (text) => { + const [en, zh = ''] = text.split('中文说明'); + return { en, zh }; + }; + const engageLangs = byLang(budgetedEngage.writes); + expect(engageLangs.en).toContain('Budget: 2 round(s)'); + expect(engageLangs.en).not.toContain('本次托管预算'); + expect(engageLangs.zh).toContain('本次托管预算 2 轮'); + expect(engageLangs.zh).not.toContain('Budget: 2 round(s)'); + const budgetedRearm = runToggle({ + cmd: 'add', + labels: ['autofix/takeover'], + cmdFor: '3', + }); + expect(budgetedRearm.writes).toContain( + '\n', + ); + // The re-arm copy is pinned per language too: it is a second printf with + // its own argument order. + const rearmLangs = byLang(budgetedRearm.writes); + expect(rearmLangs.en).toContain('Budget: 3 round(s)'); + expect(rearmLangs.en).not.toContain('本次托管预算'); + expect(rearmLangs.zh).toContain('本次托管预算 3 轮'); + expect(rearmLangs.zh).not.toContain('Budget: 3 round(s)'); + // The two parameterized forms never co-emit, and the guard values emit + // no marker at all — a stray marker would cap a window the maintainer + // never bounded. + expect(budgetedEngage.writes).not.toContain('autofix-round-start'); + expect(seededEngage.writes).not.toContain('autofix-round-budget'); + for (const n of ['', '0', 'abc']) { + expect(runToggle({ cmd: 'add', cmdFor: n }).writes).not.toContain( + 'autofix-round-budget', + ); + } // remove + present → label removed, through the URI-encoded path segment // (real jq runs in the substitution, so the %2F is the executed truth). const removePresent = runToggle({ @@ -5964,8 +7151,9 @@ exit 1 }); it('behaviorally parses the takeover round seed and keeps every other body closed', () => { - // '@qwen-code /takeover from N' is the ONE parameterized command form, so - // it is also the one place a value is read out of a comment body. Replay + // '@qwen-code /takeover from N' is one of the TWO parameterized command + // forms, so it is one of the two places a value is read out of a comment + // body. Replay // the issue_comment branch VERBATIM (drift fails) and pin both halves: // the literal prefix must still match TAKEOVER_COMMAND byte-for-byte, and // the tail must be a bounded integer. Everything else — a prefixed body, a @@ -6093,6 +7281,104 @@ exit 1 ); }); + it('behaviorally parses the takeover round budget and keeps every other body closed', () => { + // ' for N' is the SECOND parameterized form, replayed VERBATIM for + // the same reason the seed is: the literal prefix must still match + // TAKEOVER_COMMAND byte-for-byte and the tail must be a bounded integer. + // 'for 0' is NOT the seed's explicit-no-op spelling — a zero-round + // engagement is what 'stop' means — so it must fail closed with the rest. + const sanitize = routeStep.match( + /(sanitize_number\(\) \{[\s\S]*?\n {10}\})/, + )?.[1]; + const cmdBranch = routeStep.match( + /(if \[\[ "\$\{EVENT_NAME\}" == 'issue_comment' \]\]; then[\s\S]*?\n {14}fi)/, + )?.[1]; + expect(sanitize).toBeTruthy(); + expect(cmdBranch).toBeTruthy(); + const budgetOf = (body) => { + const dir = mkdtempSync(join(tmpdir(), 'autofix-for-')); + try { + writeFileSync( + join(dir, 'gh'), + `#!/bin/bash\nif [[ "$*" == *"/pulls/"* ]]; then printf '%s' 'QwenLM/qwen-code'; else printf '%s' 'write'; fi\n`, + ); + chmodSync(join(dir, 'gh'), 0o755); + // TAKEOVER_FOR is echoed through sanitize_number exactly as the + // route step's own output line does. + const out = execFileSync( + 'bash', + [ + '-c', + `${sanitize.replace(/\n {10}/g, '\n')}\nEVENT_NAME=issue_comment\nTAKEOVER_CMD=''\nTAKEOVER_FROM=''\nTAKEOVER_FOR=''\nCMD_PR=''\n${cmdBranch.replace(/\n {14}/g, '\n')}\nprintf '%s|%s|%s' "$TAKEOVER_CMD" "$(sanitize_number "$TAKEOVER_FOR")" "$(sanitize_number "$TAKEOVER_FROM")"`, + ], + { + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + COMMENT_BODY: body, + SENDER_LOGIN: 'maintainer-b', + COMMENT_PR_AUTHOR: 'human-a', + HAS_PR_URL: 'url', + ISSUE_STATE: 'open', + ISSUE_NUMBER: '7165', + AUTOFIX_BOT: 'qwen-code-dev-bot', + TAKEOVER_COMMAND: '@qwen-code /takeover', + TAKEOVER_LABEL: 'autofix/takeover', + REPO: 'QwenLM/qwen-code', + GITHUB_TOKEN: 'x', + }, + encoding: 'utf8', + }, + ).split('\n'); + return out.at(-1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + expect(budgetOf('@qwen-code /takeover for 1')).toBe('add|1|'); + expect(budgetOf(' @qwen-code /takeover for 12 ')).toBe('add|12|'); + expect(budgetOf('@qwen-code /takeover for 99')).toBe('add|99|'); + // Leading zeros are decimal, not octal ('08'/'09' would error outright). + expect(budgetOf('@qwen-code /takeover for 02')).toBe('add|2|'); + expect(budgetOf('@qwen-code /takeover for 08')).toBe('add|8|'); + // The unparameterized forms carry no budget. + expect(budgetOf('@qwen-code /takeover')).toBe('add||'); + expect(budgetOf('@qwen-code /takeover stop')).toBe('remove||'); + // A seed is not a budget and vice versa. + expect(budgetOf('@qwen-code /takeover from 3')).toBe('add||3'); + for (const body of [ + '@qwen-code /takeover for 0', + '@qwen-code /takeover for 00', + '@qwen-code /takeover for 100', + '@qwen-code /takeover for 3x', + '@qwen-code /takeover for -1', + '@qwen-code /takeover for', + '@qwen-code /takeover stop for 3', + '@qwen-code /takeover from 3 for 2', + '@qwen-code /takeover for 3', + '@qwen-code /takeoverfor 3', + 'please @qwen-code /takeover for 3', + '@qwen-code /takeover for 3 please', + '@qwen-code /takeover for 3; rm -rf /', + '@qwen-code /takeover for $(id)', + '@qwen-code /takeover for `id`', + ]) { + expect(budgetOf(body)).toBe('||'); + } + // The value crosses a GITHUB_OUTPUT write and two job-boundary wires no + // behavioral harness exercises — pin them verbatim, or a typo silently + // degrades 'for N' to a bare '/takeover' with the suite still green. + expect(workflow).toContain( + 'echo "takeover_for=$(sanitize_number "${TAKEOVER_FOR}")" >> "${GITHUB_OUTPUT}"', + ); + expect(workflow).toContain( + "takeover_for: '${{ steps.decide.outputs.takeover_for }}'", + ); + expect(workflow).toContain( + "CMD_FOR: '${{ needs.route.outputs.takeover_for }}'", + ); + }); + it('gates real-time review triggers on bot author, trusted sender, and in-repo PR', () => { // Route step must check PR author against AUTOFIX_BOT for review events // (an in-repo PR is managed only when the bot authored it). @@ -8890,6 +10176,12 @@ exit 1 expect(emitWith({ allow: 'true' })).toBe( '\n', ); + // The audit's window field prefers GROWTH_BASE_WIN, and only falls back to + // WINDOW: the two differ whenever a conflict round ran under a newer key, + // and swapping the arguments would stamp the wrong window. + expect(emitWith({ allow: 'true', growthBaseWin: 'A1', window: 'B2' })).toBe( + '\n', + ); // sound on the FAILED report path → verdict marker, never the re-arm. expect(emitWith({ allow: 'false' })).toBe( '', @@ -16666,15 +17958,15 @@ exit 1 // instead of re-handing-off every tick. expect(reviewAddressReportStep).toContain('MARK_ROUND="${MAX_ROUNDS}"'); expect(reviewAddressReportStep).toContain( - '', + '', ); // Per-site (not just the global count-3): each producer keeps its win // key, or windowed ROUND silently restarts at 0 and the cap never fires. expect(pushAndReportStep).toContain( - '', + '', ); expect(pushAndReportStep).toContain( - '', + '', ); // The ts fallback must be non-empty even under cascading API failure (empty // WATERMARK), or the scan's `ts=([^ ]+)` regex would not match the terminal @@ -30225,10 +31517,10 @@ describe('review-address: regression accounting (af-157)', () => { // round that consumes no number makes the NEXT round reuse it, and // the brake's regression walk then charges two rounds as one. expect(giveUpArm).toContain( - '', + '', ); expect(giveUpArm).toContain( - '', + '', ); // The emitter's headline shape and the brake's reader agree on where // the round number sits. diff --git a/scripts/tests/qwen-fleet-shepherd-workflow.test.js b/scripts/tests/qwen-fleet-shepherd-workflow.test.js index 573741bd9d8..1433b932dd3 100644 --- a/scripts/tests/qwen-fleet-shepherd-workflow.test.js +++ b/scripts/tests/qwen-fleet-shepherd-workflow.test.js @@ -1252,6 +1252,32 @@ exit 1`; expect( computeResume({ comments: [cmd('2026-08-06T00:00:00Z', 'wenshao')] }).ts, ).toBe('2026-08-06T00:00:00Z'); + // …and the parameterized forms route accepts count too (R4-3): without + // these two arms a `for N` / `from N` command between the maintainer's + // comment and the ack leaves the shepherd blind, and it auto-releases a + // PR that was just re-engaged. + expect( + computeResume({ + comments: [ + cmd('2026-08-06T00:00:00Z', 'wenshao', '@qwen-code /takeover for 2'), + ], + }).ts, + ).toBe('2026-08-06T00:00:00Z'); + expect( + computeResume({ + comments: [ + cmd('2026-08-06T00:00:00Z', 'wenshao', '@qwen-code /takeover from 4'), + ], + }).ts, + ).toBe('2026-08-06T00:00:00Z'); + // A near-miss is not a command: the number is part of the grammar. + expect( + computeResume({ + comments: [ + cmd('2026-08-06T00:00:00Z', 'wenshao', '@qwen-code /takeover for'), + ], + }).ts, + ).toBe(''); // …but a stranger's command does not (R4-14)… expect( computeResume({ From ca204559a724a088071ea6fab025aa2efd740021 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Thu, 29 Oct 2026 03:27:36 +0800 Subject: [PATCH 006/166] feat(verify-pr): make "re-verify after update" a follow-up trigger (#15816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(verify-pr): make "re-verify after update" a follow-up trigger After a local verification round, a maintainer who sees the PR move says "有更新了,重新验证". That phrase was not a documented trigger: the skill could miss it entirely or start a fresh round, losing the previous-finding status table and the delta classification. The local follow-up lookup only searched PR comments, so a round that was never published was invisible and silently degraded to a fresh round, and nothing checked whether the head had actually moved before spending a full round. Add the re-verification phrases to the skill description, and a manual re-verification contract: resolve the PR from the session or the newest local artifact dir (ask when ambiguous), take the previous verified head from the newest substantive round across local artifacts and published comments, and fetch the current head before scoping. An unchanged head gets an answer, not a round — comments and CI results are data, the same rule the watch gate applies. A moved head runs the existing follow-up protocol. Coordinates inherit from the previous round; publishing stays a per-invocation request. A completed manual round records its head in the watch dedup file so an installed watch does not re-fire for it. The local follow-up lookup now searches both sources and takes the newest substantive round, so unpublished rounds participate in the protocol. * fix(verify-pr): register a manual round in the watch state before it starts The manual re-verification contract recorded its head in the shared watch dedup file only after the round completed, covering just the watch-first ordering. With the watch state still on the previous head, a manual round running in another session registered nothing while it worked, so the next watch fire read the stale head and started a second round for the same head. Make the check-and-register a step of its own ahead of running the round: read the shared file, defer when it already records the head or marks a round in flight, otherwise write the head as in-flight before starting — the same write-before-start rule the watch prompt follows — and record it as verified once the round completes. Both orderings now dedup through the one file. Pin the contract, including the registration-before-round ordering, in the skill-text suite so the race cannot return unnoticed. --- .qwen/skills/verify-pr/SKILL.md | 51 +++++++++++++++++++--- scripts/tests/qwen-triage-workflow.test.js | 33 ++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/.qwen/skills/verify-pr/SKILL.md b/.qwen/skills/verify-pr/SKILL.md index b12398064c4..3df98af8a64 100644 --- a/.qwen/skills/verify-pr/SKILL.md +++ b/.qwen/skills/verify-pr/SKILL.md @@ -1,6 +1,6 @@ --- name: verify-pr -description: This skill should be used to run a sandboxed deep verification of a qwen-code PR — "/verify-pr ", "/verify-pr --publish" (local round that also posts the report comment), "深度验证这个 PR", "验证这个 PR 并把报告发到评论", A/B load-bearing proof against the base build, mock-free harnesses with wire oracles, and targeted gates — producing tmp/pr-verify-/report.md plus a machine-readable verdict. Designed for the token-free CI verify job; also usable locally. +description: This skill should be used to run a sandboxed deep verification of a qwen-code PR — "/verify-pr ", "/verify-pr --publish" (local round that also posts the report comment), "深度验证这个 PR", "验证这个 PR 并把报告发到评论", "有更新了,重新验证", "重新验证 PR ", "re-verify after update", A/B load-bearing proof against the base build, mock-free harnesses with wire oracles, and targeted gates — producing tmp/pr-verify-/report.md plus a machine-readable verdict. Designed for the token-free CI verify job; also usable locally. argument-hint: ' [--publish] [--watch] [--repo /] [--assets-repo /] [--assets-branch ]' --- @@ -250,13 +250,16 @@ base. Resolve `baseRefOid` and `headRefOid` explicitly from `gh pr view` and use those OIDs throughout; if either is not present locally, report `inconclusive` rather than substituting a parent. -**Local follow-up rounds: check the PR's comment history before scoping.** +**Local follow-up rounds: check the prior round before scoping.** The CI follow-up trigger (`previous-report.md` next to the context file) -does not exist locally. When you have `gh` access (a maintainer-driven run -on their own machine), look for prior verification rounds first — +does not exist locally. Look for prior verification rounds in the newest +`tmp/pr-verify-*/` artifact dir — a round that was never published +exists only there — and, when you have `gh` access (a maintainer-driven +run on their own machine), in `gh api repos///issues//comments`, plus any linked assets -branches or artifact indexes those comments name. If a substantive earlier -round exists, run the follow-up protocol exactly as the CI path requires: +branches or artifact indexes those comments name; take the newest +substantive round across both sources. If one exists, run the follow-up +protocol exactly as the CI path requires: lead with a previous-finding status table and **re-measure every carried-forward finding at the new head, never quote the old verdict**. The trigger is per finding, not per scenario: a finding whose repro needs a @@ -301,6 +304,42 @@ treat the round as a production change. on correctly suffixed values (including the repo's own `2s` / `500ms`) and still bound a stale `HARNESS_TURN_DEADLINE=600000` as 6.9 days. +**Manual re-verification: "有更新了,重新验证" / "re-verify after update".** +When the maintainer says a previously verified PR has moved, that phrase is +a follow-up trigger, not a fresh round, and it needs no PR number restated: +resolve the PR from the session's earlier round, or from the newest +`tmp/pr-verify-*/` when the session has none — ask when that is +ambiguous rather than verify a guessed PR. Then, in order: + +1. Find the previous verified head `P` from the newest substantive round + across both sources: the local artifact dir's report verdict line, and + the verification comments (an unpublished round exists only locally; a + CI round published after it supersedes it). No prior round anywhere → + run a fresh round and say so. +2. Fetch the current head and state with + `gh pr view --repo / --json headRefOid,state`. + A merged or closed PR gets that answer, not a round. Head `H == P` → no + round either: report that the head has not moved since `P`. Comments, + reviews and CI results that arrived since are data to fold into that + answer, never a reason to re-verify — the same rule the watch gate + applies. +3. Register the round in the shared watch state before starting it. Read + `.qwen/tmp/verify-pr-watch-.json`: when it already records `H` or + marks a round in flight, let that round answer instead of starting a + duplicate. Otherwise write `H` as in-flight to that file **before** the + round starts — the same write-before-start rule the watch prompt follows, + so a watch fire during the manual round skips `H` — and record `H` as + verified in the same file once the round completes. +4. Run the follow-up protocol above: classify the `P..H` delta, lead with + the previous-finding status table, re-measure every carried-forward + finding at `H`. +5. Inherit the previous round's coordinates (`--repo`, assets repo/branch) + instead of asking again. Publishing stays a per-invocation request: the + phrase alone does not post a comment. + +To make this trigger automatic instead of typed, install `--watch` (above); +the two share that head-dedup file. + **Maintainer-driven local publish (only on explicit request).** "Never post" yields when the maintainer running the local round explicitly asks for a PR comment; `--publish` in the invocation is that request, and needs diff --git a/scripts/tests/qwen-triage-workflow.test.js b/scripts/tests/qwen-triage-workflow.test.js index 2d8a6067923..fa367d78a8e 100644 --- a/scripts/tests/qwen-triage-workflow.test.js +++ b/scripts/tests/qwen-triage-workflow.test.js @@ -5270,6 +5270,39 @@ describe('qwen-triage verify hardening round 2', () => { expect(flat).toContain('schedule nothing'); }); + // A manual re-verification round that records its head only after it + // finishes leaves the shared dedup file on the previous head while it + // runs: the next watch fire reads that stale head and starts a second + // round for the same head (PR #15816 review, P2). The registration must + // precede the round, as the watch prompt's own write-before-start rule does. + it('registers a manual re-verification round before it starts', () => { + const flat = verifySkill.replace(/\s+/g, ' '); + + // The phrase is a documented trigger, and an ambiguous PR is asked about. + expect(flat).toContain('有更新了,重新验证'); + expect(flat).toContain('ask when that is ambiguous'); + // An unchanged head gets an answer, not a round. + expect(flat).toContain('the head has not moved since'); + // The shared state is checked and registered before the round runs. + expect(flat).toContain( + 'Register the round in the shared watch state before starting it', + ); + expect(flat).toContain('write `H` as in-flight'); + expect(flat).toContain( + 'the same write-before-start rule the watch prompt follows', + ); + expect(flat).toContain( + 'record `H` as verified in the same file once the round completes', + ); + // Registration is a step of its own ahead of the round, not an + // afterthought behind it. + expect( + flat.indexOf('Register the round in the shared watch state'), + ).toBeLessThan(flat.indexOf('Run the follow-up protocol above')); + // Publishing consent is not inherited from the phrase. + expect(flat).toContain('the phrase alone does not post a comment'); + }); + // The TUI renders `argument-hint` beside the command name; without the field // `/verify-pr` looks like it takes no arguments, unlike `/review`. it('declares an argument hint for the command prompt', () => { From ca298174dbe4c5c42aae78ffc5b458ce6bd90f2a Mon Sep 17 00:00:00 2001 From: callmeYe <512217680@qq.com> Date: Thu, 29 Oct 2026 08:12:25 +0800 Subject: [PATCH 007/166] feat(extensions): support unscoped npm installation and updates (#15781) * feat(extensions): support unscoped npm packages * test(extensions): use invalid source in lifecycle validation --------- Co-authored-by: Shaojin Wen --- docs/design/unscoped-npm-extensions.md | 54 ++++ docs/design/unscoped-npm-extensions.zh-CN.md | 43 ++++ docs/users/extension/agent-plugins.md | 2 +- docs/users/extension/extension-releasing.md | 4 +- docs/users/extension/introduction.md | 13 +- .../cli/src/commands/extensions/install.ts | 2 +- .../src/serve/routes/workspace-extensions.ts | 4 +- packages/cli/src/serve/server.test.ts | 56 ++++- .../src/extension/extensionManager.test.ts | 6 +- .../core/src/extension/marketplace.test.ts | 29 ++- packages/core/src/extension/marketplace.ts | 6 +- packages/core/src/extension/npm.test.ts | 232 +++++++++++------- packages/core/src/extension/npm.ts | 37 +-- 13 files changed, 358 insertions(+), 130 deletions(-) create mode 100644 docs/design/unscoped-npm-extensions.md create mode 100644 docs/design/unscoped-npm-extensions.zh-CN.md diff --git a/docs/design/unscoped-npm-extensions.md b/docs/design/unscoped-npm-extensions.md new file mode 100644 index 00000000000..e30a528d3f4 --- /dev/null +++ b/docs/design/unscoped-npm-extensions.md @@ -0,0 +1,54 @@ +# Unscoped npm extension sources + +[English](unscoped-npm-extensions.md) | [简体中文](unscoped-npm-extensions.zh-CN.md) + +## Problem and scope + +Extension installation and update parsing currently require `@scope/name`. +Support `name`, `name@version`, and `name@tag` through the same npm delivery +path, including CLI and daemon installation. Do not add npm aliases, change +version-selection policy, or change runtime ownership. + +## Design + +- Extend npm parsing and add a general source predicate. Keep the exported + scoped-only predicate compatible. +- Preserve existing local-file/directory precedence, URL handling, and + `owner/repo` GitHub shorthand. Explicit paths must not become npm sources. + A missing bare filename that is also a valid package name is an npm source; + use an explicit path when a local file is intended. +- Unscoped packages use an explicit registry override, default `.npmrc` + registry, or npmjs. They never select a scoped registry. Persist the registry + and source as before so updates use the same package and registry. +- Extend daemon preflight for bare npm sources while preserving existing scoped + marketplace selectors and URL validation. Unscoped marketplace selectors over + daemon routes remain outside this change. Route ownership is unchanged: + the global mutation flow keeps its existing preparation/commit context; + selected-workspace routes keep their resolved runtime. +- Update CLI help and user-facing npm documentation. + +## Consumers and affected files + +The core npm and marketplace modules serve CLI install, TUI extension actions, +Web Shell discovery/source registration, and daemon install routes. Extension +manager installation/update preparation downloads npm packages; the shared +update dispatcher serves CLI/TUI updates and background checks. The daemon +preflight in `workspace-extensions.ts` also needs the general predicate. +Managed-package preparation also consumes the shared parser and npm downloader. +Collocated core tests and daemon server tests cover the changed boundaries. + +## Validation and acceptance + +Use an isolated fixture npm registry and harmless package with a root extension +manifest. Verify scoped control, unscoped bare/latest installation and upgrade, +exact-version pinning, beta-tag upgrades, repeated up-to-date checks, and no npm +requests for explicit missing paths. Unit tests cover source classification, +registry resolution, download URLs, and daemon preflight. Run build, typecheck, +bundle, focused tests, and two self-audit passes. Baseline and E2E evidence live +in `.qwen/e2e-tests/unscoped-npm-extensions.md`. + +## Risks and open questions + +Bare package names can overlap local filenames; existing local entries keep +priority. Existing relative-path/GitHub shorthand ambiguity is outside this +change. No open design questions remain. diff --git a/docs/design/unscoped-npm-extensions.zh-CN.md b/docs/design/unscoped-npm-extensions.zh-CN.md new file mode 100644 index 00000000000..a4ae4dee130 --- /dev/null +++ b/docs/design/unscoped-npm-extensions.zh-CN.md @@ -0,0 +1,43 @@ +# 无 scope 的 npm 扩展来源 + +[English](unscoped-npm-extensions.md) | [简体中文](unscoped-npm-extensions.zh-CN.md) + +## 问题与范围 + +扩展安装及更新解析目前要求 `@scope/name`。通过同一 npm 分发流程支持 +`name`、`name@version` 和 `name@tag`,覆盖 CLI 与 daemon 安装。 +不增加 npm 别名,不改变版本选择策略或运行时归属。 + +## 设计 + +- 扩展 npm 解析并增加通用来源判断函数,保留已导出的仅 scoped 包判断函数的兼容行为。 +- 保留现有本地文件/目录优先级、URL 处理及 `owner/repo` GitHub 简写。 + 显式路径不得变成 npm 来源。不存在的裸文件名若也是有效包名,则视为 npm 来源; + 若意图使用本地文件,应使用显式路径。 +- 无 scope 包使用显式 registry 覆盖、`.npmrc` 默认 registry 或 npmjs, + 不选择 scoped registry。照常保存 registry 和来源,使更新使用同一包及 registry。 +- daemon 预检查支持裸 npm 来源,保留现有 scoped marketplace 选择器及 URL 校验。 + daemon 路由中的无 scope marketplace 选择器不在本次范围内。 + 路由归属不变:全局变更流程保留原有准备/提交上下文,选定工作区路由继续使用已解析的运行时。 +- 更新 CLI 帮助和面向用户的 npm 文档。 + +## 消费方与涉及文件 + +核心 npm 和 marketplace 模块供 CLI 安装、TUI 扩展操作、Web Shell 发现/来源注册 +及 daemon 安装路由使用。扩展管理器的安装/更新准备流程下载 npm 包;共享更新分发器 +服务 CLI/TUI 更新和后台检查。`workspace-extensions.ts` 中的 daemon 预检查也需使用 +通用判断函数。托管包准备流程也使用共享解析器与 npm 下载器。 +同目录核心测试和 daemon server 测试覆盖改变的边界。 + +## 验证与验收 + +使用隔离的测试 npm registry 及根目录含扩展清单的无副作用包。验证 scoped 对照、 +无 scope 裸包名/latest 安装和升级、精确版本固定、beta 标签升级、重复检查已最新, +以及显式缺失路径不会请求 npm。单元测试覆盖来源分类、registry 解析、下载 URL +及 daemon 预检查。执行构建、类型检查、bundle、定向测试及两轮自审。 +基线和 E2E 证据记录在 `.qwen/e2e-tests/unscoped-npm-extensions.md`。 + +## 风险与待定问题 + +裸包名可能与本地文件名重叠;已存在的本地条目保持优先。现有相对路径/GitHub +简写歧义不属于此次改动范围。没有待定设计问题。 diff --git a/docs/users/extension/agent-plugins.md b/docs/users/extension/agent-plugins.md index 8155daa7f05..bae83066d3f 100644 --- a/docs/users/extension/agent-plugins.md +++ b/docs/users/extension/agent-plugins.md @@ -6,7 +6,7 @@ packages. The package keeps its standard `plugin.json`, `mcp.json`, and rewrite portable files. Use the existing extension commands with a local directory, link, archive, -Git repository, archive URL, or scoped npm package: +Git repository, archive URL, or npm package: ```bash qwen extensions install ./my-agent-plugin diff --git a/docs/users/extension/extension-releasing.md b/docs/users/extension/extension-releasing.md index 1d63dbe236d..3ae38335fce 100644 --- a/docs/users/extension/extension-releasing.md +++ b/docs/users/extension/extension-releasing.md @@ -123,7 +123,7 @@ jobs: ## Releasing through npm registry -You can publish Qwen Code extensions as scoped npm packages (e.g. `@your-org/my-extension`). This is a good fit when: +You can publish Qwen Code extensions as scoped or unscoped npm packages (e.g. `@your-org/my-extension` or `my-extension`). This is a good fit when: - Your team already uses npm for package distribution - You need private registry support with existing auth infrastructure @@ -161,7 +161,7 @@ npm publish --registry https://your-registry.com ### Installation -Users install your extension using the scoped package name: +Users install your extension using the package name: ```bash # Install latest version diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index b0aefd6c8e6..f618cf56098 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -101,7 +101,7 @@ Gemini extensions are automatically converted to Qwen Code format during install #### From Qoder Plugins -Qwen Code supports [Qoder plugins](https://docs.qoder.com/en/cli/sdk/plugins) that contain a `.qoder-plugin/plugin.json` manifest. Install a local directory, archive, Git repository, archive URL, or scoped npm package with the existing `qwen extensions install` command: +Qwen Code supports [Qoder plugins](https://docs.qoder.com/en/cli/sdk/plugins) that contain a `.qoder-plugin/plugin.json` manifest. Install a local directory, archive, Git repository, archive URL, or npm package with the existing `qwen extensions install` command: ```bash qwen extensions install ./sample-qoder-plugin @@ -127,25 +127,28 @@ The portable runtime supports Agent Skills plus stdio and Streamable HTTP MCP se #### From npm Registry -Qwen Code supports installing extensions from npm registries using scoped package names. This is ideal for teams with private registries that already have auth, versioning, and publishing infrastructure in place. +Qwen Code supports installing extensions from npm registries using scoped or unscoped package names. This is ideal for teams with private registries that already have auth, versioning, and publishing infrastructure in place. ```bash # Install the latest version qwen extensions install @scope/my-extension +# Install an unscoped package +qwen extensions install my-extension + # Install a specific version -qwen extensions install @scope/my-extension@1.2.0 +qwen extensions install my-extension@1.2.0 # Install from a custom registry qwen extensions install @scope/my-extension --registry https://your-registry.com ``` -Only scoped packages (`@scope/package-name`) are supported to avoid ambiguity with the `owner/repo` GitHub shorthand format. +Both `package-name` and `@scope/package-name` are supported, with optional `@version` or `@tag` suffixes. Existing local files and directories take precedence; use an explicit path such as `./my-extension` when a local source is intended. The `owner/repo` form remains a GitHub shorthand. **Registry resolution** follows this priority: 1. `--registry` CLI flag (explicit override) -2. Scoped registry from `.npmrc` (e.g. `@scope:registry=https://...`) +2. Scoped registry from `.npmrc` (scoped packages only, e.g. `@scope:registry=https://...`) 3. Default registry from `.npmrc` 4. Fallback: `https://registry.npmjs.org/` diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 487503b7ad7..58a8265fa75 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -157,7 +157,7 @@ export async function handleInstall(args: InstallArgs) { export const installCommand: CommandModule = { command: 'install ', describe: t( - 'Installs an extension from a git repository URL, local path or archive, archive URL, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).', + 'Installs an extension from a git repository URL, local path or archive, archive URL, npm package (name or @scope/name), or claude marketplace (marketplace-url:plugin-name).', ), builder: (yargs) => yargs diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 2be67b15c05..b78fe5cf8f9 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -27,6 +27,7 @@ import { qualifySkillName, validateSkillName, } from '@qwen-code/qwen-code-core'; +import { isNpmPackage } from '@qwen-code/qwen-code-core/extension/npm.js'; import express, { type Application, type Request, @@ -1312,7 +1313,8 @@ export function registerWorkspaceExtensionRoutes( return; } } - const npmSource = sourceValue.startsWith('@'); + const npmSource = + sourceValue.startsWith('@') || isNpmPackage(sourceValue); const remoteSource = npmSource || sourceValue.startsWith('https://') || diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index b34a2cfa0bd..fc06769f5a1 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -10386,7 +10386,59 @@ describe('createServeApp', () => { }, ); - it('rejects an npm extension install with ref before queuing', async () => { + it.each(['some-package', 'some-package@beta', '@scope/ext:plugin'])( + 'installs npm source %s with a registry override', + async (source) => { + let installMetadata: ExtensionInstallMetadata | undefined; + const restore = mockExtensionManagerMethods({ + async prepareExtensionInstall(options) { + installMetadata = options.installMetadata; + return testExtension('npm-extension'); + }, + }); + try { + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const res = await request(app) + .post('/workspace/extensions/install') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + source, + registry: 'https://registry.npmjs.org', + consent: true, + }); + expect(res.status).toBe(202); + await vi.waitFor(() => { + expect(bridge.extensionEvents.at(-1)).toMatchObject({ + status: 'installed', + name: 'npm-extension', + }); + }); + expect(installMetadata).toMatchObject({ + source: source.split(':')[0], + type: 'npm', + registryUrl: 'https://registry.npmjs.org', + ...(source.endsWith(':plugin') ? { pluginName: 'plugin' } : {}), + }); + } finally { + restore(); + } + }, + ); + + it.each([ + '@scope/ext', + 'some-package', + 'some-package@beta', + '@scope/ext:plugin', + ])('rejects npm source %s with ref before queuing', async (source) => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); const app = createServeApp( @@ -10400,7 +10452,7 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${tokenOpts.port}`) .set('Authorization', 'Bearer secret') .set('X-Qwen-Client-Id', 'client-1') - .send({ source: '@scope/ext', ref: 'v1', consent: true }); + .send({ source, ref: 'v1', consent: true }); expect(res.status).toBe(400); expect(res.body.error).toBe( diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 06cf464a9e4..96c7dc2cf4d 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -3234,9 +3234,9 @@ describe('extension tests', () => { // Non-archive probe failures must keep the marketplace-specific // guidance rather than surfacing the raw install-source error. - await expect( - manager.addSource('invalid-format-no-slash'), - ).rejects.toThrow(/No marketplace found at/); + await expect(manager.addSource('invalid package name')).rejects.toThrow( + /No marketplace found at/, + ); expect(events).toEqual([]); }); diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 1ceff5b01ac..1b30fd73e47 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -352,9 +352,9 @@ describe('parseInstallSource', () => { it('should throw error for non-existent path that is not valid format', async () => { statMissing(); - await expect( - parseInstallSource('invalid-format-no-slash'), - ).rejects.toThrow('Install source not found: invalid-format-no-slash'); + await expect(parseInstallSource('invalid package name')).rejects.toThrow( + 'Install source not found: invalid package name', + ); }); // The colon after C should not be treated as plugin separator. @@ -362,6 +362,29 @@ describe('parseInstallSource', () => { local('C:\\path\\to\\extension')); }); + describe('unscoped npm package parsing', () => { + it.each([ + 'some-package', + 'some-package@1.2.0', + 'some-package@latest', + 'some-package@beta', + ])('parses %s', async (source) => { + await expectParsed(source, { source, type: 'npm' }); + }); + it('preserves plugin selectors', () => + expectParsed('some-package:my-plugin', { + source: 'some-package', + type: 'npm', + pluginName: 'my-plugin', + })); + it('keeps an existing bare local path local', () => + expectParsed( + 'some-package', + { source: 'some-package', type: 'local' }, + true, + )); + }); + describe('scoped npm package parsing', () => { it('should parse scoped npm package without version', () => expectParsed('@ali/openclaw-tmcp-dingtalk', { diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 6ebd6b828b4..1ab65f9738e 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -16,7 +16,7 @@ import { isSupportedArchiveUrl, parseGitHubRepoForReleases, } from './github.js'; -import { isScopedNpmPackage } from './npm.js'; +import { isNpmPackage } from './npm.js'; import { redactUrlCredentials } from './redaction.js'; import { clientForUrl } from './http-client.js'; import { resolveNetworkTarget } from './network-policy.js'; @@ -516,8 +516,8 @@ export async function parseInstallSource( } catch { // Not a valid GitHub URL or failed to fetch, continue without marketplace config } - } else if (isScopedNpmPackage(repo)) { - // Priority 3: Scoped npm package (@scope/name, optionally @version) + } else if (isNpmPackage(repo)) { + // Priority 3: npm package (name or @scope/name, optionally @version) installMetadata = { source: repo, type: 'npm', diff --git a/packages/core/src/extension/npm.test.ts b/packages/core/src/extension/npm.test.ts index 67833215d09..a6a2a8bb169 100644 --- a/packages/core/src/extension/npm.test.ts +++ b/packages/core/src/extension/npm.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { parseNpmPackageSource, isScopedNpmPackage, + isNpmPackage, resolveNpmRegistry, checkNpmUpdate, downloadFromNpmRegistry, @@ -35,25 +36,22 @@ vi.mock('node:stream/promises', () => ({ describe('parseNpmPackageSource', () => { it.each<[string, string, string | undefined]>([ + ['unscoped', 'some-package', undefined], + ['unscoped version', 'some-package', '1.2.0'], + ['unscoped tag', 'some-package', 'beta'], ['without version', '@ali/openclaw-tmcp-dingtalk', undefined], ['with version', '@ali/openclaw-tmcp-dingtalk', '1.2.0'], ['with latest tag', '@scope/pkg', 'latest'], ['with semver range', '@scope/pkg', '^1.0.0'], - ])('should parse scoped package %s', (_, name, version) => { + ])('should parse package %s', (_, name, version) => { const result = parseNpmPackageSource(version ? `${name}@${version}` : name); expect(result.name).toBe(name); expect(result.version).toBe(version); }); it('should throw for invalid source', () => { - expect(() => parseNpmPackageSource('not-scoped')).toThrow( - 'Invalid scoped npm package source', - ); - }); - - it('should throw for unscoped package', () => { - expect(() => parseNpmPackageSource('some-package')).toThrow( - 'Invalid scoped npm package source', + expect(() => parseNpmPackageSource('./missing')).toThrow( + 'Invalid npm package source', ); }); @@ -94,6 +92,31 @@ describe('isScopedNpmPackage', () => { }); }); +describe('isNpmPackage', () => { + it.each([ + 'some-package', + 'some-package@1.2.0', + 'some-package@beta', + '@scope/pkg', + '@scope/pkg@latest', + ])('accepts %s', (source) => expect(isNpmPackage(source)).toBe(true)); + it.each([ + '.', + '..', + '.hidden', + '_private', + './missing', + '../missing', + '/absolute/path', + 'C:\\missing', + 'owner/repo', + 'https://example.com/pkg', + 'git@github.com:owner/repo', + 'some package', + 'pkg@', + ])('rejects %s', (source) => expect(isNpmPackage(source)).toBe(false)); +}); + describe('resolveNpmRegistry', () => { beforeEach(() => { vi.clearAllMocks(); @@ -129,6 +152,13 @@ describe('resolveNpmRegistry', () => { expect(result).toBe('https://custom.registry.com'); }); + it('uses only the default registry for an unscoped package', () => { + vi.mocked(fs.readFileSync).mockReturnValueOnce( + '@scope:registry=https://scoped.example.com/\npkg:registry=https://wrong.example.com/\nregistry=https://default.example.com/', + ); + expect(resolveNpmRegistry(undefined)).toBe('https://default.example.com'); + }); + it('should return npmjs.org as fallback', () => { vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error('ENOENT'); @@ -288,6 +318,15 @@ describe('downloadFromNpmRegistry', () => { vi.unstubAllEnvs(); }); + it('downloads an unscoped package from its registry metadata', async () => { + mockNpmDownload(TGZ); + const result = await download({ source: 'some-package' }); + expect(result).toMatchObject({ type: 'npm', version: '1.0.0' }); + expect(vi.mocked(https.get).mock.calls[0]?.[0]).toBe( + 'https://registry.example.com/some-package', + ); + }); + it('does not send the ambient npm token to an override registry', async () => { vi.stubEnv('NPM_TOKEN', 'ambient-secret'); mockNpmDownload(TGZ); @@ -694,96 +733,99 @@ describe('downloadFromNpmRegistry', () => { }); }); -describe('checkNpmUpdate', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(fs.readFileSync).mockImplementation(() => { - throw new Error('ENOENT'); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** Serves these dist-tags and versions, then checks `source`@`releaseTag`. */ - const check = ( - source: string, - releaseTag: string, - tags: Record, - versions: string[], - registryUrl = 'https://registry.npmjs.org', - ) => { - mockGet(() => - jsonResponse({ - 'dist-tags': tags, - versions: Object.fromEntries( - versions.map((v) => [v, { dist: { tarball: '' } }]), - ), - }), - ); - return checkNpmUpdate({ source, type: 'npm', releaseTag, registryUrl }); - }; - - it('should report UPDATE_AVAILABLE when latest is newer', async () => { - const result = await check('@scope/pkg', '1.0.0', { latest: '2.0.0' }, [ - '2.0.0', - ]); - expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); - }); - - it('uses the HTTPS client for uppercase HTTPS registry URLs', async () => { - vi.mocked(http.get).mockImplementation(() => { - throw new Error('wrong client'); +describe.each(['@scope/pkg', 'some-package'])( + 'checkNpmUpdate(%s)', + (packageName) => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('ENOENT'); + }); }); - const result = await check( - '@scope/pkg', - '1.0.0', - { latest: '1.0.0' }, - ['1.0.0'], - 'HTTPS://registry.npmjs.org', - ); + afterEach(() => { + vi.restoreAllMocks(); + }); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - expect(https.get).toHaveBeenCalled(); - expect(http.get).not.toHaveBeenCalled(); - }); + /** Serves these dist-tags and versions, then checks `source`@`releaseTag`. */ + const check = ( + source: string, + releaseTag: string, + tags: Record, + versions: string[], + registryUrl = 'https://registry.npmjs.org', + ) => { + mockGet(() => + jsonResponse({ + 'dist-tags': tags, + versions: Object.fromEntries( + versions.map((v) => [v, { dist: { tarball: '' } }]), + ), + }), + ); + return checkNpmUpdate({ source, type: 'npm', releaseTag, registryUrl }); + }; - it('should report UP_TO_DATE when latest matches', async () => { - const result = await check('@scope/pkg', '1.0.0', { latest: '1.0.0' }, [ - '1.0.0', - ]); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - }); + it('should report UPDATE_AVAILABLE when latest is newer', async () => { + const result = await check(packageName, '1.0.0', { latest: '2.0.0' }, [ + '2.0.0', + ]); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); - it('should report UP_TO_DATE for pinned exact version', async () => { - const result = await check( - '@scope/pkg@1.0.0', - '1.0.0', - { latest: '2.0.0' }, - ['1.0.0', '2.0.0'], - ); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - }); + it('uses the HTTPS client for uppercase HTTPS registry URLs', async () => { + vi.mocked(http.get).mockImplementation(() => { + throw new Error('wrong client'); + }); - it('should check correct dist-tag for non-latest tag installs', async () => { - const result = await check( - '@scope/pkg@beta', - '2.0.0-beta.1', - { latest: '1.0.0', beta: '2.0.0-beta.2' }, - ['1.0.0', '2.0.0-beta.1', '2.0.0-beta.2'], - ); - expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); - }); + const result = await check( + packageName, + '1.0.0', + { latest: '1.0.0' }, + ['1.0.0'], + 'HTTPS://registry.npmjs.org', + ); - it('should report UP_TO_DATE for beta tag when on latest beta', async () => { - const result = await check( - '@scope/pkg@beta', - '2.0.0-beta.2', - { latest: '1.0.0', beta: '2.0.0-beta.2' }, - ['1.0.0', '2.0.0-beta.2'], - ); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - }); -}); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(https.get).toHaveBeenCalled(); + expect(http.get).not.toHaveBeenCalled(); + }); + + it('should report UP_TO_DATE when latest matches', async () => { + const result = await check(packageName, '1.0.0', { latest: '1.0.0' }, [ + '1.0.0', + ]); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should report UP_TO_DATE for pinned exact version', async () => { + const result = await check( + `${packageName}@1.0.0`, + '1.0.0', + { latest: '2.0.0' }, + ['1.0.0', '2.0.0'], + ); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should check correct dist-tag for non-latest tag installs', async () => { + const result = await check( + `${packageName}@beta`, + '2.0.0-beta.1', + { latest: '1.0.0', beta: '2.0.0-beta.2' }, + ['1.0.0', '2.0.0-beta.1', '2.0.0-beta.2'], + ); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should report UP_TO_DATE for beta tag when on latest beta', async () => { + const result = await check( + `${packageName}@beta`, + '2.0.0-beta.2', + { latest: '1.0.0', beta: '2.0.0-beta.2' }, + ['1.0.0', '2.0.0-beta.2'], + ); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + }, +); diff --git a/packages/core/src/extension/npm.ts b/packages/core/src/extension/npm.ts index 79cd0340939..f5416b67f5c 100644 --- a/packages/core/src/extension/npm.ts +++ b/packages/core/src/extension/npm.ts @@ -53,7 +53,7 @@ function resolveNpmRedirectUrl(currentUrl: string, location: string): URL { } /** - * Parse a scoped npm package source string into name and optional version. + * Parse an npm package source string into name and optional version. * Examples: * "@ali/openclaw-tmcp-dingtalk" → { name: "@ali/openclaw-tmcp-dingtalk" } * "@ali/openclaw-tmcp-dingtalk@1.2.0" → { name: "@ali/openclaw-tmcp-dingtalk", version: "1.2.0" } @@ -63,12 +63,12 @@ export function parseNpmPackageSource(source: string): { name: string; version?: string; } { - // Scoped package: @scope/name[@version] - // First @ is the scope prefix, last @ (after scope/) is the version delimiter - const match = source.match(/^(@[^/]+\/[^@]+)(?:@(.+))?$/); + const match = source.match( + /^(@[^/]+\/[^@]+|[a-zA-Z0-9-][a-zA-Z0-9_.-]*)(?:@(.+))?$/, + ); if (!match) { throw new Error( - `Invalid scoped npm package source: ${redactUrlCredentials(source)}`, + `Invalid npm package source: ${redactUrlCredentials(source)}`, ); } return { @@ -84,8 +84,15 @@ export function isScopedNpmPackage(source: string): boolean { return /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(@.+)?$/.test(source); } +export function isNpmPackage(source: string): boolean { + return ( + isScopedNpmPackage(source) || + /^[a-zA-Z0-9-][a-zA-Z0-9_.-]*(@[^/\\:@]+)?$/.test(source) + ); +} + /** - * Resolve the npm registry URL for a scoped package. + * Resolve the npm registry URL for a package. * * Priority: * 1. Explicit CLI override (registryOverride parameter) @@ -94,7 +101,7 @@ export function isScopedNpmPackage(source: string): boolean { * 4. Fallback: https://registry.npmjs.org/ */ export function resolveNpmRegistry( - scope: string, + scope: string | undefined, registryOverride?: string, ): string { if (registryOverride) { @@ -116,11 +123,13 @@ export function resolveNpmRegistry( for (const line of lines) { const trimmed = line.trim(); // Scoped registry: @scope:registry=https://... - const scopeMatch = trimmed.match( - new RegExp( - `^${scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:registry\\s*=\\s*(.+)`, - ), - ); + const scopeMatch = scope + ? trimmed.match( + new RegExp( + `^${scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:registry\\s*=\\s*(.+)`, + ), + ) + : null; if (scopeMatch && !scopedRegistry) { scopedRegistry = scopeMatch[1].trim().replace(/\/$/, ''); } @@ -532,7 +541,7 @@ export async function downloadFromNpmRegistry( const { name, version: requestedVersion } = parseNpmPackageSource( installMetadata.source, ); - const scope = name.split('/')[0]; + const scope = name.startsWith('@') ? name.split('/')[0] : undefined; const configuredRegistryUrl = resolveNpmRegistry(scope, undefined); const registryUrl = installMetadata.registryUrl || configuredRegistryUrl; @@ -652,7 +661,7 @@ export async function checkNpmUpdate( ): Promise { try { const { name } = parseNpmPackageSource(installMetadata.source); - const scope = name.split('/')[0]; + const scope = name.startsWith('@') ? name.split('/')[0] : undefined; const configuredRegistryUrl = resolveNpmRegistry(scope, undefined); const registryUrl = installMetadata.registryUrl || configuredRegistryUrl; const authToken = getNpmAuthToken(registryUrl, configuredRegistryUrl); From 7da3f043d81a658ce01e4464e8c7744b5942df65 Mon Sep 17 00:00:00 2001 From: Kun De Date: Thu, 29 Oct 2026 08:42:48 +0800 Subject: [PATCH 008/166] feat(web-shell): move no-workspace sessions beside workspaces (#15319) * feat(web-shell): move no-workspace sessions beside workspaces * test(web-shell): strengthen no-workspace collapse regressions * test(web-shell): cover persisted no-workspace re-expansion * fix(web-shell): preserve no-workspace preference during search --------- Co-authored-by: qwen-code-dev-bot Co-authored-by: Shaojin Wen --- .../images/no-workspace-sessions-after.png | Bin 0 -> 52432 bytes .../images/no-workspace-sessions-before.png | Bin 0 -> 48360 bytes docs/design/no-workspace-session-placement.md | 75 +++++++++++++++ .../no-workspace-session-placement.zh-CN.md | 65 +++++++++++++ .../sidebar/StandaloneRecents.test.tsx | 86 +++++++++++++++++ .../components/sidebar/StandaloneRecents.tsx | 14 ++- .../components/sidebar/WebShellSidebar.tsx | 89 +++++++++--------- ...WebShellSidebar.workspace-removal.test.tsx | 9 +- .../client/e2e/web-shell.standalone.spec.ts | 73 ++++++++++++++ 9 files changed, 363 insertions(+), 48 deletions(-) create mode 100644 docs/design/images/no-workspace-sessions-after.png create mode 100644 docs/design/images/no-workspace-sessions-before.png create mode 100644 docs/design/no-workspace-session-placement.md create mode 100644 docs/design/no-workspace-session-placement.zh-CN.md diff --git a/docs/design/images/no-workspace-sessions-after.png b/docs/design/images/no-workspace-sessions-after.png new file mode 100644 index 0000000000000000000000000000000000000000..783e9d279be3018238eb39a2fb963754c63a873c GIT binary patch literal 52432 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 lZ;l|+zz}}5Mp~q#I~1jR(T#LVv&elf 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 C#}&2! literal 0 HcmV?d00001 diff --git a/docs/design/no-workspace-session-placement.md b/docs/design/no-workspace-session-placement.md new file mode 100644 index 00000000000..f7caa2f8c56 --- /dev/null +++ b/docs/design/no-workspace-session-placement.md @@ -0,0 +1,75 @@ +# No-workspace session placement + +[English](no-workspace-session-placement.md) | [简体中文](no-workspace-session-placement.zh-CN.md) + +## Problem and scope + +Issue #15309 describes a discoverability problem: the no-workspace session group +is nested inside Workspaces and disappears when Workspaces is collapsed. Its own +collapse state also resets on reload. The group should be a peer of Workspaces. + +The agreed name is “No-workspace sessions / 无工作区会话”, confirmed by the +requester after discussion. Retain that label in both languages. + +## Design + +Move the active `StandaloneRecents` node in `WebShellSidebar.tsx` after the +Workspaces body, before Archived. Preserve its existing props and visibility +guards, including the Live view restriction previously supplied by the outer +wrapper. It continues to use the focused daemon client; no other host's sessions +are added. Locked and section-specific views retain their existing restrictions. + +In `StandaloneRecents.tsx`, reuse `workspaceExpansion.ts` to read and write the +existing expanded state, with distinct `standalone:active` and +`standalone:archived` preference ids. Missing preferences default to expanded. +Search continues to show matching rows even if the saved group state is +collapsed, without overwriting that preference. While searching, the group +header toggle is disabled; clearing the query restores it and the saved state. +Pagination resets on collapse as before. Archived sessions stay inside Archived. + +An explicit collapse remains authoritative when opening a session from Recent, +a deep link, or the group's New task action. These actions do not automatically +expand the group or overwrite its preference. This follows the ordinary workspace +section convention and preserves the existing creation behavior; the header and +search remain available to reveal rows. + +Recent intentionally continues to aggregate no-workspace sessions, so one session +can appear in both its source group and Recent, as ordinary workspace sessions +already do. Main's source-aware selection tracks the clicked copy. Moving the +group does not change Recent membership or assign a new session owner. + +## Constraints and risks + +Preserve creation, opening, selection, search, pagination, lifecycle actions, +capability checks, and daemon ownership. No backend, SDK, storage migration, +filesystem permissions, or automatic workspace association changes are needed. +The layout changes position only; it reuses the current group header and rows. +An unavailable localStorage retains the existing safe default. + +## Validation and acceptance + +Add browser coverage showing that collapsing Workspaces leaves the no-workspace +group visible, that both collapse states survive reload independently, and +that searching and opening a matching standalone session still work. Retain +capability restriction coverage. Add component coverage for independent active +and archived expansion preferences. Capture before/after browser evidence with +a simulated daemon and clearly distinguish it from live daemon testing. + +Browser evidence with Workspaces collapsed (simulated daemon): + +| Before | After | +| -------------------------------------------------------- | ------------------------------------------------------------- | +| ![Group hidden](images/no-workspace-sessions-before.png) | ![Peer group visible](images/no-workspace-sessions-after.png) | + +Run the repository build, typecheck, bundle, relevant component and browser tests, +formatting and lint checks, then audit the full diff. + +## Decisions and related work + +This design supersedes the nested-placement descriptions in the historical +[standalone daemon design](standalone-daemon-sessions.md) and +[sidebar session scope design](web-shell-sidebar-session-scope.md). + +The name is settled: retain “No-workspace sessions / 无工作区会话”. This PR +addresses peer placement and persistence and closes #15309. Recent-session aggregation (#14904), creation refresh +(#15311), and terminology (#14988) remain separate work. diff --git a/docs/design/no-workspace-session-placement.zh-CN.md b/docs/design/no-workspace-session-placement.zh-CN.md new file mode 100644 index 00000000000..102fbb89142 --- /dev/null +++ b/docs/design/no-workspace-session-placement.zh-CN.md @@ -0,0 +1,65 @@ +# 无工作区会话的位置 + +[English](no-workspace-session-placement.md) | [简体中文](no-workspace-session-placement.zh-CN.md) + +## 问题与范围 + +Issue #15309 描述了一个可发现性问题:无工作区会话分组嵌套在工作区内部, +折叠工作区后便不可见。分组自身的折叠状态在刷新后也会重置。 +该分组应与工作区同级。 + +经讨论,请求者已确认名称为“No-workspace sessions / 无工作区会话”。 +两种语言均保留该名称。 + +## 设计 + +将 `WebShellSidebar.tsx` 中活动会话的 `StandaloneRecents` 节点移动到工作区 +主体之后、已归档之前。保留现有属性与可见性判断,包括原本由外层容器提供的 +Live 视图限制。继续使用当前聚焦 daemon 的客户端,不增加其他主机的会话。 +锁定工作区及特定分区视图继续遵守原有限制。 + +在 `StandaloneRecents.tsx` 中复用 `workspaceExpansion.ts` 读写现有展开状态, +分别使用 `standalone:active` 和 `standalone:archived` 偏好 id。 +没有偏好时默认展开。搜索仍会在分组已保存为折叠时显示匹配条目,且不覆盖 +已保存偏好。搜索期间禁用分组标题的折叠按钮;清除查询后恢复按钮和已保存状态。 +折叠时仍重置分页展开状态。归档会话仍位于已归档区域内。 + +从最近会话、深链接或分组的新建任务入口打开会话时,用户明确选择的折叠状态 +仍优先。这些操作不自动展开分组,也不覆盖折叠偏好。这遵循普通工作区分组的 +约定,并保留已有创建行为;用户仍可通过标题和搜索显示会话行。 + +最近会话继续有意汇总无工作区会话,因此同一会话可同时出现在来源分组和 +最近会话中,与普通工作区会话的展示方式一致。main 中已有来源感知的选中 +逻辑负责跟踪被点击的副本。本次移动不改变最近会话成员或分配新的会话归属。 + +## 约束与风险 + +保留创建、打开、选中、搜索、分页、生命周期操作、能力检查与 daemon 归属。 +无需修改后端、SDK、存储迁移、文件系统权限或自动工作区关联。 +布局只调整位置,复用现有分组标题与会话行。 +localStorage 不可用时仍采用现有安全默认值。 + +## 验证与验收 + +新增浏览器覆盖,确认折叠工作区后无工作区分组仍可见,两个分组的折叠状态 +分别跨刷新保留,搜索及打开匹配的独立会话仍然正常。保留能力限制覆盖。 +新增组件覆盖以验证活动与已归档分组的展开偏好互不干扰。 +使用模拟 daemon 捕获修改前后的浏览器证据,并明确区分真实 daemon 测试。 + +折叠工作区后的浏览器证据(模拟 daemon): + +| 修改前 | 修改后 | +| ---------------------------------------------------- | ------------------------------------------------------- | +| ![分组隐藏](images/no-workspace-sessions-before.png) | ![同级分组可见](images/no-workspace-sessions-after.png) | + +运行仓库构建、类型检查、打包、相关组件和浏览器测试、格式及 lint 检查, +然后审查完整差异。 + +## 决定与关联工作 + +本设计取代历史[独立会话 daemon 设计](standalone-daemon-sessions.md)及 +[侧栏会话范围设计](web-shell-sidebar-session-scope.md)中关于嵌套位置的描述。 + +名称已确定:保留“No-workspace sessions / 无工作区会话”。 +本 PR 完成同级位置调整及持久化,并关闭 #15309。 +最近会话汇总(#14904)、创建刷新(#15311)与术语(#14988)保持独立。 diff --git a/packages/web-shell/client/components/sidebar/StandaloneRecents.test.tsx b/packages/web-shell/client/components/sidebar/StandaloneRecents.test.tsx index aec2dc0084c..69d5dda25a4 100644 --- a/packages/web-shell/client/components/sidebar/StandaloneRecents.test.tsx +++ b/packages/web-shell/client/components/sidebar/StandaloneRecents.test.tsx @@ -58,6 +58,7 @@ describe('StandaloneRecents', () => { let root: Root; beforeEach(() => { + window.localStorage.clear(); vi.spyOn(console, 'warn').mockImplementation(() => {}); mocks.t.mockImplementation((key: string) => key); mocks.list.mockReset(); @@ -224,6 +225,91 @@ describe('StandaloneRecents', () => { }); }); + it.each(['active', 'archived'] as const)( + 'persists %s collapse across remounts without collapsing the other group', + async (archiveState) => { + const collapsedTitle = + archiveState === 'active' ? 'Active chat' : 'Archived chat'; + const otherState = archiveState === 'active' ? 'archived' : 'active'; + const otherTitle = + archiveState === 'active' ? 'Archived chat' : 'Active chat'; + await render({ archiveState, currentSessionId: archiveState }); + const toggle = () => + container.querySelector( + `[data-testid="standalone-${archiveState}-group"] > div > button`, + )!; + await act(async () => toggle().click()); + expect(container.textContent).not.toContain(collapsedTitle); + await act(async () => root.unmount()); + root = createRoot(container); + await render({ archiveState, currentSessionId: archiveState }); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + expect(container.textContent).not.toContain(collapsedTitle); + + await act(async () => root.unmount()); + root = createRoot(container); + await render({ archiveState: otherState }); + expect(container.textContent).toContain(otherTitle); + expect( + container + .querySelector('[aria-expanded]') + ?.getAttribute('aria-expanded'), + ).toBe('true'); + + await act(async () => root.unmount()); + root = createRoot(container); + await render({ archiveState, currentSessionId: archiveState }); + await act(async () => toggle().click()); + expect( + window.localStorage.getItem( + `qwen.web-shell.sidebar.workspace-expanded:standalone:${archiveState}`, + ), + ).toBe('true'); + await act(async () => root.unmount()); + root = createRoot(container); + await render({ archiveState, currentSessionId: archiveState }); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain(collapsedTitle); + }, + ); + + it.each([ + ['active', false], + ['active', true], + ['archived', false], + ['archived', true], + ] as const)( + 'preserves the %s expanded preference %s when clicking its header during search', + async (archiveState, expanded) => { + const title = archiveState === 'active' ? 'Active chat' : 'Archived chat'; + const key = `qwen.web-shell.sidebar.workspace-expanded:standalone:${archiveState}`; + const toggle = () => + container.querySelector( + `[data-testid="standalone-${archiveState}-group"] > div > button`, + )!; + await render({ archiveState }); + await act(async () => toggle().click()); + if (expanded) await act(async () => toggle().click()); + expect(window.localStorage.getItem(key)).toBe(String(expanded)); + + await render({ archiveState, searchQuery: archiveState.toUpperCase() }); + expect(container.textContent).toContain(title); + await act(async () => toggle().click()); + expect(window.localStorage.getItem(key)).toBe(String(expanded)); + expect(toggle().getAttribute('aria-expanded')).toBe(String(expanded)); + expect(container.textContent).toContain(title); + + await render({ archiveState, searchQuery: ' ' }); + expect(toggle().disabled).toBe(false); + expect(container.textContent?.includes(title)).toBe(expanded); + await act(async () => root.unmount()); + root = createRoot(container); + await render({ archiveState }); + expect(toggle().getAttribute('aria-expanded')).toBe(String(expanded)); + expect(container.textContent?.includes(title)).toBe(expanded); + }, + ); + it('loads only the requested archived group', async () => { const { renderSession } = await render({ archiveState: 'archived' }); diff --git a/packages/web-shell/client/components/sidebar/StandaloneRecents.tsx b/packages/web-shell/client/components/sidebar/StandaloneRecents.tsx index 6bb7feffbf2..33a5923f623 100644 --- a/packages/web-shell/client/components/sidebar/StandaloneRecents.tsx +++ b/packages/web-shell/client/components/sidebar/StandaloneRecents.tsx @@ -25,6 +25,10 @@ import { useI18n } from '../../i18n'; import { DialogShell } from '../dialogs/DialogShell'; import { SessionRenameDialog } from '../dialogs/SessionRenameDialog'; import { Button } from '../ui/button'; +import { + readWorkspaceExpanded, + writeWorkspaceExpanded, +} from './workspaceExpansion'; import sidebarStyles from './WebShellSidebar.module.css'; import workspaceStyles from './WorkspaceSection.module.css'; @@ -138,7 +142,10 @@ export function StandaloneRecents({ const sessionsRef = useRef(sessions); sessionsRef.current = sessions; const [cursor, setCursor] = useState(); - const [expanded, setExpanded] = useState(true); + const expansionId = `standalone:${archiveState}`; + const [expanded, setExpanded] = useState(() => + readWorkspaceExpanded(expansionId), + ); const [showAll, setShowAll] = useState(false); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); @@ -475,8 +482,11 @@ export function StandaloneRecents({ type="button" className={workspaceStyles.header} aria-expanded={expanded} + disabled={Boolean(query)} onClick={(event) => { - setExpanded((value) => !value); + const nextExpanded = !expanded; + writeWorkspaceExpanded(expansionId, nextExpanded); + setExpanded(nextExpanded); if (event.detail > 0) event.currentTarget.blur(); }} > diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index a23a4e5afe4..24253408806 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -7358,50 +7358,6 @@ export function WebShellSidebar({
{!channelView && managedWorkspaces} - {standaloneSessionsVisible && - onLoadStandaloneSession && - onStandaloneNotice && ( - - renderSessionRow(session, { - isArchived: standalone.isArchived, - standalone, - }) - } - onNewSession={ - onNewStandaloneSession - ? () => { - void Promise.resolve( - onNewStandaloneSession(), - ).then((created) => { - if (created) handleStandaloneMutation(); - }); - } - : undefined - } - onLoadSession={onLoadStandaloneSession} - onRenameSession={(sessionId, displayName) => - onSessionRenameConfirmed?.( - undefined, - sessionId, - displayName, - ) - } - onMutated={handleStandaloneMutation} - onError={onError} - onNotice={onStandaloneNotice} - /> - )} {orderedProjectWorkspaces.map((ws) => ( )}
+ {!liveView && + standaloneSessionsVisible && + onLoadStandaloneSession && + onStandaloneNotice && ( + + renderSessionRow(session, { + isArchived: standalone.isArchived, + standalone, + }) + } + onNewSession={ + onNewStandaloneSession + ? () => { + void Promise.resolve(onNewStandaloneSession()).then( + (created) => { + if (created) handleStandaloneMutation(); + }, + ); + } + : undefined + } + onLoadSession={onLoadStandaloneSession} + onRenameSession={(sessionId, displayName) => + onSessionRenameConfirmed?.( + undefined, + sessionId, + displayName, + ) + } + onMutated={handleStandaloneMutation} + onError={onError} + onNotice={onStandaloneNotice} + /> + )} {!sectionView && !hideProjectHeader && workspaceQualifiedRestCoreEnabled && ( diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index 1e100985486..c4db80139b1 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -7279,13 +7279,17 @@ describe('WebShellSidebar standalone grouping', () => { expect(other!.disabled).toBe(false); }); - it('puts No workspace in Projects and hides it for a locked workspace', async () => { + it('puts No workspace beside Projects and hides it for a locked workspace', async () => { const standaloneCapabilities = { ...capabilities, features: [...capabilities.features, 'standalone_sessions_v1'], }; connection.capabilities = standaloneCapabilities; workspace.capabilities = standaloneCapabilities; + window.localStorage.setItem( + 'qwen.web-shell.sidebar.workspace-expanded:projects', + 'false', + ); renderSidebar({ onLoadStandaloneSession: vi.fn(), onStandaloneNotice: vi.fn(), @@ -7298,7 +7302,8 @@ describe('WebShellSidebar standalone grouping', () => { '[data-testid="standalone-active-group"]', ); expect(group).not.toBeNull(); - expect(group!.closest('[class*="workspaceList"]')).not.toBeNull(); + expect(group!.closest('[class*="workspaceList"]')).toBeNull(); + expect(group!.closest('[hidden]')).toBeNull(); expect(listStandaloneSessionsPage).toHaveBeenCalledWith({ archiveState: 'active', pageSize: 50, diff --git a/packages/web-shell/client/e2e/web-shell.standalone.spec.ts b/packages/web-shell/client/e2e/web-shell.standalone.spec.ts index 98a5a6d26a6..8bd321bfcee 100644 --- a/packages/web-shell/client/e2e/web-shell.standalone.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.standalone.spec.ts @@ -194,6 +194,79 @@ test('an uncertain standalone create stays recoverable and is never retried @smo ).toHaveLength(1); }); +test('no-workspace sessions remain visible beside collapsed Workspaces and persist independently @smoke', async ({ + page, +}, testInfo) => { + const sessionId = '33333333-3333-4333-8333-333333333333'; + const scenario = createWebShellDaemonScenario({ + capabilities: { + features: [...standaloneFeatures, 'workspace_qualified_rest_core'], + }, + standaloneSessions: [ + standaloneSummary(sessionId, 'Independent conversation'), + ], + }); + await installScenario(page, scenario, testInfo); + await page.goto('/'); + + const sidebar = page.getByRole('complementary'); + const projects = sidebar + .getByRole('button', { + name: 'Project', + exact: true, + }) + .first(); + await expect(sidebar.getByTestId('recent-sessions')).toBeVisible(); + const group = sidebar.getByTestId('standalone-active-group'); + const toggle = group.getByRole('button', { + name: 'No-workspace sessions', + exact: true, + }); + const row = group.getByText('Independent conversation', { exact: true }); + await expect(row).toBeVisible(); + await projects.click(); + await expect(projects).toHaveAttribute('aria-expanded', 'false'); + await expect(group).toBeVisible(); + await expect(row).toBeVisible(); + await page.reload(); + await expect(projects).toHaveAttribute('aria-expanded', 'false'); + await expect(row).toBeVisible(); + + await toggle.click(); + await page.reload(); + await expect(projects).toHaveAttribute('aria-expanded', 'false'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(row).toHaveCount(0); + await projects.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await page.reload(); + await expect(projects).toHaveAttribute('aria-expanded', 'true'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + + await sidebar + .getByRole('button', { name: 'Search sessions', exact: true }) + .click(); + await sidebar + .getByRole('textbox', { name: 'Search sessions', exact: true }) + .fill('Independent'); + await expect(row).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect( + await page.evaluate(() => + window.localStorage.getItem( + 'qwen.web-shell.sidebar.workspace-expanded:standalone:active', + ), + ), + ).toBe('false'); + await row.click(); + await expect(page).toHaveURL( + new URL( + `/session/${sessionId}?context=standalone`, + String(testInfo.project.use.baseURL), + ).href, + ); +}); + test('standalone Recents keeps lifecycle actions on exact standalone routes @smoke', async ({ page, }, testInfo) => { From 4afe1a28fd84cead1dbb86a7cbf4cd7d1d35c672 Mon Sep 17 00:00:00 2001 From: jinye Date: Thu, 29 Oct 2026 08:55:09 +0800 Subject: [PATCH 009/166] feat(managed): add independent worktrees for main sessions (#15745) * feat(managed): add independent worktrees for main sessions * fix(managed): reconcile session worktree contract guards Join worktree descriptors into session pages while preserving the existing query budgets. Keep schema, CSI inventory, surface captures and lock-order witnesses consistent with the new feature, and memoize cleanup blockers and maintenance operation kinds. Co-authored-by: Qwen-Coder * fix(managed): require initial worktree checkout completion Record checkout completion only after Git succeeds and refuse reentry or verification without the matching durable receipt. Preserve incomplete directories and user edits. Co-authored-by: Qwen-Coder * fix(managed): allow shared local project imports Keep registration metadata private to each local installation so existing projects can be imported without taking over the parent marker. Refuse operations that require exclusive authority over shared storage. Co-authored-by: Qwen-Coder * feat(web-shell): streamline managed worktree controls Show the actual Git branch and use a one-click worktree choice that creates isolation on the first message. Keep ready worktree metadata in session details and match Local sidebar markers while preserving frozen retries and preparation feedback. Co-authored-by: Qwen-Coder * fix(managed): address worktree lifecycle regressions Keep cancellation available before worktree readiness, bind persistence to the Managed catalog, use maintenance-safe cleanup IDs and isolate Git scan scheduling. Refuse new checkouts nested under their source repository while retaining existing cleanup, and repair the affected CI fixtures. Co-authored-by: Qwen-Coder --------- Co-authored-by: Qwen-Coder --- ...26-10-21-managed-local-directory-import.md | 14 +- ...21-managed-local-directory-import.zh-CN.md | 13 +- ...026-10-28-managed-local-shared-projects.md | 53 ++ ...-28-managed-local-shared-projects.zh-CN.md | 53 ++ .../2026-10-28-managed-session-worktrees.md | 144 ++++ ...6-10-28-managed-session-worktrees.zh-CN.md | 103 +++ .../2026-10-28-managed-worktree-composer.md | 39 + ...6-10-28-managed-worktree-composer.zh-CN.md | 39 + docs/users/managed-local-serve.md | 11 +- docs/users/managed-local-serve.zh-CN.md | 9 +- docs/users/managed-session-worktrees.md | 44 + docs/users/managed-session-worktrees.zh-CN.md | 35 + packages/cli/src/serve/capability-parity.ts | 1 + .../cli/src/serve/managed-local-serve.test.ts | 2 + packages/cli/src/serve/managed-local-serve.ts | 2 + .../src/serve/managed-runtime-bootstrap.ts | 27 +- .../src/serve/managed-workspace-files.test.ts | 50 +- .../cli/src/serve/managed-workspace-files.ts | 75 +- .../serve/managed-workspace-upload.test.ts | 60 +- .../cli/src/serve/managed-workspace-upload.ts | 17 +- packages/managed-agent-server/README.md | 8 +- .../api/PublicAgentController.java | 12 +- .../api/SessionWorktreeController.java | 115 +++ .../api/WebShellAgentController.java | 12 +- .../api/WorkspaceFileController.java | 15 +- .../config/ManagedAgentProperties.java | 9 + .../config/ManagedArtifactConfiguration.java | 5 + .../code/managedagent/model/ApiModels.java | 75 +- .../code/managedagent/model/StoreModels.java | 6 +- .../persistence/ManagedAgentStore.java | 106 ++- .../persistence/ManagedUserMessageStore.java | 2 + .../persistence/ManagedWorkspaceRegistry.java | 36 +- .../persistence/SessionWorktreeStore.java | 477 +++++++++++ .../persistence/WorkspaceCreationRoots.java | 36 +- .../persistence/WorkspaceCreationStore.java | 2 +- .../WorkspaceCsiApplicationInventory.java | 7 + .../persistence/WorkspaceExecutionStore.java | 29 +- .../persistence/WorkspaceFileStore.java | 105 ++- .../persistence/WorkspaceFileUploadStore.java | 49 +- .../WorkspaceMaintenanceRecovery.java | 57 +- .../persistence/WorkspaceMigrationStore.java | 4 + .../persistence/WorkspaceOperations.java | 7 +- .../persistence/WorkspaceStorageGuard.java | 38 +- .../managedagent/session/AgentStateStore.java | 27 + .../session/HarnessCoordinator.java | 14 +- .../session/ManagedAgentService.java | 178 ++-- .../workspace/ChildWorktreeGit.java | 29 +- .../workspace/SessionWorktreeGit.java | 328 +++++++ .../workspace/SessionWorktreeService.java | 268 ++++++ .../managedschema/ManagedSchemaContract.java | 2 + .../db/managed-schema/V1__managed_schema.sql | 27 +- .../managed-agent-public-api.openapi.json | 805 +++++++++++++++++- .../managedagent/BackfillWitnessesTest.java | 2 +- .../ManagedAgentApiContractTest.java | 31 + .../ManagedWorkspaceAdmissionTest.java | 161 ++++ .../SurfaceAdmissionAcceptanceTest.java | 11 + .../managedagent/api/SurfaceRegistry.java | 18 + .../api/WorkspaceFileControllerTest.java | 33 +- .../config/ManagedAgentPropertiesTest.java | 1 + .../persistence/SessionRowLockOrderTest.java | 13 +- .../persistence/SessionWorktreeMySqlIT.java | 22 + .../persistence/SessionWorktreeStoreTest.java | 223 +++++ .../SharedLocalProjectsMySqlIT.java | 24 + .../persistence/SharedLocalProjectsTest.java | 199 +++++ .../WorkspaceCreationStoreTest.java | 9 +- .../WorkspaceCsiApplicationInventoryTest.java | 13 +- .../WorkspaceCsiNativeActivationGateTest.java | 2 +- .../WorkspaceCsiSessionMainTest.java | 2 +- .../WorkspaceCsiSqlWorkerMySqlIT.java | 2 +- .../persistence/WorkspaceFileStoreTest.java | 140 ++- .../WorkspaceFileUploadStoreTest.java | 42 + .../WorkspaceMigrationMySqlIT.java | 5 +- .../session/HarnessCoordinatorTest.java | 63 +- .../ManagedAgentServiceWorktreeTest.java | 86 ++ .../workspace/SessionWorktreeGitTest.java | 425 +++++++++ .../workspace/SessionWorktreeServiceTest.java | 150 ++++ .../managedschema/ManagedSchemaTest.java | 2 +- .../capability-parity/managed-surface.txt | 10 +- .../client/components/ChatEditor.test.tsx | 44 + .../client/components/ChatEditor.tsx | 14 +- .../ManagedSessionNavigationRow.test.tsx | 15 + .../managed/ManagedSessionNavigationRow.tsx | 15 +- .../managed/ManagedSessionWorktrees.test.tsx | 150 ++++ .../managed/ManagedSessionWorktrees.tsx | 318 +++++++ .../managed/ManagedSessionsPage.test.tsx | 109 ++- .../managed/ManagedSessionsPage.tsx | 13 +- .../managed/ManagedWorktreePopover.tsx | 159 ++++ .../managed/WorkspaceBindingCreator.test.tsx | 466 +++++++++- .../managed/WorkspaceBindingCreator.tsx | 173 +++- .../managed/generated/managed-agent-api.ts | 257 +++++- .../managed/java-managed-agent-client.test.ts | 47 + .../managed/java-managed-agent-client.ts | 55 +- .../java-managed-agent-provider.test.ts | 12 +- .../managed/java-managed-agent-provider.ts | 30 + .../managed/managed-agent-provider.ts | 61 +- .../managed/managed-worktree-messages.ts | 122 +++ .../use-managed-file-references.test.tsx | 43 + .../managed/use-managed-file-references.ts | 33 +- .../managed/use-managed-file-upload.ts | 21 +- .../managed/use-managed-session.test.tsx | 106 +++ .../components/managed/use-managed-session.ts | 28 + .../sidebar/SessionDetailsTooltip.tsx | 4 + .../web-shell/client/e2e/managed-mcp.spec.ts | 9 + .../client/e2e/managed-recovery.spec.ts | 4 + ...managed-ssh-workspace-registration.spec.ts | 4 + .../client/e2e/managed-usability.spec.ts | 18 + 106 files changed, 7414 insertions(+), 286 deletions(-) create mode 100644 docs/design/2026-10-28-managed-local-shared-projects.md create mode 100644 docs/design/2026-10-28-managed-local-shared-projects.zh-CN.md create mode 100644 docs/design/2026-10-28-managed-session-worktrees.md create mode 100644 docs/design/2026-10-28-managed-session-worktrees.zh-CN.md create mode 100644 docs/design/2026-10-28-managed-worktree-composer.md create mode 100644 docs/design/2026-10-28-managed-worktree-composer.zh-CN.md create mode 100644 docs/users/managed-session-worktrees.md create mode 100644 docs/users/managed-session-worktrees.zh-CN.md create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SessionWorktreeController.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStore.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGit.java create mode 100644 packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeService.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeMySqlIT.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStoreTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsMySqlIT.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentServiceWorktreeTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGitTest.java create mode 100644 packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeServiceTest.java create mode 100644 packages/web-shell/client/components/managed/ManagedSessionWorktrees.test.tsx create mode 100644 packages/web-shell/client/components/managed/ManagedSessionWorktrees.tsx create mode 100644 packages/web-shell/client/components/managed/ManagedWorktreePopover.tsx create mode 100644 packages/web-shell/client/components/managed/managed-worktree-messages.ts diff --git a/docs/design/2026-10-21-managed-local-directory-import.md b/docs/design/2026-10-21-managed-local-directory-import.md index 535e744efcd..bfaa03c1754 100644 --- a/docs/design/2026-10-21-managed-local-directory-import.md +++ b/docs/design/2026-10-21-managed-local-directory-import.md @@ -51,10 +51,9 @@ A concurrent first import can fail closed while registration is pending; a retry may reuse the now-completed owned root after verifying the pinned identity. Revoked access is never restored by replay. -The selected project becomes `defaultCwdRelative`. The registered parent and -its physical-owner lease are shared, as in existing directory selection; -selecting a child directory does not establish filesystem isolation. The marker -stays outside Git cleanup and the project remains the initial working directory. +The selected project becomes `defaultCwdRelative`. Within one installation, the registered parent and its catalog-local execution lease are shared, as in existing directory selection; selecting a child directory does not establish filesystem isolation. The parent remains a navigation/worktree-layout boundary, not a global claim over its contents. + +New imports use `imported-` storage IDs and private registration metadata under the Runtime state directory. They leave an existing global marker untouched and allow independent local catalogs to use the same project. Whole-storage migration, physical recovery and exclusive file-history rewind are refused for these shared imports; own-process Runtime recovery stays available. Existing `created-` receipts retain their original global marker contract and are not rewritten automatically. See the [shared-project design](2026-10-28-managed-local-shared-projects.md). ## Browser flow and contract @@ -92,8 +91,7 @@ contract stays unchanged. File queries and an initial tool Turn use the imported root. 3. Retry, restart, disabled future imports, second-project reuse and concurrent first imports preserve durable authority and idempotency. -4. Missing/replaced directories, foreign markers, overlapping roots, private - state, invalid requests and revoked grants fail without creating authority. +4. Missing/replaced directories, missing or mismatched private registration metadata, overlapping roots, private state, invalid requests and revoked grants fail without creating authority. Legacy imports continue to reject foreign global markers; new shared imports preserve them. 5. Disabled or remotely exposed deployments cannot browse arbitrary host paths. Existing empty creation, authorized directory selection and SSH still work. 6. Run focused H2/unit tests, disposable MySQL 8.4 integration tests, Maven @@ -102,7 +100,7 @@ contract stays unchanged. ## Status and open questions -Implemented. Native macOS checks cover import from an empty registry, file +The original import implementation is complete. Its earlier native macOS checks cover import from an empty registry, file queries, model Read tools, active-root reuse, browser retry, and restart with new imports disabled. Native Linux execution was not exercised locally. Targeted Java/MySQL and TypeScript/browser checks are recorded separately from @@ -114,3 +112,5 @@ The local-launcher follow-up also verifies loopback SIGNED startup, retained bearer/signature refusals, absolute-path browser import, an actual Read tool turn and restart retention on native macOS. Detailed command results and test artifact limitations remain in `.qwen/e2e-tests/managed-local-import.md`. + +The shared-project follow-up has separate local validation recorded in its [design](2026-10-28-managed-local-shared-projects.md). Earlier model, browser and restart evidence above does not establish those paths for the follow-up. diff --git a/docs/design/2026-10-21-managed-local-directory-import.zh-CN.md b/docs/design/2026-10-21-managed-local-directory-import.zh-CN.md index 6681a79b764..0c0c1cdd701 100644 --- a/docs/design/2026-10-21-managed-local-directory-import.zh-CN.md +++ b/docs/design/2026-10-21-managed-local-directory-import.zh-CN.md @@ -40,9 +40,9 @@ Workspace 创建根。同一根目录使用期间再次导入项目,应复用 首次并发导入遇到尚未完成的登记时可以安全拒绝;重试时,在验证固定物理身份后, 可以复用已经完成的自有根目录。回执重放不得恢复已撤销的授权。 -选中的项目成为 `defaultCwdRelative`。与现有目录选择一致,登记的父目录及物理 -owner 租约由相关 Workspace 共享;选择子目录不产生文件系统隔离。标记留在 -Git 清理范围之外,项目本身作为初始工作目录。 +选中的项目成为 `defaultCwdRelative`。与现有目录选择一致,同一实例内的登记父目录及 catalog 内执行租约由相关 Workspace 共享;选择子目录不产生文件系统隔离。父目录继续作为导航/worktree 布局边界,不对其内容声明全局独占归属。 + +新导入使用 `imported-` 存储 ID,在 Runtime 状态目录中保存私有登记元数据;已有全局标记保持原样,独立本机 catalog 可以使用同一项目。这些共享导入拒绝整存储迁移、物理恢复和独占文件历史 rewind,本实例进程的 Runtime 恢复仍可用。已有 `created-` 回执保留原全局标记契约,不自动重写。参见[共享项目设计](2026-10-28-managed-local-shared-projects.zh-CN.md)。 ## 浏览器流程与契约 @@ -72,8 +72,7 @@ Git 清理范围之外,项目本身作为初始工作目录。 1. 未登记父目录时即可浏览并导入已有 Git 项目,得到 OWNER 权限,并在项目中启动。 2. 文件保持原样,登记标记位于工作树之外;文件查询和首次工具 Turn 使用导入根目录。 3. 重试、重启、关闭后续导入、复用第二个项目及首次并发导入保持持久化权限和幂等性。 -4. 目录丢失或替换、外来标记、根目录重叠、私有状态、非法请求和授权撤销均安全拒绝, - 不产生新权限。 +4. 目录丢失或替换、私有登记元数据丢失或不匹配、根目录重叠、私有状态、非法请求和授权撤销均安全拒绝,不产生新权限。旧导入继续拒绝外来全局标记,新共享导入保留它们。 5. 未启用或向远程暴露的部署不能浏览任意主机路径。原有空目录创建、授权目录选择及 SSH 保持可用。 6. 执行专项 H2/单元测试、临时 MySQL 8.4 集成测试、Maven 验证、TypeScript @@ -82,7 +81,7 @@ Git 清理范围之外,项目本身作为初始工作目录。 ## 状态与开放问题 -已实现。原生 macOS 验证覆盖空登记表导入、文件查询、模型 Read 工具、运行中根目录复用、 +原始目录导入已实现。此前的原生 macOS 验证覆盖空登记表导入、文件查询、模型 Read 工具、运行中根目录复用、 浏览器重试,以及关闭新增导入后的重启。未在本地执行原生 Linux 验收。专项 Java/MySQL 和 TypeScript/浏览器检查与中断的 Java 全量测试分别记录在 `.qwen/e2e-tests/`。 没有未决产品选择。普通创建回执省略未使用的导入字段,继续支持旧 Java 版本读取。 @@ -90,3 +89,5 @@ Git 清理范围之外,项目本身作为初始工作目录。 本机启动器后续验收还覆盖原生 macOS 上的回环 SIGNED 启动、保留的 bearer/签名 拒绝、浏览器绝对路径导入、真实 Read 工具 Turn 和重启保留。详细命令结果与测试 产物限制记录在 `.qwen/e2e-tests/managed-local-import.md`。 + +共享项目后续改动的本地验证单独记录于对应[设计](2026-10-28-managed-local-shared-projects.zh-CN.md)。上面的早期模型、浏览器和重启证据不能作为该后续改动已验证这些路径的证明。 diff --git a/docs/design/2026-10-28-managed-local-shared-projects.md b/docs/design/2026-10-28-managed-local-shared-projects.md new file mode 100644 index 00000000000..d4f5b39f897 --- /dev/null +++ b/docs/design/2026-10-28-managed-local-shared-projects.md @@ -0,0 +1,53 @@ +# Shared local projects in Managed + +[English](2026-10-28-managed-local-shared-projects.md) | [简体中文](2026-10-28-managed-local-shared-projects.zh-CN.md) + +## Status and problem + +Local implementation based on `149d577ed6`; validation is recorded below. Existing local imports register a project parent as a physical storage root and publish one `.qwen-managed-storage.json` there. Independent local installations use separate catalogs and identities, so the second installation refuses that marker. Stopping a launcher does not transfer the registration or prove its durable workers stopped. + +The requested behavior is to let trusted local installations use the same existing project without a parent-directory takeover. Each installation continues to own its catalog, processes and allocated worktrees. This follow-up is included in PR #15745 alongside the main-Session worktree support. + +## Registration and layout + +New local-path imports receive the opaque storage ID `imported-`. Existing `created-` receipts, including earlier local imports, retain their current meaning and marker location. The new prefix must resolve to a valid actor-scoped creation receipt with a pinned `localRoot`; a prefix alone never authorizes a path. No schema migration or public request field is added. + +The project parent remains a shared layout and navigation boundary. Main and child worktree paths stay storage-relative and outside the source Git worktree. Selecting a project continues to set its relative working directory; this does not introduce a project filesystem sandbox or globally claim its parent. + +Registration metadata for a new imported storage lives under `/imported-projects//.qwen-managed-storage.json`. These canonical, private directories belong to this installation. Registration never creates, reads for ownership, replaces or removes the external root's global marker. A pre-existing global marker and the project files remain untouched. + +The catalog still records the original host/device/inode/birth identity and per-installation registration. Source replacement, missing private metadata, malformed receipts, revoked grants and unknown bindings continue to fail closed. Replaying a completed request does not recreate missing metadata or restore access. Turning off new imports does not disable existing imported roots. + +## Execution and maintenance + +`WorkspaceCreationStore` allocates the new storage identity. `WorkspaceCreationRoots` resolves it and its private metadata. `WorkspaceStorageGuard` uses that metadata for registration, normal verification, locked verification, file roots, inspection and probe verification. `WorkspaceExecutionStore` retains its existing catalog-local admission and lease checks and refuses exclusive rewind for the new shared storage. LOCAL Runtime and file protocols do not change; SSH bootstrap and CSI remain outside this behavior. + +Two installations may edit the same imported project, with the same ordinary editing conflicts as independent terminals. No cross-installation exclusive-write or physical-recovery guarantee is introduced. Independent worktrees remain the way to isolate edits. + +Main/child worktree preparation, exact-path cleanup and stopped-operation reconciliation continue to use each installation's recorded IDs and identities. They do not enumerate, prune or delete another installation's worktrees. A maintenance hold coordinates that installation's own executions only. + +Whole-storage fencing, physical snapshot/recovery, file-history rewind and storage migration require exclusive authority and are refused for new shared imports with `workspace_shared_directory` before mutating their storage state. Gate the storage guard's fence, recovery-registration, captured-registration, restore and migration entry points. Own-process Runtime recovery remains available. Application-owned roots, explicitly configured mounts, legacy imports and deployed SSH/CSI retain their existing protection. + +## Compatibility and scope + +The runtime state directory already required by durable local execution also stores import metadata and must be preserved with the catalog. Older Java readers reject the new storage prefix rather than silently treating it as an owned root. Existing receipts and on-disk markers are not migrated, repaired or rewritten automatically. + +This implementation does not relocate worktree containers into a second mount, narrow the existing parent navigation grant, redesign pending-creation UI, coordinate arbitrary external editors or alter deployment authentication. A project-content root with a separate private worktree mount would be a larger follow-up. + +## Validation and acceptance + +Use separate catalogs and private state directories with one real project: both imports must succeed, keep the same project contents and leave an existing foreign global marker byte-identical. Verify completed replay, restart/disabled-new-import behavior, missing or tampered private metadata, source replacement and actor/tenant denial. Legacy and application-owned marker refusal must remain intact. + +Exercise normal file resolution, main worktree creation/cleanup and per-installation metadata independence. Whole-root maintenance must refuse without advancing operation state or touching files. Select affected H2/unit cases and real MySQL 8.4 cases, plus a real-process local startup/import path. Local Java checks share one 600-second budget for this requested change; full supported-platform, fault and static-analysis gates remain with CI. Record failed attempts and gaps separately. + +### Recorded local results + +The unchanged baseline reproduced the collision: installation A imported successfully and installation B returned `workspace_unavailable`. The installed global CLI was 0.24.1 without Managed support, so the baseline used production Java classes with native Git and independent H2 catalogs. + +On the local implementation, the 54 selected creation, storage-guard, shared-import and worktree-store unit/H2 cases passed across focused runs. Three selected MySQL 8.4.11 cases passed: independent catalogs with an untouched foreign marker, legacy receipt resumption and exclusive-maintenance refusal. The native macOS MySQL instance disabled binary logging; the Linux-only native-AIO setting was not applicable. Java packaging and Checkstyle passed. + +Two actual `serve --managed` installations using the changed Java payload and the unchanged built CLI each imported the same fixture, replayed their request, browsed files and reported supported worktree options. A separate production Java/native Git probe prepared two isolated worktrees, refused dirty cleanup and cleaned one without changing the other checkout, private metadata or foreign global marker. Task-owned services and fixtures were removed; the user's running demo and real Projects directory were untouched. + +The first focused run exposed two new-test fixture/expectation errors, not product failures: a completed receipt replay returns the persisted Workspace without re-registering missing metadata, and the seeded legacy receipt must write `result_json` separately from `previousStatus`. Both were corrected and the affected cases rerun. A subsequent Checkstyle failure for import-group blank lines was corrected before the passing package/style run. Failed logs are retained in the local test artifacts. + +These results establish the selected native macOS paths only. Model Turns, browser interaction, process restart/fault recovery, Linux execution, full MySQL/Hosted profiles and full static-analysis gates were not run for this follow-up. Required CI acceptance remains pending; earlier PR or design evidence is not substituted for these gaps. diff --git a/docs/design/2026-10-28-managed-local-shared-projects.zh-CN.md b/docs/design/2026-10-28-managed-local-shared-projects.zh-CN.md new file mode 100644 index 00000000000..0c7aa7650aa --- /dev/null +++ b/docs/design/2026-10-28-managed-local-shared-projects.zh-CN.md @@ -0,0 +1,53 @@ +# Managed 共享本机项目 + +[English](2026-10-28-managed-local-shared-projects.md) | [简体中文](2026-10-28-managed-local-shared-projects.zh-CN.md) + +## 状态与问题 + +基于 `149d577ed6` 的本地实施方案,验证结果记录于下文。当前本机导入把项目父目录登记为物理存储根,并在其中发布唯一的 `.qwen-managed-storage.json`。独立本机实例使用不同的 catalog 和身份,因此第二个实例拒绝已有标记。停止启动器既不会移交登记,也不证明其持久 worker 已停止。 + +目标是让受信任本机实例使用同一已有项目,无需接管父目录。各实例继续管理自己的 catalog、进程和分配的 worktree。本后续改动与主会话 worktree 支持一起纳入 PR #15745。 + +## 登记与布局 + +新建绝对本机路径导入使用不透明存储 ID `imported-`。已有 `created-` 回执(包括早期本机导入)保留当前语义和标记位置。新前缀必须解析到有效的 actor 范围创建回执,并包含固定的 `localRoot`;仅有前缀不能授权路径。不新增 schema migration 或公开请求字段。 + +项目父目录继续作为共享布局及导航边界。主会话和子任务 worktree 保持相对存储根的路径,位于源 Git 工作树外。选择项目仍设置其相对工作目录;这不引入项目文件系统沙箱,也不对父目录声明全局独占归属。 + +新导入存储的登记元数据位于 `/imported-projects//.qwen-managed-storage.json`。这些规范化私有目录属于当前实例。登记不创建外部根的全局标记,不读取它来判断归属,也不替换或删除它。已有全局标记与项目文件均保持原样。 + +catalog 仍保存原始 host/device/inode/birth 身份及当前实例的登记。源目录替换、私有元数据丢失、回执损坏、授权撤销和未知绑定继续拒绝。已完成请求重放不重建丢失元数据,也不恢复授权。关闭新增导入能力不影响已有导入根。 + +## 执行与维护 + +`WorkspaceCreationStore` 分配新存储身份,`WorkspaceCreationRoots` 解析身份及私有元数据。`WorkspaceStorageGuard` 在登记、普通及加锁校验、文件根、检查及探针校验中使用这些元数据。`WorkspaceExecutionStore` 保留现有 catalog 内准入和租约检查,并拒绝新共享存储的独占 rewind。LOCAL Runtime 与文件协议不变;SSH bootstrap 及 CSI 不使用此行为。 + +两个实例可以编辑同一导入项目,承担与多个独立终端相同的普通编辑冲突。本改动不引入跨实例独占写入或物理恢复保证。需要隔离编辑时继续使用独立 worktree。 + +主会话/子任务 worktree 准备、精确路径清理及已停止操作对账继续使用各实例记录的 ID 和身份,不枚举、prune 或删除其他实例的 worktree。维护占用仅协调本实例的执行。 + +整存储 fencing、物理快照/恢复、文件历史 rewind 及存储迁移要求独占权限;新共享导入在改变存储状态前返回 `workspace_shared_directory`。在存储 guard 的 fence、恢复登记、已捕获登记校验、还原及迁移入口拒绝。实例自身进程的 Runtime 恢复仍可使用。应用自有根、显式配置的挂载、旧导入及部署型 SSH/CSI 保留原有保护。 + +## 兼容性与范围 + +持久本机执行已要求的 runtime state directory 同时保存导入元数据,必须与 catalog 一起保留。旧 Java 读取端拒绝新存储前缀,不会静默按自有根处理。不自动迁移、修复或重写已有回执和磁盘标记。 + +本次不把 worktree 容器搬到第二个挂载,不缩小现有父目录导航授权,不重做待确认创建的界面,不协调任意外部编辑器,也不改变部署认证。项目内容根与独立私有 worktree 挂载需要更大的后续改动。 + +## 验证与验收 + +使用不同 catalog 和私有状态目录导入同一个真实项目:两边均成功,项目内容不变,已有外来全局标记逐字节不变。验证已完成重放、重启/关闭新增导入后的访问、私有元数据缺失或篡改、源目录替换以及 actor/tenant 拒绝。旧登记及应用自有根的标记拒绝保持有效。 + +执行普通文件解析、主 worktree 创建/清理及实例元数据独立性检查。整根维护应拒绝,不推进操作状态或修改文件。选择受影响的 H2/单元及真实 MySQL 8.4 用例,并执行真实进程的本机启动/导入链路。当前请求的本地 Java 检查共享一个 600 秒预算;完整支持平台、故障及静态分析门禁留在 CI。分别记录失败尝试和缺口。 + +### 已记录的本地结果 + +未修改基线复现了冲突:实例 A 导入成功,实例 B 返回 `workspace_unavailable`。全局安装的 CLI 为 0.24.1,不支持 Managed,因此基线使用生产 Java 类、原生 Git 和独立 H2 catalog。 + +本地实现的 54 个创建、存储 guard、共享导入及 worktree store 专项单元/H2 用例在专项运行中均通过。MySQL 8.4.11 的三个专项用例通过:独立 catalog 及外来标记保留、旧回执续办和独占维护拒绝。原生 macOS MySQL 实例关闭二进制日志;Linux 专用 native-AIO 设置不适用。Java 打包和 Checkstyle 通过。 + +两套真实 `serve --managed` 使用修改后的 Java payload 和未修改的已构建 CLI,分别导入同一个测试项目、重放请求、浏览文件并返回支持 worktree 的选项。另一个生产 Java/原生 Git 探针准备两个隔离 worktree,拒绝脏目录清理,并清理其中一个而不改变另一个 checkout、私有元数据或外来全局标记。任务自有服务及测试目录已清理;用户正在运行的演示实例和真实 Projects 目录保持原样。 + +首次专项运行暴露了两个新测试的夹具/预期错误,并非产品失败:已完成回执重放返回持久化 Workspace,不重新登记丢失元数据;构造旧回执时必须单独写入 `result_json`,不能作为 `previousStatus` 传入。二者修正后重跑受影响用例。后续 Checkstyle 发现 import 分组缺少空行,修正后打包/样式检查通过。失败日志保留于本地测试产物。 + +这些结果仅证明所选原生 macOS 路径。后续改动未执行模型 Turn、浏览器交互、进程重启/故障恢复、Linux 执行、完整 MySQL/Hosted profile 和完整静态分析门禁。必需 CI 验收仍待完成,不使用早期 PR 或设计证据代替这些缺口。 diff --git a/docs/design/2026-10-28-managed-session-worktrees.md b/docs/design/2026-10-28-managed-session-worktrees.md new file mode 100644 index 00000000000..62f8dbc4da6 --- /dev/null +++ b/docs/design/2026-10-28-managed-session-worktrees.md @@ -0,0 +1,144 @@ +# Managed main Session worktrees + +[English](2026-10-28-managed-session-worktrees.md) | [简体中文](2026-10-28-managed-session-worktrees.zh-CN.md) + +## Status and scope + +Implementation of the local first version, based on `32f6713ce6`. New main +Sessions may opt into a separate Git worktree. The default remains the current +directory. Supported storage is LOCAL on macOS/Linux, with a durable, +Session-isolated local-process Runtime. SSH, CSI, reopening closed Sessions, +automatic merging and switching a running Session between worktrees are outside +this change. Worktrees isolate edits, not host permissions or shared Git refs. + +## Ownership and persistence + +The existing ROOT Workspace continues to own grants and storage. A one-to-one +`ma_session_worktree` records the Session, actor, storage identity, repository +common directory, source offset, target root/cwd, initial branch, frozen commit, +state and revision. Preparation and cleanup use durable `ma_operation` commands. +The identity outlives closing, archiving and deleting the chat. + +The server allocates `.qwen-managed-worktrees/` below the storage root and +`worktree-managed-` as the initial branch. Ownership metadata lives outside +the checkout. The actual cwd preserves the source's repository-relative offset; +that directory must exist in the selected commit. Repository and Git metadata +must stay within the authorized storage. A linked source whose common directory +escapes that root is refused; mounts are never enlarged automatically. +New worktrees require the source repository strictly below the storage root so +its Git add/clean commands cannot include the sibling private container. A +repository at the storage root still supports current-directory Sessions and +safe cleanup of existing worktrees, but new preparation is refused. + +## Creation and execution + +1. Replay the actor-scoped create receipt before checking availability or + resolving HEAD. The digest includes the requested mode/ref; the first + admission separately freezes the resolved commit. +2. Read-only preflight validates source access, storage identity, Git layout and + ref. It does not create branches or directories. +3. One transaction revalidates source identity and inserts the Session with its + final target binding, worktree identity, preparation command, optional first + Turn and create receipt. Context revision starts at one. +4. A claimed worker obtains the physical storage maintenance hold, creates from + the frozen commit and validates the resulting identity before marking ready. + Storage busy defers without consuming the failure budget. +5. Only ready worktrees admit warm, attach, dispatch, new input or file access. + Cancellation, lifecycle draining and queued-message query/edit/withdraw remain + possible before readiness. New queue input and resume require readiness; + rename also remains gated because it attaches to the Harness. + +Worktree state is orthogonal to Session status: `preparing`, `ready`, `failed`, +`blocked`, `removing`, `removed`. Closing during preparation never starts the +Runtime afterwards, even if Git completed. Known preparation failure fails an +initial Turn only if it was never submitted. If the checkout becomes blocked +after submission, its Turn retains the unknown outcome until existing +cancellation/reconciliation yields committed settlement; a blocked checkout alone +cannot prove failure. The UI exposes that block and preserves cancellation. +Retry answers the same identity rather than allocating a new one. + +Ordinary Sessions retain the existing shared execution model. Preparation and +cleanup exclusively maintain the whole physical storage and may wait for other +executions. Reuse the existing safe Git runner and layout checks, but not child +worktree recreation, forced discard or merge semantics. + +## Recovery and cleanup + +Ready Sessions validate the original checkout, registration, common directory +and ownership on use. Normal edits, commits and branch renames do not trigger a +reset. The source checkout need not survive. Missing or replaced targets block +execution and never fall back to the source. Cwd changes stay within the target. + +Claim expiry is not physical stop evidence. Unknown Git execution retains the +maintenance hold and enters blocked until existing stop evidence permits +reconciliation. macOS automatic recovery from a JVM killed during Git is not +introduced. A recheck cannot force-release that hold. + +Only a successful initial `git worktree add` records a forced-to-disk completion +receipt outside the checkout, bound to its filesystem and Git identity. Reentry +and ready checks require that receipt. An existing checkout with missing, partial +or mismatched completion evidence stays blocked, including after verified stop +recovery or Session closure. Recovery never resets files to infer completion; +normal edits and branch renames after a recorded completion remain valid. + +Close/archive/delete preserve the checkout. Cleanup requires the recorded owner, +current Workspace write permission, a terminal Session and drained Runtime, +children, uploads and preparation. Under the maintenance hold it rechecks +ownership, tracked/untracked/ignored files, unfinished Git operations and nested +repositories/submodules. Current HEAD must remain reachable from a retained +normal branch/tag/remote ref. Cleanup uses non-force removal and never deletes +branches. Refusal preserves the checkout. Retained worktrees block physical +storage migration/deletion; completed historical children do not block forever. + +## API, files and WebShell + +Session creation accepts optional `worktree: {baseRef?}` (`base_ref` publicly). +Omission selects the current directory; an empty object selects HEAD. The +server chooses paths and branch names. Session GET/list includes a descriptor. +Session pages join the durable worktree rows in the existing page query, then +verify only the selected rows after closing the result set. The ordinary list +query budget stays unchanged, and invalid ready identities are durably blocked. +Workspace-scoped options, query, inspect and cleanup operations expose support, +retained checkouts, progress and idempotent cleanup, including deleted chats. +Read access follows Workspace grants; deletion additionally checks ownership +and write access. + +File requests accept exactly one locator: Session ID, or Workspace ID plus cwd. +The former resolves the durable binding and includes worktree revision in the +scope revision. The latter remains for pre-creation browsing and cannot enter +private worktree paths. Only server-resolved Session bases may bypass the +private-base restriction; user-relative paths keep their existing validation. +Both locators enforce the SSH runtime-node actor allowlist as well as Workspace +grants. A Session locator keeps its pinned cwd and context revision. + +WebShell offers current directory/worktree in the composer and creates isolated +worktrees from HEAD; an explicit starting ref remains available through the API. +It shows the current branch, freezes pending submissions +and reports preparing, waiting, failed or blocked states. Directory references +and uploads pause before a selected worktree exists; ordinary input attachments +remain available. Ready Sessions restore file capabilities against their target. +Stale directory references require reselection while retaining the text draft. + +Target cwd supplies project instructions. Models, approval policy, Skills, MCP +and Extensions retain existing Session pinning. No ignored configuration, +environment files, dependencies or symlinks are copied. Child agents may still +derive and merge into this main worktree. Workspace management lists retained +worktrees with inspect and cleanup actions. + +## Rollout and validation + +`session-worktrees-enabled` defaults off for deployments. The controlled local +launcher enables supported hosts. Missing/unsupported Git disables this option, +not current-directory Sessions. Disabling new worktrees does not disable +execution, inspection or cleanup of existing identities. + +Update canonical V1 SQL, strict schema validation, OpenAPI, surface contracts and +generated WebShell types together; no V2 migration or schema version bump. + +Acceptance covers independent concurrent edits, create replay after HEAD moves, +dirty source preservation, preparation interruption and stop evidence, close +races, target loss, authorization revocation, file/upload scope, safe cleanup +after branch changes and child merge routing. Use focused unit/contract tests, +real MySQL 8.4 and real Git/Runtime/browser checks on supported platforms. Record +unavailable platform/fault coverage explicitly. Java local validation shares the +module's ten-minute budget; full matrices remain in CI. diff --git a/docs/design/2026-10-28-managed-session-worktrees.zh-CN.md b/docs/design/2026-10-28-managed-session-worktrees.zh-CN.md new file mode 100644 index 00000000000..038cc2e867a --- /dev/null +++ b/docs/design/2026-10-28-managed-session-worktrees.zh-CN.md @@ -0,0 +1,103 @@ +# Managed 主会话 worktree + +[English](2026-10-28-managed-session-worktrees.md) | [简体中文](2026-10-28-managed-session-worktrees.zh-CN.md) + +## 状态与范围 + +基于 `32f6713ce6` 实施本机首版。新建主会话可选择独立 Git worktree,默认仍为当前目录。 +支持 macOS/Linux 上 LOCAL 存储、持久化且按 Session 隔离的 local-process Runtime。 +本次不包含 SSH、CSI、重开关闭的会话、自动合并及运行中切换 worktree。 +worktree 隔离编辑目录,不隔离主机权限及共享 Git refs。 + +## 所有权与持久化 + +现有 ROOT Workspace 继续持有授权和存储。一对一 `ma_session_worktree` 记录会话、actor、 +存储身份、Git common directory、源目录偏移、目标根目录及 cwd、初始分支、冻结 commit、 +状态与修订号。准备和清理使用持久化 `ma_operation`,关闭、归档或删除聊天后仍保留身份。 + +服务端在存储根内分配 `.qwen-managed-worktrees/`,初始分支为 +`worktree-managed-`,所有权元数据置于工作树外。实际 cwd 保留源目录相对仓库的偏移, +该目录必须存在于选定 commit。仓库和 Git 元数据必须位于已授权存储内。 +linked source 的 common directory 越出存储根时拒绝,不自动扩大挂载。 +新建 worktree 要求源仓库严格位于存储根之下,使其 Git add/clean 命令不会包含旁侧的内部目录。 +存储根本身是仓库时,仍支持当前目录会话和已有 worktree 的安全清理,但拒绝新建准备。 + +## 创建与执行 + +1. 在检查可用性或解析 HEAD 前重放 actor 范围的创建回执。摘要包含请求的模式和 ref; + 首次准入另行冻结解析出的 commit。 +2. 只读预检源目录权限、存储身份、Git 布局和 ref,不创建分支或目录。 +3. 单一事务复核源身份,并写入最终目标绑定的 Session、worktree 身份、准备命令、 + 可选首 Turn 和创建回执。context revision 从一开始。 +4. worker 取得 claim 及物理存储维护占用后,从冻结 commit 创建并验证身份,再改为 ready。 + 存储忙仅延后,不消耗失败预算。 +5. 仅 ready 放行 warm、attach、dispatch、新输入和文件访问;就绪前仍可取消、排空生命周期, + 以及查询、编辑和撤回队列消息。新队列输入和恢复队列要求就绪;重命名会连接 Harness, + 因此也保留就绪检查。 + +worktree 状态独立于会话状态:`preparing`、`ready`、`failed`、`blocked`、`removing`、 +`removed`。准备中关闭后,即使 Git 完成也不再启动 Runtime。已知准备失败仅使尚未提交的 +首 Turn 失败。若提交后 checkout 受阻,Turn 保留未知执行结果,直到既有取消/对账流程取得 +已提交的结算证据;仅 checkout 受阻不能证明失败。界面显示该阻塞并保留取消入口。 +重试返回相同身份,不分配另一份目录。 + +普通会话沿用现有共享执行模型。准备和清理独占整个物理存储,可能等待其他执行。 +复用安全 Git runner 和布局检查,不复用 child 的重建、强制丢弃或合并语义。 + +## 恢复与清理 + +ready 会话使用前验证原目录、Git 注册项、common directory 和所有权。正常编辑、提交及 +分支改名不触发重置。源 checkout 无需一直存在。目标丢失或替换时阻止执行,不回退源目录。 +cwd 调整限制于自身目标根内。 + +claim 过期不证明物理进程已停止。Git 结果未知时保留维护占用并进入 blocked,直到既有 +停止证据允许对账。本次不新增 macOS 在 Git 中强杀 JVM 后的自动恢复。 +重新检查不能强制解除占用。 + +仅首次 `git worktree add` 正常成功后,才在工作树外强制落盘完成回执,并绑定目录与 Git +身份。重入及 ready 检查均要求该回执。完成证据缺失、写入不完整或身份不符时,已有目录 +保持阻塞,可信停止恢复或会话关闭后也不例外。恢复不通过重置文件推断完成;记录完成后 +的正常编辑及分支改名仍然有效。 + +关闭、归档及删除保留目录。清理要求记录所有者、当前 Workspace 写权限、终态会话, +并确认 Runtime、子任务、上传及准备均已排空。在维护占用下复核所有权、已跟踪/未跟踪/ +忽略文件、进行中的 Git 操作、嵌套仓库及子模块。当前 HEAD 必须仍由保留的普通分支、tag +或 remote ref 可达。使用非强制移除,始终保留分支;拒绝时保留目录。 +保留的 worktree 阻止物理存储迁移/删除;已完成的历史 child 不永久阻塞清理。 + +## API、文件与 WebShell + +创建 Session 接受可选 `worktree: {baseRef?}`(公开字段为 `base_ref`)。省略即当前目录, +空对象即 HEAD。路径及分支由服务端分配。Session GET/list 返回描述。 +会话分页在既有分页查询中联接持久化 worktree 记录,关闭结果集后仅验证选中的行。 +普通列表的查询预算不变,ready 身份无效时持久化阻塞状态。 +Workspace 下增加 options、query、inspect、cleanup,提供支持情况、保留目录、进度及幂等 +清理,覆盖已删除聊天。读取遵循 Workspace 授权,删除还检查所有权与写权限。 + +文件请求严格二选一:Session ID,或 Workspace ID 加 cwd。前者解析持久绑定并将 worktree +修订纳入 scope revision;后者保留用于创建前浏览,禁止进入内部 worktree 路径。 +仅服务端解析的 Session base 可通过内部保留目录,用户相对路径仍沿用原有验证。 +两种定位方式均检查 Workspace 授权及 SSH Runtime 节点的 actor 白名单。Session 定位保留 +其固定的 cwd 和 context revision。 + +WebShell 在输入框中提供当前目录/worktree,从 HEAD 创建隔离工作树;显式起始 ref 仍可通过 API 指定。 +界面显示当前分支,冻结待提交请求,并显示准备、等待、失败和受阻。目标创建前暂停目录文件引用与上传, +普通输入附件仍可用;ready 后按目标恢复文件能力。旧目录引用提示重选,保留文字草稿。 + +项目指令从目标 cwd 读取。模型、审批、Skills、MCP 和 Extensions 沿用当前 Session pin。 +不复制被忽略配置、环境文件、依赖或软链接。子 Agent 仍可派生并合回该主 worktree。 +Workspace 管理入口列出保留目录并提供检查、清理。 + +## 发布与验证 + +部署的 `session-worktrees-enabled` 默认关闭,受控本机启动器对支持的主机启用。 +Git 缺失或版本不支持只禁用此选项,不影响当前目录会话。关闭新增能力不影响既有身份的 +执行、查询和清理。 + +同步 canonical V1 SQL、严格 schema 校验、OpenAPI、surface 契约及生成的 WebShell 类型, +不新增 V2 迁移,不提升 schema version。 + +验收覆盖并发独立编辑、HEAD 移动后的创建重放、源 dirty 保留、准备中断与停止证据、 +关闭竞争、目标丢失、权限撤销、文件/上传 scope、分支变化后的安全清理及子任务合并位置。 +执行聚焦单元/契约、真实 MySQL 8.4、支持平台的真实 Git/Runtime/浏览器验证。 +明确记录不可用的平台及故障覆盖;Java 本地验证共享模块十分钟预算,完整矩阵交给 CI。 diff --git a/docs/design/2026-10-28-managed-worktree-composer.md b/docs/design/2026-10-28-managed-worktree-composer.md new file mode 100644 index 00000000000..a2c3ff13c20 --- /dev/null +++ b/docs/design/2026-10-28-managed-worktree-composer.md @@ -0,0 +1,39 @@ +# Managed worktree controls in the composer + +[English](2026-10-28-managed-worktree-composer.md) | [简体中文](2026-10-28-managed-worktree-composer.zh-CN.md) + +## Problem and scope + +Managed's full-width Session files fieldset has been moved beside the workspace below the composer using Local's layout and stable Git chip styles. The chip shows the actual current Git branch, such as main. The user requested the same simple worktree selection as daemon: choose Worktree, then send the first message to create the isolated checkout, without a base-ref form. + +## Branch contract and ownership + +Add optional nullable currentBranch to WebShell worktree options and current_branch to public options. Read the selected source checkout's symbolic branch using the existing verified repository path and native Git runner: a named branch returns its real name, detached checkout returns HEAD, unavailable safe inspection returns null. This observation is independent of the selected baseRef/baseCommit; capture it before validating the worktree base so an invalid base or unborn branch does not erase the source branch. Preserve actor authorization, storage guards, deployment gates and rejection of private/unsafe layouts. No ordinary daemon Git API, schema migration, writer or model request is added. + +## Composer behavior + +Fetch Managed options for the selected workspace/cwd without baseRef and remain usable in current mode even when worktrees are unsupported. Key branch observations by workspace/cwd, abort old selections and retain the matching branch while refreshing the same source. Recheck worktree support when switching mode or opening the popover. The current-mode chip displays the observed branch; unknown information uses a generic files control, never a fabricated branch or a mode label presented as a branch. Worktree intent uses daemon's Worktree label and purple indication. + +The popover reuses daemon's Git mode/current branch/worktree labels, first-message description and stable styles. It contains current/worktree selection and retained-worktree management; both choices close immediately. Worktree intent has the same clear button to return to the current branch. Remove base-ref, commit and file-selection fields from this choice. New worktree requests send worktree: {} so the existing server resolves current HEAD at admission and allocates its own branch/path. Selection alone sends no create or model request. The public API keeps its existing optional explicit base-ref support. + +Checking/errors remain visible after closing. Busy/loading/pending state freezes edits and the clear button, while unsupported worktrees can return to current mode. Existing pending requests, including explicit base refs created before this simplification, retry their exact stored payload and idempotency key. File references remain unavailable until the target worktree is ready. Controlled open state retains dialogOpen and disabled on the composer for focus protection. Shared hooks, daemon behavior, backend lifecycle, authorization and storage guards remain unchanged. + +## Simplification acceptance + +Verify that choosing Worktree closes the picker, changes the chip, creates nothing until the first message, and sends worktree: {} with the original input. Verify clear/current mode, unavailable-worktree errors without fallback, manager access, frozen retries including a legacy explicit base ref, and stale-workspace branch rejection. Run focused creator and Local popover tests, repository build/typecheck/bundle, and Chrome against the built frontend with the existing backend. This frontend-only increment needs no new Java package/startup or repeated Java tests; retain the existing shared verification budget and prior backend evidence. + +## Session information placement + +In Home chat, place ready worktree status, branch and base commit inside the existing session details card beside its folder/status/ID metadata. Add a passive optional details content slot to the shared card and supply the existing Managed worktree status renderer from the Managed navigation row. Keep preparing/removing and blocked/failed attention visible above chat in compact form without branch/base details. Ready chat has no persistent worktree block. The standalone Managed page retains its full display because its plain navigation has no details card; the worktree manager also retains full information. No new API requests, state transitions or model calls are added. Verify card content/ID copy/portals, Home readiness and errors, standalone and manager behavior, then the existing ready session in Chrome. + +This placement passed 183 distinct cases across the affected page, navigation row, worktree manager and shared details-card suites, with repository build/typecheck/bundle and scoped lint/format checks. Chrome verified the user's existing ready session: no ready block above chat, full state/branch/base in its details card. The frontend preview's deep-link HTML marker was corrected separately in the local harness; no backend restart or new model turn was needed. Full CI and other platforms remain unverified. + +## Verification and status + +The earlier layout increment passed repository build/typecheck/bundle,263 composer cases,18 Local popover cases and the selected creator cases; its retained evidence is in the local E2E plan. The Storage.prototype fixture mismatch on Node26.12.1 was diagnosed and corrected. Real Chrome verified layout, base editing, non-Git switchback and management. Those results did not establish a real branch label; that is the current correction. + +The baseline confirmed native Git on the existing project reported main while the existing options response had no currentBranch. The correction passed 29 Java tests, including native Git branch/base independence, nested paths, checkout changes, detached and unborn branches, safe denial, camel/snake response projections, ManagedAgentApiContractTest and SurfaceRegistryGateTest. Checkstyle, packaging and regenerated WebShell types passed. All 287 focused creator/client/provider tests passed, along with repository build, typecheck, bundle and targeted ESLint. + +Before the simplification, a fresh local runtime returned currentBranch=main, matching native Git. Real Chrome displayed main beside the Managed workspace and kept Current directory/main and Independent worktree inside the popover. The existing services were not restarted; project content and the parent marker were preserved. Measured Java-related verification, including the retained earlier work and fresh startup, used 186.33 seconds of the shared 600-second budget. No model prompt was sent. This is local macOS evidence; full CI and other platforms remain unverified. + +The simplified frontend passed all 145 creator cases and 18 Local popover cases across the applicable runs, plus repository build/typecheck/bundle and targeted formatting/lint checks. An initial collection failure during a concurrent SDK rebuild and two new fixture mismatches were corrected in the verification setup; the affected cases passed without changing production behavior. Real Chrome using the production-built frontend and existing backend verified two simple choices, automatic closing, the purple Worktree chip, reset to main and retained-worktree management. No model prompt or worktree mutation was used for that browser check; first-message payload and legacy retry behavior were verified through the provider tests. Backend files and the Java verification budget are unchanged in this increment. diff --git a/docs/design/2026-10-28-managed-worktree-composer.zh-CN.md b/docs/design/2026-10-28-managed-worktree-composer.zh-CN.md new file mode 100644 index 00000000000..aae36534cf9 --- /dev/null +++ b/docs/design/2026-10-28-managed-worktree-composer.zh-CN.md @@ -0,0 +1,39 @@ +# 输入框中的 Managed worktree 控件 + +[English](2026-10-28-managed-worktree-composer.md) | [简体中文](2026-10-28-managed-worktree-composer.zh-CN.md) + +## 问题与范围 + +Managed 整行“会话文件”表单已复用 Local 的布局及稳定 Git 按钮样式,移至输入框下方的工作区旁。按钮显示实际当前 Git 分支,例如 main。用户要求像 daemon 一样简单选择 worktree:选择“Worktree 隔离”后,发送首条消息时创建独立 checkout,不填写起始 ref 表单。 + +## 分支契约与权限归属 + +为 WebShell worktree 选项新增可选可空 currentBranch,公开选项新增 current_branch。沿用已验证的仓库路径和原生 Git runner,读取所选源 checkout 的符号分支:有名分支返回真实名称,游离 checkout 返回 HEAD,无法安全检查时返回 null。该观察独立于所选 baseRef/baseCommit;在验证 worktree 起点前读取,避免无效起点或未产生提交的分支抹掉源分支。保留 actor 授权、存储 guard、部署开关及私有/不安全布局拒绝。不新增普通 daemon Git API、schema migration、写入或模型请求。 + +## 输入框行为 + +为所选 workspace/cwd 请求 Managed 选项,不发送 baseRef;即使不支持 worktree,当前模式也保持可用。按 workspace/cwd 绑定分支观察,中止旧选择的请求,刷新同一来源时保留匹配分支。切换模式或打开弹层时重新检查 worktree 支持情况。当前模式按钮显示观察到的分支;信息未知时使用通用文件控件,不猜测分支,也不把工作方式作为分支名。worktree 意图复用 daemon 的“Worktree 隔离”标签和紫色提示。 + +弹层复用 daemon 的 Git 模式/当前分支/Worktree 隔离标签、首条消息说明及稳定样式。只保留当前/worktree 选择和保留目录管理入口,两种选择均立即收起。worktree 意图提供同样的清除按钮,恢复当前分支。移除该选择中的起始 ref、提交和文件选择表单。新的隔离请求发送 worktree: {},由已有服务在受理时解析当前 HEAD 并分配分支和目录。单纯选择不创建会话,也不请求模型。公开 API 保留已有显式起始 ref 能力。 + +关闭后仍可见检查状态和错误。繁忙、加载及待确认状态冻结编辑和清除按钮,不支持 worktree 时仍能切回当前模式。包括此次简化前指定起始 ref 的已有待确认请求,均按原存储载荷和幂等键重试。目标 worktree 就绪前不开放文件引用。受控打开状态继续设置输入框的 dialogOpen 和 disabled 保护焦点。共享 hook、daemon 行为、后端生命周期、授权和存储 guard 保持不变。 + +## 简化验收 + +验证选择 Worktree 后弹层收起、按钮变化、首条消息前没有创建,发送时携带 worktree: {} 和原输入。验证清除/当前模式、不可用时错误可见且不降级、管理入口、冻结重试(含旧显式起点)及工作区切换的旧分支响应拒绝。执行专项创建器和 Local 弹层测试、仓库 build/typecheck/bundle,并用构建前端配合已有后端在 Chrome 验证。此次仅修改前端,不新增 Java 打包/启动或重复 Java 测试;保留已有共享验证预算和后端证据。 + +## 会话信息位置 + +在首页聊天中,将已就绪 worktree 的状态、分支和起始提交放入现有会话详情卡,与目录/状态/ID 元数据一起展示。共享详情卡增加可选的纯展示内容插槽,由 Managed 导航行传入已有 worktree 状态渲染器。准备/删除中和受阻/失败状态仍在聊天上方简要提示,不展示分支和起始提交;已就绪时不再常驻 worktree 区块。独立 Managed 页面使用普通导航,没有详情卡,因此保留完整显示;worktree 管理器也保留完整信息。不新增 API 请求、状态流转或模型调用。验证卡片信息/ID 复制/portal、首页就绪与错误、独立页及管理器行为,再在 Chrome 检查已有就绪会话。 + +此信息位置调整通过受影响页面、导航行、worktree 管理器及共享详情卡测试中的 183 个独立用例,仓库 build/typecheck/bundle 及专项 lint/格式检查通过。Chrome 验证了用户已有的就绪会话:聊天上方没有就绪区块,详情卡包含完整状态/分支/起始提交。前端预览的深层链接 HTML 标记另在本地验证工具中修正,未重启后端或发起新模型轮次。完整 CI 及其他平台仍未验证。 + +## 验证与状态 + +此前布局增量通过仓库 build/typecheck/bundle、263 个输入框用例、18 个 Local 弹层用例及专项创建器用例,证据保留于本地 E2E 计划。已定位并修正 Node26.12.1 的 Storage.prototype 测试夹具不匹配。真实 Chrome 验证了布局、起始 ref 编辑、非 Git 切回及管理入口;这些结果没有证明真实分支标签,本次修正补齐此项。 + +基线确认原项目的原生 Git 返回 main,但已有选项响应没有 currentBranch。本次修正通过 29 个 Java 测试,覆盖原生 Git 分支与起点独立、嵌套路径、checkout 变化、游离及未提交分支、安全拒绝、camel/snake 响应投影、ManagedAgentApiContractTest 和 SurfaceRegistryGateTest。Checkstyle、打包及 WebShell 类型重新生成通过。287 个专项创建器/client/provider 测试全部通过,仓库 build、typecheck、bundle 及专项 ESLint 通过。 + +简化前,全新本机 runtime 返回 currentBranch=main,与原生 Git 一致。真实 Chrome 在 Managed 工作区旁显示 main,弹层内保留“当前目录/main”和“独立 worktree”。没有重启已有服务,项目内容和父目录标记保留。包含此前保留工作和新实例启动的 Java 相关验证共使用 186.33 秒,未超过共享 600 秒预算。未发送模型提示。这些是本地 macOS 证据,完整 CI 及其他平台仍未验证。 + +简化后的前端在适用的多次运行中通过全部 145 个创建器用例及 18 个 Local 弹层用例,仓库 build/typecheck/bundle 和专项格式/lint 检查通过。一次并发 SDK 构建导致的测试收集失败和两个新夹具不匹配已在验证配置中修正,受影响用例通过,无需修改生产行为。真实 Chrome 使用构建前端和已有后端,验证了简洁的两项选择、自动收起、紫色 Worktree 按钮、恢复 main 及保留目录管理。该浏览器检查未发送模型提示或修改 worktree;首条消息载荷和旧请求重试由 provider 测试验证。此次增量未修改后端文件或增加 Java 验证预算。 diff --git a/docs/users/managed-local-serve.md b/docs/users/managed-local-serve.md index 687e8daedd0..ffac47c06f7 100644 --- a/docs/users/managed-local-serve.md +++ b/docs/users/managed-local-serve.md @@ -132,16 +132,13 @@ directory or stop the conflicting service rather than editing persisted facts. Startup creates an application-owned default Workspace and its OWNER grant through the existing idempotent Workspace API. To use an existing local project, open **Workspace → Choose existing directory**, browse or enter its absolute -path, then add the folder. The service registers its parent and grants OWNER -access automatically, keeping existing files in place. New tasks start in the -selected project. You can also create an empty Workspace. +path, then add the folder. The service grants OWNER access and keeps existing files in place. New tasks start in the selected project. New imports are shared external directories: independent local installations can import the same project without transferring its parent or replacing an existing global storage marker. The parent remains the shared navigation and worktree-layout boundary within each installation. You can also create an empty Workspace. The launch directory remains available to Local sessions and is not imported automatically. `--workspace` remains unsupported in Managed local mode; select -the project in the UI. The `.qwen-managed-storage.json` maintenance marker is -kept outside Git worktrees; do not edit, delete, stash or commit it. Imported -project files stay outside the private Managed data directory and require their -own backup. +the project in the UI. New import metadata is private to the installation under `runtime/imported-projects//.qwen-managed-storage.json`; preserve it with the complete Managed data directory. Existing imports and application-owned storage retain their earlier marker contract; do not edit, delete, stash or commit those markers. Imported project files stay outside the private Managed data directory and require their own backup. + +Shared projects allow the same editing conflicts as independent terminals. Use independent worktrees to isolate edits. Whole-storage migration, physical recovery and exclusive file-history rewind are unavailable for new shared imports; recovery of this installation's own Runtime processes remains available. Earlier imports are not converted automatically. See the [shared-project design](../design/2026-10-28-managed-local-shared-projects.md). Local session history continues to use ordinary Qwen storage. It is separate from the Managed data directory and is not included in a Managed-only backup. diff --git a/docs/users/managed-local-serve.zh-CN.md b/docs/users/managed-local-serve.zh-CN.md index ead513142a2..c7af3e905d0 100644 --- a/docs/users/managed-local-serve.zh-CN.md +++ b/docs/users/managed-local-serve.zh-CN.md @@ -110,13 +110,12 @@ qwen serve --managed --managed-data-dir /absolute/private/data --open 启动通过现有幂等 Workspace API 创建应用管理的默认 Workspace 和 OWNER 授权。 使用已有本机项目时,打开“工作区 → 选择已有目录”,浏览或输入绝对路径后添加 -文件夹。服务会自动登记项目父目录并授予 OWNER 权限,已有文件保留原处, -新任务从所选项目启动。也可创建空 Workspace。 +文件夹。服务自动授予 OWNER 权限,已有文件保留原处,新任务从所选项目启动。新导入是共享外部目录:独立本机实例可以导入同一项目,无需移交父目录,也不替换已有全局存储标记。父目录继续作为各实例内共享的导航及 worktree 布局边界。也可创建空 Workspace。 启动目录仍供 Local 会话使用,不会自动导入。Managed 本地模式继续拒绝 -`--workspace`,请从界面选择项目。`.qwen-managed-storage.json` 维护标记保留在 -Git 工作树之外,不要编辑、删除、stash 或提交。导入的项目文件位于私有 Managed -数据目录之外,需要单独备份。 +`--workspace`,请从界面选择项目。新导入的实例私有元数据位于 `runtime/imported-projects//.qwen-managed-storage.json`,应与整个 Managed 数据目录一起保留。已有导入及应用自有存储保留此前的标记契约,不要编辑、删除、stash 或提交这些标记。导入项目的文件位于私有 Managed 数据目录之外,需要单独备份。 + +共享项目会承担与多个独立终端相同的编辑冲突,需要隔离编辑时使用独立 worktree。新共享导入不支持整存储迁移、物理恢复和独占文件历史 rewind;本实例自身 Runtime 进程的恢复仍可用。不自动转换早期导入。参见[共享项目设计](../design/2026-10-28-managed-local-shared-projects.zh-CN.md)。 Local 会话历史继续使用普通 Qwen 存储,与 Managed 数据目录独立,仅备份 Managed 目录不会包含 Local 会话历史。 diff --git a/docs/users/managed-session-worktrees.md b/docs/users/managed-session-worktrees.md new file mode 100644 index 00000000000..c659c61617a --- /dev/null +++ b/docs/users/managed-session-worktrees.md @@ -0,0 +1,44 @@ +# Managed Session worktrees + +[English](managed-session-worktrees.md) | [简体中文](managed-session-worktrees.zh-CN.md) + +In a supported local Managed deployment, select a Git project when creating a Session. Open the Git branch button beside the workspace below the composer and choose **Worktree**. The picker closes; sending your first message automatically creates an isolated checkout from the current HEAD on its own branch. There is no branch or directory form to fill in. Uncommitted files, ignored configuration and installed dependencies stay in the source directory. + +Before sending, choose **Current branch** or clear the Worktree chip to work directly on the source branch. The chip displays the actual source branch, such as **main**, when isolation is not selected. + +The Session reports preparation and may wait for other tools using the same +storage. File references and Workspace uploads become available after the +worktree is ready. Project instructions come from the target directory; Managed +model, approval, Skills, MCP and Extensions settings retain their Session pins. + +In Home chat, the session's more-information card contains the worktree status, branch and base commit. Ready worktree details no longer occupy the top of the conversation; preparation and error messages remain visible there when needed. + +Closing, archiving or deleting the chat keeps the checkout and branch. Open +**Session worktrees** from the same Workspace’s new-task composer popover to inspect retained directories. Close +the Session and finish its background work before cleanup. Commit or otherwise +save tracked/untracked/ignored content first. Cleanup removes only a clean +checkout and keeps all branches; it does not merge changes into the source. + +Deployments enable `qwen.managed-agent.runtime-broker.session-worktrees-enabled`. +It defaults off; `qwen serve --managed` enables eligible local hosts. This version +supports LOCAL macOS/Linux storage, Session-isolated durable local-process +workers and Git 2.40 or later. The repository and its Git common directory must +be inside the registered storage root. For an existing linked checkout with Git +metadata elsewhere, select the original checkout or ask the administrator to +register a root containing both. SSH and CSI are not supported in this version. + +Restarting a ready Session reuses its original checkout. Missing or replaced +directories block execution. If the server is killed while Git is creating or +removing a worktree, an unverified original Git process leaves the operation +blocked and its maintenance hold intact. Rechecking does not force recovery; +macOS automatic recovery for this case is outside this version. Preserve the +directory and branch until the original execution can be reconciled. + +The public Session creation request adds `worktree: {"base_ref":"main"}`; +`worktree: {}` selects HEAD and omission uses the current directory. Send an +explicit authorized `workspace` and retain the same Idempotency-Key on retry. +Query the returned Session's `worktree.state` before sending subsequent input. + +This development version updates the shared V1 schema baseline. Use a +development catalog recreated from that baseline; an upgrade migration for an +older catalog is not included. diff --git a/docs/users/managed-session-worktrees.zh-CN.md b/docs/users/managed-session-worktrees.zh-CN.md new file mode 100644 index 00000000000..b968da5d4f4 --- /dev/null +++ b/docs/users/managed-session-worktrees.zh-CN.md @@ -0,0 +1,35 @@ +# Managed 会话 worktree + +[English](managed-session-worktrees.md) | [简体中文](managed-session-worktrees.zh-CN.md) + +在受支持的本机 Managed 部署中,新建会话时选择 Git 项目,点击输入框下方工作区旁的 Git 分支按钮,再选择 **Worktree 隔离**。弹层立即收起;发送首条消息时,自动从当前 HEAD 创建使用独立分支的 checkout,无需填写分支或目录表单。未提交文件、忽略的配置及已安装依赖仍留在源目录。 + +发送前,可选择**当前分支**或清除 Worktree 按钮上的隔离选择,直接在源分支工作。未选择隔离时,按钮显示实际源分支,例如 **main**。 + +会话会显示准备状态,可能需要等待使用同一存储的其他工具执行结束。 +worktree 就绪后可使用文件引用和 Workspace 上传。项目指令来自目标目录;模型、审批、 +Skills、MCP 和 Extensions 继续使用 Managed 会话固定的配置版本。 + +在首页聊天中,会话的更多信息卡包含 worktree 状态、分支和起始提交。已就绪详情不再占用聊天顶部;准备中和错误提示仍会在需要时显示。 + +关闭、归档或删除聊天后,目录和分支都会保留。在同一工作区新建任务时,可从输入框弹层的**会话 worktree**检查保留目录。清理前需要关闭会话并结束后台工作;先提交或另外保存已跟踪、未跟踪及 +忽略的内容。清理只删除干净的目录,保留所有分支,不自动将修改合回源目录。 + +部署通过 `qwen.managed-agent.runtime-broker.session-worktrees-enabled` 启用,默认关闭; +`qwen serve --managed` 为符合条件的本机主机启用。首版支持 macOS/Linux 的 LOCAL 存储、 +按 Session 隔离的持久化 local-process worker,以及 Git 2.40 或更新版本。 +仓库及其 Git common directory 必须在已登记存储根内。已有 linked checkout 的 Git +元数据位于外部时,请选择原始 checkout,或由管理员登记同时包含两者的根目录。 +首版不支持 SSH 和 CSI。 + +已就绪会话重启后继续使用原目录。目录丢失或替换会阻止执行。如果服务在 Git 创建或 +删除 worktree 时被强杀,原 Git 进程停止状态无法确认,操作将保持受阻及原维护占用。 +重新检查不会强制恢复;首版不包含 macOS 对该场景的自动恢复。在原执行能够对账前, +保留目录和分支。 + +公开 Session 创建请求新增 `worktree: {"base_ref":"main"}`;`worktree: {}` 选择 HEAD, +省略该字段使用当前目录。同时发送已授权的显式 `workspace`,重试沿用原 Idempotency-Key。 +发送后续输入前检查返回 Session 的 `worktree.state`。 + +本开发版本更新了共享 V1 schema baseline,需要按该基线重新创建开发 catalog;本次不提供 +旧 catalog 的升级迁移。 diff --git a/packages/cli/src/serve/capability-parity.ts b/packages/cli/src/serve/capability-parity.ts index 8d1061fdcaf..3afcda14ed4 100644 --- a/packages/cli/src/serve/capability-parity.ts +++ b/packages/cli/src/serve/capability-parity.ts @@ -657,6 +657,7 @@ export const MANAGED_ONLY_REPORTED: readonly string[] = [ 'SESSION_CONTEXT_COMPRESSION', 'SESSION_RECOVERY_QUERY', 'SESSION_RECOVERY_RECHECK', + 'SESSION_WORKTREE', ]; /** diff --git a/packages/cli/src/serve/managed-local-serve.test.ts b/packages/cli/src/serve/managed-local-serve.test.ts index fb159958535..9d61fdc12e5 100644 --- a/packages/cli/src/serve/managed-local-serve.test.ts +++ b/packages/cli/src/serve/managed-local-serve.test.ts @@ -86,6 +86,8 @@ describe('Managed local service environment', () => { models, ); expect(JSON.parse(environment['SPRING_APPLICATION_JSON']!)).toEqual({ + 'qwen.managed-agent.runtime-broker.session-worktrees-enabled': + process.platform !== 'win32', 'qwen.managed-agent.model-catalog': models, 'qwen.managed-agent.definition-models': ['a,comma-model'], 'qwen.managed-agent.definition-tool-profiles': [ diff --git a/packages/cli/src/serve/managed-local-serve.ts b/packages/cli/src/serve/managed-local-serve.ts index 04ead8a9ccc..aff79bb4ed6 100644 --- a/packages/cli/src/serve/managed-local-serve.ts +++ b/packages/cli/src/serve/managed-local-serve.ts @@ -50,6 +50,8 @@ export function managedLocalJavaEnvironment( return { ...environment, SPRING_APPLICATION_JSON: JSON.stringify({ + 'qwen.managed-agent.runtime-broker.session-worktrees-enabled': + process.platform !== 'win32', 'qwen.managed-agent.model-catalog': models, 'qwen.managed-agent.definition-models': models.map( (model) => model.modelId, diff --git a/packages/cli/src/serve/managed-runtime-bootstrap.ts b/packages/cli/src/serve/managed-runtime-bootstrap.ts index d8452b3fa86..5326bcef94d 100644 --- a/packages/cli/src/serve/managed-runtime-bootstrap.ts +++ b/packages/cli/src/serve/managed-runtime-bootstrap.ts @@ -13,7 +13,9 @@ import { getCliVersion } from '../utils/version.js'; import { readWorkspaceFiles, WorkspaceFileError, + sessionWorkspaceFileScope, workspaceFilePath, + type SessionWorkspaceFileScope, } from './managed-workspace-files.js'; import { inspectWorkspaceUpload, @@ -687,10 +689,12 @@ async function workspaceUpload( request: Record, expected: { device: string; inode: string }, verifyStorage: () => Promise, + sessionScope?: SessionWorkspaceFileScope, ): Promise { const { metadata, bytes } = workspaceUploadRequest(request['files']); const root = absolute(request['root']); - const cwd = workspaceFilePath(request['cwdRelative']); + const cwd = + sessionScope?.cwdRelative ?? workspaceFilePath(request['cwdRelative']); const target = { nodeId: request['nodeId'], root, @@ -698,6 +702,7 @@ async function workspaceUpload( expected, identity: request['identity'] ?? null, marker: request['marker'] ?? null, + ...(sessionScope ? { sessionFileScope: sessionScope } : {}), ...metadata, }; const canonical = (value: unknown): unknown => @@ -779,6 +784,7 @@ async function workspaceUpload( bytes, expected, verifyStorage, + sessionScope, ); } else { let live = true; @@ -794,6 +800,7 @@ async function workspaceUpload( metadata, expected, verifyStorage, + sessionScope, ); } await writeSshPrivateJson(receiptPath, result); @@ -844,19 +851,31 @@ export async function executeSshBootstrap( if (identity && !uploading) await verifySshStorage(identity, marker); const expected = identity ?? object(request['localRootIdentity']); try { + if (request['sessionFileScope'] !== undefined && identity) + refuse('Session file scopes require local storage.'); + const sessionScope = sessionWorkspaceFileScope( + request['sessionFileScope'], + request['cwdRelative'], + ); const fileIdentity = { device: text(expected.device), inode: text(expected.inode), }; const files = uploading - ? await workspaceUpload(request, fileIdentity, async () => { - if (identity) await verifySshStorage(identity, marker); - }) + ? await workspaceUpload( + request, + fileIdentity, + async () => { + if (identity) await verifySshStorage(identity, marker); + }, + sessionScope, + ) : await readWorkspaceFiles( root, text(request['cwdRelative']), request['files'], fileIdentity, + sessionScope, ); if (identity && !uploading) await verifySshStorage(identity, marker); return { qwenVersion: await getCliVersion(), files }; diff --git a/packages/cli/src/serve/managed-workspace-files.test.ts b/packages/cli/src/serve/managed-workspace-files.test.ts index 495e0cfb0cf..6f1fad40cb0 100644 --- a/packages/cli/src/serve/managed-workspace-files.test.ts +++ b/packages/cli/src/serve/managed-workspace-files.test.ts @@ -7,7 +7,10 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { readWorkspaceFiles } from './managed-workspace-files.js'; +import { + readWorkspaceFiles, + type SessionWorkspaceFileScope, +} from './managed-workspace-files.js'; describe('Managed Workspace file snapshots', () => { let root: string; @@ -78,6 +81,51 @@ describe('Managed Workspace file snapshots', () => { await expect(request('read', { path: relative })).rejects.toThrow(); }); + it('permits only the internally resolved Session base and still blocks private relative paths', async () => { + const cwd = '.qwen-managed-worktrees/owned/src'; + const target = path.join(root, '.qwen-managed-worktrees/owned'); + await fs.mkdir(path.join(root, cwd), { recursive: true }); + await fs.writeFile(path.join(root, cwd, 'a.txt'), 'owned'); + const stat = await fs.stat(target, { bigint: true }); + const scope: SessionWorkspaceFileScope = { + sessionId: 'session-a', + contextDigest: `sha256:${'a'.repeat(64)}`, + worktreeId: 'owned', + worktreeRevision: 2, + cwdRelative: cwd, + worktreeRootIdentity: { + device: String(stat.dev), + inode: String(stat.ino), + }, + }; + const read = (relative: string, proof = scope) => + readWorkspaceFiles( + root, + cwd, + { protocolVersion: 1, operation: 'read', path: relative }, + identity, + proof, + ); + await expect(request('read', { path: 'a.txt' }, cwd)).rejects.toThrow(); + expect((await read('a.txt'))['text']).toBe('owned'); + for (const relative of [ + '../a.txt', + '.git/config', + '.qwen-managed-worktrees/other/a.txt', + ]) { + await expect(read(relative)).rejects.toThrow(); + } + await expect( + read('a.txt', { ...scope, worktreeId: 'other' }), + ).rejects.toThrow(); + await fs.rename(target, `${target}-moved`); + await fs.mkdir(path.join(root, cwd), { recursive: true }); + await fs.writeFile(path.join(root, cwd, 'a.txt'), 'replacement'); + await expect(read('a.txt')).rejects.toMatchObject({ + code: 'ssh_runtime_file_changed', + }); + }); + it('refuses symlink files, symlink ancestors, wrong root identity and special files', async () => { await fs.writeFile(path.join(root, 'real'), 'text'); await fs.symlink(path.join(root, 'real'), path.join(root, 'link')); diff --git a/packages/cli/src/serve/managed-workspace-files.ts b/packages/cli/src/serve/managed-workspace-files.ts index 5d0a0ff8c01..70b168e6054 100644 --- a/packages/cli/src/serve/managed-workspace-files.ts +++ b/packages/cli/src/serve/managed-workspace-files.ts @@ -42,6 +42,65 @@ export function workspaceFilePath(value: unknown): string { return value; } +export interface SessionWorkspaceFileScope { + sessionId: string; + contextDigest: string; + worktreeId: string; + worktreeRevision: number; + cwdRelative: string; + worktreeRootIdentity: { device: string; inode: string }; +} + +// Only the trusted local Java-to-Node stdin protocol carries this scope. +export function sessionWorkspaceFileScope( + value: unknown, + cwdRelative: unknown, +): SessionWorkspaceFileScope | undefined { + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) + refuse('invalid_request'); + const scope = value as Record; + const keys = [ + 'sessionId', + 'contextDigest', + 'worktreeId', + 'worktreeRevision', + 'cwdRelative', + 'worktreeRootIdentity', + ]; + if ( + Object.keys(scope).length !== keys.length || + keys.some((key) => !(key in scope)) || + typeof scope['sessionId'] !== 'string' || + !/^[A-Za-z0-9._:-]{1,128}$/.test(scope['sessionId']) || + typeof scope['contextDigest'] !== 'string' || + !/^sha256:[a-f0-9]{64}$/.test(scope['contextDigest']) || + typeof scope['worktreeId'] !== 'string' || + !/^[A-Za-z0-9_-]{1,128}$/.test(scope['worktreeId']) || + !Number.isSafeInteger(scope['worktreeRevision']) || + Number(scope['worktreeRevision']) < 1 || + typeof cwdRelative !== 'string' || + scope['cwdRelative'] !== cwdRelative + ) + refuse('invalid_request'); + const prefix = `.qwen-managed-worktrees/${scope['worktreeId']}`; + if (cwdRelative !== prefix && !cwdRelative.startsWith(`${prefix}/`)) + refuse('invalid_path'); + workspaceFilePath(cwdRelative.slice(prefix.length + 1) || '.'); + const identity = scope['worktreeRootIdentity']; + if (!identity || typeof identity !== 'object' || Array.isArray(identity)) + refuse('invalid_request'); + const fields = identity as Record; + if ( + Object.keys(fields).length !== 2 || + !['device', 'inode'].every( + (key) => typeof fields[key] === 'string' && /^\d+$/.test(fields[key]), + ) + ) + refuse('invalid_request'); + return scope as unknown as SessionWorkspaceFileScope; +} + function signature(stat: BigIntStats): string { return [ stat.dev, @@ -57,8 +116,13 @@ export async function workspaceFileDirectories( root: string, cwdRelative: string, expected: { device: string; inode: string }, + sessionScope?: SessionWorkspaceFileScope, ) { - const cwd = workspaceFilePath(cwdRelative); + const scope = sessionWorkspaceFileScope(sessionScope, cwdRelative); + const cwd = scope?.cwdRelative ?? workspaceFilePath(cwdRelative); + const worktreeRoot = scope + ? path.join(root, '.qwen-managed-worktrees', scope.worktreeId) + : undefined; const anchors = new Map(); async function directory(relative: string): Promise { const segments = relative === '.' ? [] : relative.split('/'); @@ -74,6 +138,13 @@ export async function workspaceFileDirectories( String(stat.ino) !== expected.inode) ) refuse(); + if ( + scope && + current === worktreeRoot && + (String(stat.dev) !== scope.worktreeRootIdentity.device || + String(stat.ino) !== scope.worktreeRootIdentity.inode) + ) + refuse('changed'); const prior = anchors.get(current); const identity = [stat.dev, stat.ino, stat.birthtimeNs].join(':'); if (prior && prior !== identity) refuse('changed'); @@ -107,6 +178,7 @@ export async function readWorkspaceFiles( cwdRelative: string, value: unknown, expected: { device: string; inode: string }, + sessionScope?: SessionWorkspaceFileScope, ): Promise> { if (!value || typeof value !== 'object' || Array.isArray(value)) refuse('invalid_request'); @@ -116,6 +188,7 @@ export async function readWorkspaceFiles( root, cwdRelative, expected, + sessionScope, ); if (request['operation'] === 'read') { const relative = workspaceFilePath(request['path']); diff --git a/packages/cli/src/serve/managed-workspace-upload.test.ts b/packages/cli/src/serve/managed-workspace-upload.test.ts index 3e529974cf4..cdbffcee8a5 100644 --- a/packages/cli/src/serve/managed-workspace-upload.test.ts +++ b/packages/cli/src/serve/managed-workspace-upload.test.ts @@ -10,7 +10,10 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { executeSshBootstrap } from './managed-runtime-bootstrap.js'; -import { MAX_WORKSPACE_UPLOAD_BYTES } from './managed-workspace-files.js'; +import { + MAX_WORKSPACE_UPLOAD_BYTES, + type SessionWorkspaceFileScope, +} from './managed-workspace-files.js'; import { workspaceUploadRequest, type WorkspaceUploadMetadata, @@ -56,6 +59,7 @@ describe('Managed Workspace upload receipts and filesystem', () => { info: WorkspaceUploadMetadata, bytes?: Buffer, cwdRelative = '.', + sessionFileScope?: SessionWorkspaceFileScope, ) { const result = await executeSshBootstrap({ protocolVersion: 1, @@ -66,6 +70,7 @@ describe('Managed Workspace upload receipts and filesystem', () => { allowedRoots: [temporary], localRootIdentity: identity, cwdRelative, + ...(sessionFileScope ? { sessionFileScope } : {}), files: { protocolVersion: 1, operation: bytes === undefined ? 'upload-status' : 'upload', @@ -79,6 +84,59 @@ describe('Managed Workspace upload receipts and filesystem', () => { const receiptDirectory = (info: WorkspaceUploadMetadata) => path.join(stateDirectory, 'workspace-uploads', info.uploadId); + it('freezes the Session identity in upload receipts and refuses replay into another Session', async () => { + const cwd = '.qwen-managed-worktrees/owned'; + await fs.mkdir(path.join(root, cwd), { recursive: true }); + const stat = await fs.stat(path.join(root, cwd), { bigint: true }); + const scope: SessionWorkspaceFileScope = { + sessionId: 'session-a', + contextDigest: `sha256:${'a'.repeat(64)}`, + worktreeId: 'owned', + worktreeRevision: 2, + cwdRelative: cwd, + worktreeRootIdentity: { + device: String(stat.dev), + inode: String(stat.ino), + }, + }; + const bytes = Buffer.from('session upload'); + const info = metadata(bytes); + await expect(request(info, bytes, cwd)).rejects.toThrow(); + expect(await request(info, bytes, cwd, scope)).toMatchObject({ + state: 'completed', + }); + expect(await fs.readFile(path.join(root, cwd, info.path))).toEqual(bytes); + expect(await request(info, undefined, cwd, scope)).toMatchObject({ + state: 'completed', + }); + await expect( + request(info, undefined, cwd, { ...scope, sessionId: 'session-b' }), + ).rejects.toMatchObject({ code: 'ssh_runtime_file_upload_conflict' }); + await fs.rename(path.join(root, cwd), path.join(root, 'moved')); + await fs.mkdir(path.join(root, cwd)); + expect( + await request(metadata(bytes, 'new.bin'), bytes, cwd, scope), + ).toMatchObject({ + state: 'failed', + code: 'ssh_runtime_file_changed', + }); + expect(await fs.readdir(path.join(root, cwd))).toEqual([]); + await fs.unlink(path.join(receiptDirectory(info), 'receipt.json')); + const temporary = path.join( + root, + cwd, + `.qwen-managed-upload-${info.uploadId}.tmp`, + ); + await fs.writeFile(temporary, 'belongs to replacement'); + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('absent'), { code: 'ESRCH' }); + }); + await expect(request(info, undefined, cwd, scope)).rejects.toMatchObject({ + code: 'ssh_runtime_file_changed', + }); + expect(await fs.readFile(temporary, 'utf8')).toBe('belongs to replacement'); + }); + it('preserves binary bytes, Unicode paths and cwd and advertises its size limit', async () => { await fs.mkdir(path.join(root, 'working', 'nested'), { recursive: true }); const bytes = Buffer.from([0, 255, 128, 10, 1]); diff --git a/packages/cli/src/serve/managed-workspace-upload.ts b/packages/cli/src/serve/managed-workspace-upload.ts index 744efc763df..bc25b8cdcc3 100644 --- a/packages/cli/src/serve/managed-workspace-upload.ts +++ b/packages/cli/src/serve/managed-workspace-upload.ts @@ -12,6 +12,7 @@ import { WorkspaceFileError, workspaceFileDirectories, workspaceFilePath, + type SessionWorkspaceFileScope, } from './managed-workspace-files.js'; export interface WorkspaceUploadMetadata { @@ -133,12 +134,18 @@ export async function writeWorkspaceUpload( bytes: Buffer, expected: { device: string; inode: string }, verifyStorage: () => Promise, + sessionScope?: SessionWorkspaceFileScope, ): Promise { let directories: Awaited>; let parent: string; try { await verifyStorage(); - directories = await workspaceFileDirectories(root, cwd, expected); + directories = await workspaceFileDirectories( + root, + cwd, + expected, + sessionScope, + ); parent = await directories.directory(path.posix.dirname(metadata.path)); } catch (error) { return failed(metadata, error); @@ -180,9 +187,15 @@ export async function inspectWorkspaceUpload( metadata: WorkspaceUploadMetadata, expected: { device: string; inode: string }, verifyStorage: () => Promise, + sessionScope?: SessionWorkspaceFileScope, ): Promise { await verifyStorage(); - const directories = await workspaceFileDirectories(root, cwd, expected); + const directories = await workspaceFileDirectories( + root, + cwd, + expected, + sessionScope, + ); const parent = await directories.directory(path.posix.dirname(metadata.path)); const destination = path.join(directories.base, metadata.path); let completed = false; diff --git a/packages/managed-agent-server/README.md b/packages/managed-agent-server/README.md index c5836a224d4..f97cf3dcfc1 100644 --- a/packages/managed-agent-server/README.md +++ b/packages/managed-agent-server/README.md @@ -874,8 +874,7 @@ Then **Workspace → Choose existing directory** browses this computer directly, without a pre-registered parent or grant. Import keeps the project in place, automatically registers its projects parent outside Git worktrees, grants the current actor OWNER access, and sets the project as the initial working directory. -The parent shares file access and its physical-owner lease across projects; this -is not a filesystem sandbox. Existing owned roots are reused while sessions run. +The parent shares file access and catalog-local leases across projects; this is not a filesystem sandbox or a global parent-directory claim. New imports use `imported-` storage IDs and private registration metadata below `runtime-broker.state-directory/imported-projects/`. Independent local catalogs may import the same project while leaving any global storage marker untouched. Whole-storage migration, physical recovery and exclusive file-history rewind are unavailable for these shared imports; own-process Runtime recovery remains available. Existing owned roots are reused while sessions run. Private Runtime state and creation roots must stay outside imported parents. Remote listeners cannot enable this local feature, including with the insecure bind override. Signed mode retains signature validation and does not accept a @@ -888,7 +887,7 @@ and idempotency key. These fields are exclusive with Workspace-relative source fields. `localDirectoryImport` advertises the capability independently; pending receipts pin native root identity and completed receipts resolve imported roots after restart or disabling further imports. Neither retry nor restart repairs a -missing/foreign marker or restores revoked access. See the +missing or mismatched registration metadata or restores revoked access. Legacy `created-` imports and application-owned roots keep their global marker contract; no existing receipt or marker is converted automatically. See the [local import design](../../docs/design/2026-10-21-managed-local-directory-import.md). For other deployments, the existing authorized-root flow remains available. @@ -1173,7 +1172,8 @@ warns. Cadences bound through `@Scheduled` placeholders — `artifacts.projection-interval`, `channels.scan-delay`, `child-relay.scan-delay`, `child-workspace.scan-delay`, `dispatch.scan-delay`, `events.replay-floor-interval`, -`message-relay.scan-delay` and `wake-recovery.scan-delay` — also read a bare number as milliseconds, +`message-relay.scan-delay`, `session-worktree.scan-delay` and +`wake-recovery.scan-delay` — also read a bare number as milliseconds, and the startup warning cannot see them, so always write the suffix there. diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java index b13e2746975..db96422da16 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java @@ -26,6 +26,7 @@ import com.alibaba.qwen.code.managedagent.session.ManagedTaskCancelService; import com.alibaba.qwen.code.managedagent.session.ManagedTaskService; import com.alibaba.qwen.code.managedagent.session.SessionLifecycleService; import com.alibaba.qwen.code.managedagent.session.SessionRecoveryService; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService; import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; import jakarta.validation.Valid; import org.apache.commons.lang3.StringUtils; @@ -97,15 +98,22 @@ public class PublicAgentController { if (selection == null && request.extensionPins() != null) { throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Extension packages require a bound Workspace."); } + var worktree = SessionWorktreeService.parse(request.worktree(), false); + if (selection == null && worktree != null) { + throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Session worktrees require a bound Workspace."); + } CommandAdmission admission = selection == null ? service.createSession(tenant.tenantId(), tenant.actorId(), idempotencyKey, request.agentId(), request.agentRevision(), null, request.metadata(), request.input()) - : service.createWorkspaceSession(tenant.tenantId(), + : worktree == null ? service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), idempotencyKey, request.agentId(), request.agentRevision(), null, - request.metadata(), request.input(), selection, null, null, request.skills(), null, request.extensionPins()); + request.metadata(), request.input(), selection, null, null, request.skills(), null, request.extensionPins()) + : service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), idempotencyKey, + request.agentId(), request.agentRevision(), null, request.metadata(), request.input(), + selection, null, null, request.skills(), null, request.extensionPins(), worktree); PublicSession session = service.getPublicSession(tenant.tenantId(), tenant.actorId(), admission.sessionId()); return ResponseEntity.status(HttpStatus.ACCEPTED) diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SessionWorktreeController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SessionWorktreeController.java new file mode 100644 index 00000000000..c3780aa5ff2 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/SessionWorktreeController.java @@ -0,0 +1,115 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.alibaba.qwen.code.managedagent.model.TenantContext; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +public class SessionWorktreeController { + private final SessionWorktreeService service; + + public SessionWorktreeController(SessionWorktreeService service) { + this.service = service; + } + + public record OptionsRequest(@NotBlank String workspaceId, @NotBlank String cwdRelative, String baseRef) { } + public record QueryRequest(@NotBlank String workspaceId) { } + public record InspectRequest(@NotBlank String workspaceId, @NotBlank String worktreeId) { } + public record CleanupRequest(@NotBlank String workspaceId, @NotBlank String worktreeId, @NotBlank String idempotencyKey) { } + public record Page(List data) { } + + @PostMapping("/api/agent/web-shell/v1/workspaces/worktrees/options") + public ResponseEntity options(TenantContext tenant, @Valid @RequestBody OptionsRequest request) { + return noStore(service.options(tenant.tenantId(), tenant.requireActorId(), + new WorkspaceSelection(request.workspaceId(), request.cwdRelative()), request.baseRef())); + } + + @PostMapping("/api/agent/web-shell/v1/workspaces/worktrees/query") + public ResponseEntity query(TenantContext tenant, @Valid @RequestBody QueryRequest request) { + return noStore(new Page(service.list(tenant.tenantId(), tenant.requireActorId(), request.workspaceId()))); + } + + @PostMapping("/api/agent/web-shell/v1/workspaces/worktrees/inspect") + public ResponseEntity inspect(TenantContext tenant, @Valid @RequestBody InspectRequest request) { + return noStore(service.inspect(tenant.tenantId(), tenant.requireActorId(), request.workspaceId(), request.worktreeId())); + } + + @PostMapping("/api/agent/web-shell/v1/workspaces/worktrees/cleanup") + public ResponseEntity cleanup(TenantContext tenant, @Valid @RequestBody CleanupRequest request) { + return ResponseEntity.accepted().cacheControl(CacheControl.noStore()).body(service.cleanup(tenant.tenantId(), + tenant.requireActorId(), request.workspaceId(), request.worktreeId(), request.idempotencyKey())); + } + + @GetMapping("/v1/agents/workspaces/{workspaceId}/worktrees/options") + public ResponseEntity> publicOptions(TenantContext tenant, @PathVariable String workspaceId, + @RequestParam(name = "cwd_relative", defaultValue = ".") String cwd, + @RequestParam(name = "base_ref", required = false) String ref) { + var options = service.options(tenant.tenantId(), tenant.requireActorId(), new WorkspaceSelection(workspaceId, cwd), ref); + Map result = new LinkedHashMap<>(); + result.put("supported", options.supported()); + result.put("reason", options.reason()); + result.put("base_commit", options.baseCommit()); + result.put("branches", options.branches()); + result.put("current_branch", options.currentBranch()); + return noStore(result); + } + + @GetMapping("/v1/agents/workspaces/{workspaceId}/worktrees") + public ResponseEntity> publicQuery(TenantContext tenant, @PathVariable String workspaceId) { + return noStore(Map.of("data", service.list(tenant.tenantId(), tenant.requireActorId(), workspaceId).stream() + .map(SessionWorktreeController::publicDescriptor).toList())); + } + + @GetMapping("/v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}") + public ResponseEntity> publicInspect(TenantContext tenant, @PathVariable String workspaceId, + @PathVariable String worktreeId) { + var inspection = service.inspect(tenant.tenantId(), tenant.requireActorId(), workspaceId, worktreeId); + Map result = new LinkedHashMap<>(); + result.put("worktree", publicDescriptor(inspection.worktree())); + result.put("can_cleanup", inspection.canCleanup()); + result.put("cleanup_blocker", inspection.cleanupBlocker()); + return noStore(result); + } + + @PostMapping("/v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}/cleanup") + public ResponseEntity> publicCleanup(TenantContext tenant, @PathVariable String workspaceId, + @PathVariable String worktreeId, @RequestHeader("Idempotency-Key") String key) { + return ResponseEntity.accepted().cacheControl(CacheControl.noStore()).body(publicDescriptor(service.cleanup( + tenant.tenantId(), tenant.requireActorId(), workspaceId, worktreeId, key))); + } + + private static Map publicDescriptor(Descriptor descriptor) { + Map result = new LinkedHashMap<>(); + result.put("id", descriptor.id()); + result.put("session_id", descriptor.sessionId()); + result.put("workspace_id", descriptor.workspaceId()); + result.put("state", descriptor.state()); + result.put("branch", descriptor.branch()); + result.put("base_commit", descriptor.baseCommit()); + result.put("cwd_relative", descriptor.cwdRelative()); + result.put("operation_id", descriptor.operationId()); + result.put("revision", descriptor.revision()); + result.put("error_code", descriptor.errorCode()); + return result; + } + + private static ResponseEntity noStore(T value) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(value); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java index 753e3708802..d5fa2b28189 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java @@ -43,6 +43,7 @@ import com.alibaba.qwen.code.managedagent.session.ManagedTaskCancelService; import com.alibaba.qwen.code.managedagent.session.ManagedTaskService; import com.alibaba.qwen.code.managedagent.session.SessionLifecycleService; import com.alibaba.qwen.code.managedagent.session.SessionRecoveryService; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService; import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -229,6 +230,10 @@ public class WebShellAgentController { if (selection == null && request.extensionPins() != null) { throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Extension packages require a bound Workspace."); } + var worktree = SessionWorktreeService.parse(request.worktree(), true); + if (selection == null && worktree != null) { + throw new ManagedAgentException(HttpStatus.BAD_REQUEST, "unsupported_feature", "Session worktrees require a bound Workspace."); + } // The WebShell handlers forward no metadata at all (null in its // slot): whatever the client sends is accepted and ignored, so no // clientId-only contract is asserted here and immediately @@ -237,10 +242,13 @@ public class WebShellAgentController { ? service.createSession(tenant.tenantId(), tenant.actorId(), request.idempotencyKey(), request.agentId(), null, request.title(), null, request.input()) - : service.createWorkspaceSession(tenant.tenantId(), + : worktree == null ? service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), request.idempotencyKey(), request.agentId(), null, request.title(), null, - request.input(), selection, request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins())); + request.input(), selection, request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins()) + : service.createWorkspaceSession(tenant.tenantId(), tenant.requireActorId(), request.idempotencyKey(), + request.agentId(), null, request.title(), null, request.input(), selection, + request.approvalMode(), request.planMode(), request.skills(), request.modelId(), request.extensionPins(), worktree)); return ResponseEntity.accepted().body(admission); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileController.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileController.java index 867831601e5..9739485d9b6 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileController.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileController.java @@ -29,7 +29,7 @@ public class WorkspaceFileController { this.uploads = uploads; } - public record QueryRequest(@NotBlank String workspaceId, @NotBlank String cwdRelative, + public record QueryRequest(String sessionId, String workspaceId, String cwdRelative, @NotBlank @Size(max = 1024) String directory, @Size(max = 256) String query, @Size(max = 64) String expectedScopeRevision) { @JsonAnySetter @@ -38,7 +38,7 @@ public class WorkspaceFileController { } } - public record ReadRequest(@NotBlank String workspaceId, @NotBlank String cwdRelative, + public record ReadRequest(String sessionId, String workspaceId, String cwdRelative, @NotBlank @Size(max = 1024) String path, @NotBlank @Size(max = 64) String expectedScopeRevision) { @JsonAnySetter public void rejectUnknown(String name, JsonNode value) { @@ -57,7 +57,7 @@ public class WorkspaceFileController { public ResponseEntity query(TenantContext tenant, @Valid @RequestBody QueryRequest request) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(files.query(tenant.tenantId(), - tenant.requireActorId(), request.workspaceId(), request.cwdRelative(), request.directory(), + tenant.requireActorId(), new WorkspaceFileStore.Locator(request.sessionId(), request.workspaceId(), request.cwdRelative()), request.directory(), request.query() == null ? "" : request.query(), request.expectedScopeRevision())); } @@ -65,20 +65,23 @@ public class WorkspaceFileController { public ResponseEntity read(TenantContext tenant, @Valid @RequestBody ReadRequest request) { return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(files.read(tenant.tenantId(), - tenant.requireActorId(), request.workspaceId(), request.cwdRelative(), request.path(), request.expectedScopeRevision())); + tenant.requireActorId(), new WorkspaceFileStore.Locator(request.sessionId(), request.workspaceId(), request.cwdRelative()), + request.path(), request.expectedScopeRevision())); } @PostMapping(value = "/api/agent/web-shell/v1/workspaces/files/upload", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE) public ResponseEntity upload(TenantContext tenant, - @RequestParam String workspaceId, @RequestParam String cwdRelative, @RequestParam String path, + @RequestParam(required = false) String sessionId, @RequestParam(required = false) String workspaceId, + @RequestParam(required = false) String cwdRelative, @RequestParam String path, @RequestParam String expectedScopeRevision, @RequestParam String uploadId, HttpServletRequest request) throws java.io.IOException { String actor = tenant.requireActorId(); String encoding = request.getHeader("Content-Encoding"); if (encoding != null && !"identity".equalsIgnoreCase(encoding)) { throw new ManagedAgentException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "invalid_request", "Encoded Workspace uploads are unsupported."); } + var locator = new WorkspaceFileStore.Locator(sessionId, workspaceId, cwdRelative); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(uploads.upload(tenant.tenantId(), actor, - workspaceId, cwdRelative, path, expectedScopeRevision, uploadId, request.getInputStream(), request.getContentLengthLong())); + locator, path, expectedScopeRevision, uploadId, request.getInputStream(), request.getContentLengthLong())); } @PostMapping("/api/agent/web-shell/v1/workspaces/files/uploads/query") diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java index 05431d4a9ad..29886d37075 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java @@ -1542,6 +1542,7 @@ public class ManagedAgentProperties implements EnvironmentAware { private long staticEpoch = 1; private Map environment = new LinkedHashMap<>(); private boolean childWorkspacesEnabled; + private boolean sessionWorktreesEnabled; private boolean childSnapshotsEnabled; private String childWorkspaceGit = "git"; @DurationUnit(ChronoUnit.MILLIS) @@ -1776,6 +1777,14 @@ public class ManagedAgentProperties implements EnvironmentAware { return childWorkspacesEnabled; } + public boolean isSessionWorktreesEnabled() { + return sessionWorktreesEnabled; + } + + public void setSessionWorktreesEnabled(boolean sessionWorktreesEnabled) { + this.sessionWorktreesEnabled = sessionWorktreesEnabled; + } + public void setChildWorkspacesEnabled(boolean childWorkspacesEnabled) { this.childWorkspacesEnabled = childWorkspacesEnabled; } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedArtifactConfiguration.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedArtifactConfiguration.java index 54985cc2a90..f172c31500d 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedArtifactConfiguration.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedArtifactConfiguration.java @@ -115,6 +115,11 @@ public class ManagedArtifactConfiguration { return builder.poolSize(1).threadNamePrefix("child-workspace-").build(); } + @Bean + public ThreadPoolTaskScheduler sessionWorktreeScheduler(ThreadPoolTaskSchedulerBuilder builder) { + return builder.poolSize(1).threadNamePrefix("session-worktree-").build(); + } + /** * The 100 ms materialize pass runs sequential JDBC transactions; sharing * the one-thread default scheduler would delay turn recovery by a whole diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java index ae9d140f12a..c8409d74c18 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/ApiModels.java @@ -73,7 +73,12 @@ public final class ApiModels { Boolean stream, JsonNode workspace, @Size(max = 256) List<@NotNull Map> skills, - @Size(max = 32) List<@NotNull @Valid Pin> extensionPins) { + @Size(max = 32) List<@NotNull @Valid Pin> extensionPins, JsonNode worktree) { + public CreateSessionRequest(String agentId, String agentRevision, List input, + Map metadata, Boolean stream, JsonNode workspace, List> skills, + List extensionPins) { + this(agentId, agentRevision, input, metadata, stream, workspace, skills, extensionPins, null); + } public CreateSessionRequest(String agentId, String agentRevision, List input, Map metadata, Boolean stream, JsonNode workspace, List> skills) { this(agentId, agentRevision, input, metadata, stream, workspace, skills, null); @@ -130,6 +135,14 @@ public final class ApiModels { long contextRevision, String state) { } + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PublicSessionWorktree(String id, @JsonProperty("session_id") String sessionId, + @JsonProperty("workspace_id") String workspaceId, String state, String branch, + @JsonProperty("base_commit") String baseCommit, @JsonProperty("cwd_relative") String cwdRelative, + @JsonProperty("operation_id") String operationId, long revision, + @JsonProperty("error_code") String errorCode) { + } + public record SessionCapabilities( boolean items, boolean snapshots, @@ -208,7 +221,18 @@ public final class ApiModels { @JsonProperty("workspace") PublicWorkspace workspace, @JsonProperty("compaction_revision") Long compactionRevision, @JsonProperty("context_compression_operation_id") String contextCompressionOperationId, - SessionRecovery recovery) { + SessionRecovery recovery, + PublicSessionWorktree worktree) { + public PublicSession(String id, String object, String agentId, String agentRevision, + String agentDefinitionDigest, String status, long createdAt, long updatedAt, + Map metadata, PublicTurn activeTurn, long lastEventId, + long replayFloorSequence, long snapshotThroughSequence, SessionCapabilities capabilities, + PublicWorkspace workspace, Long compactionRevision, String contextCompressionOperationId, + SessionRecovery recovery) { + this(id, object, agentId, agentRevision, agentDefinitionDigest, status, createdAt, updatedAt, + metadata, activeTurn, lastEventId, replayFloorSequence, snapshotThroughSequence, + capabilities, workspace, compactionRevision, contextCompressionOperationId, recovery, null); + } public PublicSession(String id, String object, String agentId, String agentRevision, String agentDefinitionDigest, String status, long createdAt, long updatedAt, Map metadata, PublicTurn activeTurn, long lastEventId, @@ -239,7 +263,18 @@ public final class ApiModels { agentDefinitionDigest, status, createdAt, updatedAt, metadata, activeTurn, lastEventId, replayFloorSequence, snapshotThroughSequence, capabilities, workspace, - compactionRevision, contextCompressionOperationId, value); + compactionRevision, contextCompressionOperationId, value, worktree); + } + + public PublicSession withWorktree(com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor value) { + return new PublicSession(id, object, agentId, agentRevision, + agentDefinitionDigest, status, createdAt, updatedAt, + metadata, activeTurn, lastEventId, replayFloorSequence, + snapshotThroughSequence, capabilities, workspace, + compactionRevision, contextCompressionOperationId, recovery, + value == null ? null : new PublicSessionWorktree(value.id(), value.sessionId(), value.workspaceId(), + value.state(), value.branch(), value.baseCommit(), value.cwdRelative(), value.operationId(), + value.revision(), value.errorCode())); } } @@ -419,7 +454,13 @@ public final class ApiModels { Map metadata, JsonNode workspace, String approvalMode, Boolean planMode, String modelId, @Size(max = 256) List<@NotNull Map> skills, - @Size(max = 32) List<@NotNull @Valid Pin> extensionPins) { + @Size(max = 32) List<@NotNull @Valid Pin> extensionPins, JsonNode worktree) { + public WebShellCreateRequest(String requestId, String idempotencyKey, String agentId, String title, + List input, Map metadata, JsonNode workspace, String approvalMode, + Boolean planMode, String modelId, List> skills, List extensionPins) { + this(requestId, idempotencyKey, agentId, title, input, metadata, workspace, approvalMode, + planMode, modelId, skills, extensionPins, null); + } public WebShellCreateRequest(String requestId, String idempotencyKey, String agentId, String title, List input, Map metadata, JsonNode workspace, String approvalMode, Boolean planMode, String modelId, List> skills) { @@ -643,7 +684,20 @@ public final class ApiModels { WebShellModelContext modelContext, WebShellModelSelection modelSelection, Long approvalRevision, String initialApprovalMode, boolean planMode, boolean planModeSupported, Long compactionRevision, String contextCompressionOperationId, - WebShellLifecycleOperation lifecycleOperation, SessionRecovery recovery) { + WebShellLifecycleOperation lifecycleOperation, SessionRecovery recovery, + com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor worktree) { + public WebShellSession(String sessionId, String title, String agentId, String status, + long createdAt, long updatedAt, WebShellTurn activeTurn, Object environment, + long lastSequence, WebShellWorkspace workspace, WebShellSessionCapabilities capabilities, + String approvalMode, WebShellModelContext modelContext, WebShellModelSelection modelSelection, + Long approvalRevision, String initialApprovalMode, boolean planMode, boolean planModeSupported, + Long compactionRevision, String contextCompressionOperationId, + WebShellLifecycleOperation lifecycleOperation, SessionRecovery recovery) { + this(sessionId, title, agentId, status, createdAt, updatedAt, activeTurn, environment, lastSequence, + workspace, capabilities, approvalMode, modelContext, modelSelection, approvalRevision, + initialApprovalMode, planMode, planModeSupported, compactionRevision, + contextCompressionOperationId, lifecycleOperation, recovery, null); + } public WebShellSession(String sessionId, String title, String agentId, String status, long createdAt, long updatedAt, WebShellTurn activeTurn, Object environment, long lastSequence, WebShellWorkspace workspace, WebShellSessionCapabilities capabilities, @@ -691,7 +745,16 @@ public final class ApiModels { workspace, capabilities, approvalMode, modelContext, modelSelection, approvalRevision, initialApprovalMode, planMode, planModeSupported, compactionRevision, contextCompressionOperationId, - lifecycleOperation, value); + lifecycleOperation, value, worktree); + } + + public WebShellSession withWorktree(com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor value) { + return new WebShellSession(sessionId, title, agentId, status, + createdAt, updatedAt, activeTurn, environment, lastSequence, + workspace, capabilities, approvalMode, modelContext, + modelSelection, approvalRevision, initialApprovalMode, + planMode, planModeSupported, compactionRevision, contextCompressionOperationId, + lifecycleOperation, recovery, value); } } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java index 8dcb1b51889..4a5dfc55fcb 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/model/StoreModels.java @@ -2,6 +2,7 @@ package com.alibaba.qwen.code.managedagent.model; import com.alibaba.qwen.code.managedagent.persistence.EventIdentity; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; import java.util.Arrays; @@ -290,7 +291,10 @@ public final class StoreModels { } public record SessionPage(List sessions, - boolean hasMore) { + boolean hasMore, Map worktrees) { + public SessionPage(List sessions, boolean hasMore) { + this(sessions, hasMore, Map.of()); + } } /** The fields of a Turn that its public view shows, without its input. */ diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java index d39e210a89d..2d1b2936f6b 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java @@ -158,8 +158,24 @@ public class ManagedAgentStore implements AgentStateStore { private final List modelCatalog; private ManagedAgentDefinitionStore definitions; private ManagedSessionSkills creationSkills; + private SessionWorktreeStore sessionWorktrees; private com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService extensionPackages; + @org.springframework.beans.factory.annotation.Autowired(required = false) + public void setSessionWorktrees(SessionWorktreeStore value) { + this.sessionWorktrees = value; + } + + @Override + public SessionWorktreeStore.Descriptor findSessionWorktree(String tenantId, String sessionId) { + return SessionWorktreeStore.descriptor(jdbc, tenantId, sessionId); + } + + @Override + public Map findSessionWorktrees(String tenantId, List sessionIds) { + return SessionWorktreeStore.descriptors(jdbc, tenantId, sessionIds); + } + @org.springframework.beans.factory.annotation.Autowired public void setExtensionPackages(com.alibaba.qwen.code.managedagent.extensions.ManagedExtensionPackageService value) { this.extensionPackages = value; @@ -481,6 +497,21 @@ public class ManagedAgentStore implements AgentStateStore { boolean planModeSupported, List> skills, boolean publicChildrenSupported, String modelId, List extensionPins, Set extensionLifecycleProfiles) { + return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, + agentId, requestedRevision, title, input, payloadDigest, selection, requestedApprovalMode, + approvalSelectionSupported, requestedPlanMode, planModeSupported, skills, publicChildrenSupported, + modelId, extensionPins, extensionLifecycleProfiles, null); + } + + @Override + @Transactional + public Admission insertWorkspaceSessionCommand(String tenantId, String actorId, + String idempotencyKey, String requestDigest, String agentId, String requestedRevision, + String title, List> input, String payloadDigest, WorkspaceSelection selection, + String requestedApprovalMode, boolean approvalSelectionSupported, Boolean requestedPlanMode, + boolean planModeSupported, List> skills, boolean publicChildrenSupported, String modelId, + List extensionPins, + Set extensionLifecycleProfiles, SessionWorktreeStore.Plan worktree) { if (!input.isEmpty() && !workspaceFilesEnabled) { throw workspaceExecutionUnavailable(); } @@ -553,9 +584,16 @@ public class ManagedAgentStore implements AgentStateStore { } merged.addAll(skills); var effectiveSkills = AgentDefinitionExtensions.sessionSkills(merged); + if (worktree != null && sessionWorktrees == null) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "worktree_unavailable", "Session worktrees are unavailable."); + } + ResolvedBinding target = worktree == null ? workspace : worktree.target(workspace); Admission admission = insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest, agentId, title, input, payloadDigest, - workspace, actorId, pin, null, planMode, modelId); + target, actorId, pin, null, planMode, modelId); + if (worktree != null) { + sessionWorktrees.admit(tenantId, actorId, admission.sessionId(), worktree, workspace); + } if (workspaceChildAgentsEnabled && publicChildrenSupported && !resident && (!sshWorkspace || managedExtensionExecutionEnabled) && (managedExtensionExecutionEnabled || "qwen-code".equals(agentId) && pin.digest() == null) && WorkspaceToolProfiles.SHELL.equals(pin.toolProfile())) { @@ -1621,6 +1659,7 @@ public class ManagedAgentStore implements AgentStateStore { throw new ManagedAgentException(HttpStatus.CONFLICT, "session_not_active", "The Session does not accept new Turns."); } + SessionWorktreeStore.requireReady(jdbc, tenantId, sessionId); ManagedConfigInstall.requireOpen(jdbc, tenantId, sessionId); if (hasActiveTurn(tenantId, sessionId)) { throw new ManagedAgentException(HttpStatus.CONFLICT, "turn_active", @@ -3389,6 +3428,14 @@ public class ManagedAgentStore implements AgentStateStore { operation)) { failure = "workspace_unavailable"; } + if (failure == null) { + try { + SessionWorktreeStore.requireReady(jdbc, tenantId, sessionId); + SessionWorktreeStore.requireCwd(jdbc, tenantId, sessionId, operation.targetCwdRelative()); + } catch (ManagedAgentException error) { + failure = error.getCode(); + } + } if (failure != null) { jdbc.update( "UPDATE ma_operation SET phase = 'FAILED', state =" @@ -3936,6 +3983,7 @@ public class ManagedAgentStore implements AgentStateStore { } private void requireCwdChangeRegistryFacts(SessionRecord session) { + SessionWorktreeStore.requireReady(jdbc, session.tenantId(), session.sessionId()); if (!hasExecutionRegistryFacts(session.tenantId(), session.sessionId())) { throw workspaceExecutionUnavailable(); @@ -4642,13 +4690,26 @@ public class ManagedAgentStore implements AgentStateStore { arguments.add(beforeSessionId); } arguments.add(limit + 1); + Map worktrees = new LinkedHashMap<>(); List rows = jdbc.query( "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") + ", COALESCE(es.last_sequence, 0) AS list_last_sequence," - + " COALESCE(es.replay_floor_sequence, 0) AS list_floor_sequence FROM" + + " COALESCE(es.replay_floor_sequence, 0) AS list_floor_sequence," + + " w.tenant_id AS worktree_tenant_id, w.session_id AS worktree_session_id," + + " w.workspace_id AS worktree_workspace_id, w.worktree_id AS worktree_worktree_id," + + " w.state AS worktree_state, w.plan_json AS worktree_plan_json," + + " w.identity_json AS worktree_identity_json, w.operation_id AS worktree_operation_id," + + " w.revision AS worktree_revision, w.failure_code AS worktree_failure_code," + + " w.owner_actor_key AS worktree_owner_actor_key, o.claim_owner AS worktree_claim_owner," + + " o.claim_generation AS worktree_claim_generation, o.claim_until AS worktree_claim_until," + + " o.phase AS worktree_phase, o.attempt_count AS worktree_attempt_count FROM" + " ma_session LEFT JOIN ma_event_stream es ON es.tenant_id =" + " ma_session.tenant_id AND es.session_id = ma_session.session_id AND" - + " es.stream_kind = 'SESSION' AND es.stream_id = X'' WHERE" + + " es.stream_kind = 'SESSION' AND es.stream_id = X''" + + " LEFT JOIN ma_session_worktree w ON w.tenant_id = ma_session.tenant_id" + + " AND w.session_id = ma_session.session_id LEFT JOIN ma_operation o" + + " ON o.operation_kind = 'SESSION_WORKTREE' AND o.tenant_id = w.tenant_id" + + " AND o.operation_id = w.operation_id WHERE" + " ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id = ? AND" + " ma_session.status <> 'DELETED' AND (ma_session.workspace_id IS NULL OR EXISTS (SELECT 1" + " FROM ma_workspace_access wa WHERE wa.tenant_id =" @@ -4658,13 +4719,21 @@ public class ManagedAgentStore implements AgentStateStore { + archiveClause + titleClause + cursorClause + " ORDER BY ma_session.gmt_create DESC, ma_session.session_id DESC" + " LIMIT ?", - sessionListMapper, + (result, index) -> { + SessionRecord session = sessionListMapper.mapRow(result, index); + if (result.getString("worktree_worktree_id") != null) { + worktrees.put(session.sessionId(), SessionWorktreeStore.readRow(result, "worktree_")); + } + return session; + }, arguments.toArray()); boolean hasMore = rows.size() > limit; if (hasMore) { rows = new ArrayList<>(rows.subList(0, limit)); } - return new SessionPage(List.copyOf(rows), hasMore); + var selectedWorktrees = rows.stream().map(row -> worktrees.get(row.sessionId())) + .filter(java.util.Objects::nonNull).toList(); + return new SessionPage(List.copyOf(rows), hasMore, SessionWorktreeStore.descriptors(jdbc, selectedWorktrees)); } public Map @@ -5508,7 +5577,7 @@ public class ManagedAgentStore implements AgentStateStore { // Harness park enters only the coordinator's read-only observation // branch; cancellation still uses the normal dispatch lane. return jdbc.query( - "SELECT tenant_id, session_id, turn_id FROM ma_turn WHERE status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') AND (dispatch_lease_until IS NULL OR dispatch_lease_until < ?) AND (retry_after IS NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR error_code IS NULL OR error_code NOT IN ('" + AgentStateStore.WORKFLOW_RECOVERY_BLOCKED + "', '" + AgentStateStore.RUNTIME_RECOVERY_BLOCKED + "')) AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_turn.tenant_id AND private_scope.session_id = ma_turn.session_id AND private_scope.csi_guard = FALSE) ORDER BY gmt_modified ASC LIMIT ?", + "SELECT tenant_id, session_id, turn_id FROM ma_turn WHERE status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') AND (dispatch_lease_until IS NULL OR dispatch_lease_until < ?) AND (retry_after IS NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR error_code IS NULL OR error_code NOT IN ('" + AgentStateStore.WORKFLOW_RECOVERY_BLOCKED + "', '" + AgentStateStore.RUNTIME_RECOVERY_BLOCKED + "')) AND (status = 'CANCELLING' OR NOT EXISTS (SELECT 1 FROM ma_session_worktree w WHERE w.tenant_id = ma_turn.tenant_id AND w.session_id = ma_turn.session_id AND w.state <> 'READY')) AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_turn.tenant_id AND private_scope.session_id = ma_turn.session_id AND private_scope.csi_guard = FALSE) ORDER BY gmt_modified ASC LIMIT ?", (result, row) -> new DispatchTarget( result.getString("tenant_id"), result.getString("session_id"), @@ -5541,7 +5610,9 @@ public class ManagedAgentStore implements AgentStateStore { + " AND status IN ('ACCEPTED', 'RUNNING'," + " 'CANCELLING') AND (dispatch_lease_until IS NULL" + " OR dispatch_lease_until < ?) AND (retry_after IS" - + " NULL OR retry_after <= ?)", + + " NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR NOT EXISTS" + + " (SELECT 1 FROM ma_session_worktree w WHERE w.tenant_id = ma_turn.tenant_id" + + " AND w.session_id = ma_turn.session_id AND w.state <> 'READY'))", owner, now + leaseDuration.toMillis(), now, tenantId, sessionId, turnId, now, now); return updated == 0 ? Optional.empty() @@ -6418,6 +6489,27 @@ public class ManagedAgentStore implements AgentStateStore { settleFailedTurn(tenantId, sessionId, turnId, owner, code, message, true); } + @Transactional + public void failWorktreeInitialTurns(String tenantId, String sessionId, String code) { + requireLegacySessionForUpdate(tenantId, sessionId); + if (jdbc.queryForList("SELECT 1 FROM ma_session_worktree WHERE tenant_id = ? AND session_id = ?" + + " AND state IN ('FAILED', 'BLOCKED') FOR UPDATE", Integer.class, tenantId, sessionId).isEmpty()) { + return; + } + long now = clock.millis(); + String message = "The Session worktree could not be prepared."; + List turns = jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id = ? AND session_id = ?" + + " AND status = 'ACCEPTED' AND submission_attempted = FALSE FOR UPDATE", String.class, tenantId, sessionId); + for (String turnId : turns) { + jdbc.update("UPDATE ma_turn SET status = 'FAILED', error_code = ?, error_message = ?, completed_at = ?," + + " gmt_modified = ?, dispatch_owner = NULL, dispatch_lease_until = NULL, version = version + 1" + + " WHERE tenant_id = ? AND session_id = ? AND turn_id = ? AND status = 'ACCEPTED'" + + " AND submission_attempted = FALSE", code, message, now, now, tenantId, sessionId, turnId); + appendEvent(tenantId, sessionId, turnId, "turn.failed", Map.of("code", code, "message", message), true, null, now); + settleTurnCommands(tenantId, sessionId, turnId, now); + } + } + @Transactional public boolean failRecoveryBlockedTurn(String tenantId, String sessionId, String turnId, String owner, String message) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedUserMessageStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedUserMessageStore.java index 8b3dc36b6fc..24404f03242 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedUserMessageStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedUserMessageStore.java @@ -82,6 +82,7 @@ public class ManagedUserMessageStore { reconcile(tenant, session); switch (command.action()) { case "submit" -> { + SessionWorktreeStore.requireReady(jdbc, tenant, session); List> input = agents.userMessageInput(command.input()); Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM ma_user_message WHERE tenant_id = ?" + " AND session_id = ? AND state IN ('queued', 'claimed', 'recovery_blocked')", Integer.class, tenant, session); @@ -117,6 +118,7 @@ public class ManagedUserMessageStore { } } case "resume" -> { + SessionWorktreeStore.requireReady(jdbc, tenant, session); if ("RECOVERY_BLOCKED".equals(latestTurn(tenant, session).get("status"))) { throw conflict("user_message_recovery_blocked"); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedWorkspaceRegistry.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedWorkspaceRegistry.java index ef4423f0420..fe1d14f6698 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedWorkspaceRegistry.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedWorkspaceRegistry.java @@ -350,22 +350,7 @@ public class ManagedWorkspaceRegistry { "workspace_forbidden", "Workspace cannot be used."); } WorkspaceRecord record = records.getFirst(); - var remote = jdbc.queryForList("SELECT registration_json FROM ma_storage WHERE alias_key = ? AND storage_kind = 'SSH_LOCAL'", - String.class, com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageAliasKey(tenantId, record.getStorageId())); - if (!remote.isEmpty()) { - boolean allowed = false; - if (ssh != null && ssh.isEnabled()) { - try { - String nodeId = JSON.readTree(remote.getFirst()).path("nodeId").asText(); - allowed = ssh.getNodes().stream().anyMatch(node -> node.nodeId().equals(nodeId) && node.admits(tenantId, actorId)); - } catch (com.fasterxml.jackson.core.JsonProcessingException error) { - throw new ManagedAgentException(HttpStatus.CONFLICT, "workspace_unavailable", "Workspace registration is invalid."); - } - } - if (!allowed) { - throw new ManagedAgentException(HttpStatus.FORBIDDEN, "runtime_node_forbidden", "New SSH Sessions are disabled or unauthorized."); - } - } + requireSshNodeAccess(tenantId, actorId, record.getStorageId()); if (record.getState() != WorkspaceState.ACTIVE) { if (selection == null) { throw workspaceRequired(); @@ -381,6 +366,25 @@ public class ManagedWorkspaceRegistry { record.getPolicyRef()); } + void requireSshNodeAccess(String tenantId, String actorId, String storageId) { + var remote = jdbc.queryForList("SELECT registration_json FROM ma_storage WHERE alias_key = ? AND storage_kind = 'SSH_LOCAL'", + String.class, com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageAliasKey(tenantId, storageId)); + if (!remote.isEmpty()) { + boolean allowed = false; + if (ssh != null && ssh.isEnabled()) { + try { + String nodeId = JSON.readTree(remote.getFirst()).path("nodeId").asText(); + allowed = ssh.getNodes().stream().anyMatch(node -> node.nodeId().equals(nodeId) && node.admits(tenantId, actorId)); + } catch (com.fasterxml.jackson.core.JsonProcessingException error) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "workspace_unavailable", "Workspace registration is invalid."); + } + } + if (!allowed) { + throw new ManagedAgentException(HttpStatus.FORBIDDEN, "runtime_node_forbidden", "SSH runtime node is disabled or unauthorized."); + } + } + } + ResolvedBinding resolveOwnedDirectory(String tenant, String actor, String workspace, String cwd) { ResolvedBinding resolved = resolveForCreation(tenant, actor, new WorkspaceSelection(workspace, cwd)); if (accessOf(tenant, actor, workspace) != WorkspaceAccess.OWNER diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStore.java new file mode 100644 index 00000000000..c608f5293d7 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStore.java @@ -0,0 +1,477 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry.ResolvedBinding; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeGit; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.file.Path; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +@Repository +public class SessionWorktreeStore { + public static final String KIND = "SESSION_WORKTREE"; + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String SELECT = "SELECT w.*, o.claim_owner, o.claim_generation, o.claim_until, o.phase, o.attempt_count" + + " FROM ma_session_worktree w JOIN ma_operation o ON o.operation_kind = 'SESSION_WORKTREE'" + + " AND o.tenant_id = w.tenant_id AND o.operation_id = w.operation_id"; + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + private final ManagedWorkspaceRegistry registry; + private final WorkspaceStorageGuard guard; + private String resumeAfter = ""; + + @Autowired + public SessionWorktreeStore(@Qualifier("managedJdbcTemplate") JdbcTemplate jdbc, + ManagedWorkspaceRegistry registry, WorkspaceStorageGuard guard) { + this(jdbc, new DataSourceTransactionManager(jdbc.getDataSource()), registry, guard); + } + + public SessionWorktreeStore(JdbcTemplate jdbc, PlatformTransactionManager manager, + ManagedWorkspaceRegistry registry, WorkspaceStorageGuard guard) { + this.jdbc = jdbc; + this.transaction = new TransactionTemplate(manager); + this.registry = registry; + this.guard = guard; + } + + public record Plan(String tenantId, String workspaceId, long workspaceGeneration, String storageId, + String sourceCwdRelative, String contextConfigRef, long contextRevision, String root, String rootIdentity, + String repositoryRelative, String sourceOffset, String commonDirectory, String commonIdentity, + String baseRef, String baseCommit, String worktreeId, String worktreeRoot, String cwdRelative, String branch, + String storageIdentity) { + public Plan(String tenantId, String workspaceId, long workspaceGeneration, String storageId, + String sourceCwdRelative, String contextConfigRef, long contextRevision, String root, String rootIdentity, + String repositoryRelative, String sourceOffset, String commonDirectory, String commonIdentity, + String baseRef, String baseCommit, String worktreeId, String worktreeRoot, String cwdRelative, String branch) { + this(tenantId, workspaceId, workspaceGeneration, storageId, sourceCwdRelative, contextConfigRef, contextRevision, + root, rootIdentity, repositoryRelative, sourceOffset, commonDirectory, commonIdentity, baseRef, + baseCommit, worktreeId, worktreeRoot, cwdRelative, branch, null); + } + public ContextBinding source() { + return new ContextBinding(tenantId, workspaceId, workspaceGeneration, storageId, sourceCwdRelative, + contextConfigRef, contextRevision); + } + + public ResolvedBinding target(ResolvedBinding source) { + if (!source().equals(source.binding())) { + throw error("worktree_source_changed"); + } + return new ResolvedBinding(new ContextBinding(tenantId, workspaceId, workspaceGeneration, storageId, + cwdRelative, source.binding().getContextConfigRef(), 1), source.configRef(), source.policyRef()); + } + } + + public record Descriptor(String id, String sessionId, String workspaceId, String state, String branch, + String baseCommit, String cwdRelative, String operationId, long revision, String errorCode) { + public String worktreeId() { + return id; + } + } + + public record Row(String tenantId, String sessionId, String workspaceId, String worktreeId, String state, + Plan plan, String identityJson, String operationId, String claimedBy, long generation, + Long claimUntil, String phase, int attempts, long revision, String errorCode, String actor) { + public Descriptor descriptor() { + return descriptor(plan.branch()); + } + + public Descriptor descriptor(String branch) { + return new Descriptor(worktreeId, sessionId, workspaceId, state.toLowerCase(Locale.ROOT), branch, + plan.baseCommit(), plan.cwdRelative(), operationId, revision, errorCode); + } + } + + public record SourceLocation(ResolvedBinding resolved, Path root, String storageIdentity) { } + + public SourceLocation source(String tenant, String actor, com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection selection) { + return transaction.execute(status -> { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenant); + ResolvedBinding source = registry.resolveForCreation(tenant, actor, selection); + Path root = guard.verifiedRoot(source.binding()); + return new SourceLocation(source, root, storageIdentity(source.binding())); + }); + } + + public void authorizeOptions(String tenant, String actor, String workspace) { + var access = registry.accessOf(tenant, actor, workspace); + if (!access.canRead()) { + throw new ManagedAgentException(HttpStatus.NOT_FOUND, "workspace_not_found", "Workspace not found."); + } + if (!access.canCreate()) { + throw new ManagedAgentException(HttpStatus.FORBIDDEN, "workspace_forbidden", "Workspace cannot be used."); + } + } + + public boolean active(Row row) { + return jdbc.queryForList("SELECT status FROM ma_session WHERE tenant_id = ? AND session_id = ?", + String.class, row.tenantId(), row.sessionId()).equals(List.of("ACTIVE")); + } + + public Descriptor describe(String tenant, String session) { + return descriptor(jdbc, tenant, session); + } + + public void admit(String tenant, String actor, String session, Plan plan, ResolvedBinding source) { + plan.target(source); + if (!tenant.equals(plan.tenantId()) || !guard.verifiedRoot(source.binding()).toString().equals(plan.root())) { + throw error("worktree_source_changed"); + } + SessionWorktreeGit.requireRoot(plan); + if (plan.storageIdentity() != null && !plan.storageIdentity().equals(storageIdentity(source.binding()))) { + throw error("worktree_source_changed"); + } + long now = System.currentTimeMillis(); + String physical = WorkspaceExecutionStore.storageKey(jdbc, plan.source()); + jdbc.update("INSERT INTO ma_session_worktree (tenant_id, session_id, workspace_id, storage_id, physical_key," + + " worktree_id, owner_actor_key, state, revision, plan_json, operation_id, gmt_create, gmt_modified)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, 'PREPARING', 1, ?, ?, ?, ?)", + tenant, session, plan.workspaceId(), plan.storageId(), physical, plan.worktreeId(), + ManagedWorkspaceRegistry.actorKey(tenant, actor), json(plan), plan.worktreeId(), now, now); + createOperation(tenant, session, plan, plan.worktreeId(), "PREPARING", null); + } + + private void createOperation(String tenant, String session, Plan plan, String id, String state, String request) { + new WorkspaceOperations(jdbc, tenant).create(WorkspaceOperations.Kind.SESSION_WORKTREE, id, + WorkspaceOperations.values("session_id", session, "storage_id", plan.storageId(), "state", state, + "phase", state, "delivery_state", "PENDING", "request_json", request == null ? json(plan) : request)); + } + + public static Descriptor descriptor(JdbcTemplate jdbc, String tenant, String session) { + Row row = find(jdbc, tenant, session); + return verifiedDescriptor(jdbc, row); + } + + private static Descriptor verifiedDescriptor(JdbcTemplate jdbc, Row row) { + if (row != null && "READY".equals(row.state())) { + try { + return row.descriptor(SessionWorktreeGit.currentBranch(SessionWorktreeGit.verify(row.plan(), row.identityJson()))); + } catch (ManagedAgentException exception) { + long revision = blockIdentity(jdbc, row, exception.getCode()); + return new Descriptor(row.worktreeId(), row.sessionId(), row.workspaceId(), "blocked", row.plan().branch(), + row.plan().baseCommit(), row.plan().cwdRelative(), row.operationId(), revision, exception.getCode()); + } + } + return row == null ? null : row.descriptor(); + } + + public static boolean isReady(JdbcTemplate jdbc, String tenant, String session) { + Descriptor descriptor = descriptor(jdbc, tenant, session); + return descriptor == null || "ready".equals(descriptor.state()); + } + + public static Map descriptors(JdbcTemplate jdbc, String tenant, List sessions) { + if (sessions.isEmpty()) { + return Map.of(); + } + var args = new java.util.ArrayList(); + args.add(tenant); + args.addAll(sessions); + return descriptors(jdbc, jdbc.query(SELECT + " WHERE w.tenant_id = ? AND w.session_id IN (" + + String.join(",", java.util.Collections.nCopies(sessions.size(), "?")) + ")", + (record, index) -> row(record, index), args.toArray())); + } + + static Map descriptors(JdbcTemplate jdbc, List rows) { + var result = new java.util.LinkedHashMap(); + rows.forEach(row -> result.put(row.sessionId(), verifiedDescriptor(jdbc, row))); + return result; + } + + public static void requireReady(JdbcTemplate jdbc, String tenant, String session) { + Descriptor descriptor = descriptor(jdbc, tenant, session); + if (descriptor != null && !"ready".equals(descriptor.state())) { + throw error(descriptor.errorCode() == null ? "worktree_" + descriptor.state() : descriptor.errorCode()); + } + } + + public static void requireCwd(JdbcTemplate jdbc, String tenant, String session, String cwd) { + Row row = find(jdbc, tenant, session); + if (row != null && !Path.of(cwd).normalize().startsWith(Path.of(row.plan().worktreeRoot()))) { + throw error("worktree_cwd_outside"); + } + } + + public static Row find(JdbcTemplate jdbc, String tenant, String session) { + List rows = jdbc.query(SELECT + " WHERE w.tenant_id = ? AND w.session_id = ?", + SessionWorktreeStore::row, tenant, session); + return rows.isEmpty() ? null : rows.getFirst(); + } + + public Row get(String tenant, String actor, String workspace, String id, boolean write) { + var access = registry.accessOf(tenant, actor, workspace); + if (!access.canRead() || write && !access.canCreate()) { + throw notFound(); + } + List rows = jdbc.query(SELECT + " WHERE w.tenant_id = ? AND w.workspace_id = ? AND w.worktree_id = ?", + SessionWorktreeStore::row, tenant, workspace, id); + if (rows.size() != 1) { + throw notFound(); + } + if (write && jdbc.queryForList("SELECT 1 FROM ma_session_worktree WHERE tenant_id = ? AND worktree_id = ?" + + " AND owner_actor_key = ?", Integer.class, tenant, id, ManagedWorkspaceRegistry.actorKey(tenant, actor)).isEmpty()) { + throw notFound(); + } + return rows.getFirst(); + } + + public List list(String tenant, String actor, String workspace) { + if (!registry.accessOf(tenant, actor, workspace).canRead()) { + throw notFound(); + } + List rows = jdbc.query(SELECT + " WHERE w.tenant_id = ? AND w.workspace_id = ? AND w.state <> 'REMOVED' ORDER BY w.gmt_create DESC", + SessionWorktreeStore::row, tenant, workspace); + var descriptions = descriptors(jdbc, tenant, rows.stream().map(Row::sessionId).toList()); + return rows.stream().map(row -> descriptions.get(row.sessionId())).toList(); + } + + public List due() { + long now = System.currentTimeMillis(); + return jdbc.query(SELECT + " WHERE w.state IN ('PREPARING', 'REMOVING') AND o.delivery_state = 'PENDING'" + + " AND o.available_at <= ? AND (o.claim_until IS NULL OR o.claim_until <= ?) ORDER BY o.available_at LIMIT 20", + SessionWorktreeStore::row, now, now); + } + + public List failedInitialSessions() { + return jdbc.query("SELECT DISTINCT w.tenant_id, w.session_id, w.failure_code FROM ma_session_worktree w" + + " JOIN ma_turn t ON t.tenant_id = w.tenant_id AND t.session_id = w.session_id" + + " WHERE w.state IN ('FAILED','BLOCKED') AND t.status = 'ACCEPTED' AND t.submission_attempted = FALSE LIMIT 20", + (result, index) -> new String[] {result.getString("tenant_id"), result.getString("session_id"), result.getString("failure_code")}); + } + + public Row claim(Row row, String owner, long leaseMillis) { + return transaction.execute(status -> { + WorkspaceStorageKindGuard.lockDomain(jdbc, row.tenantId()); + long now = System.currentTimeMillis(); + if (jdbc.queryForList("SELECT 1 FROM ma_operation WHERE operation_key = ? AND delivery_state = 'PENDING'" + + " AND (claim_until IS NULL OR claim_until <= ?) FOR UPDATE", Integer.class, key(row), now).isEmpty()) { + return null; + } + String physical = WorkspaceExecutionStore.storageKey(jdbc, storageBinding(row.plan())); + if (!jdbc.queryForList("SELECT 1 FROM ma_storage_owner WHERE storage_key = ? AND maintenance_id = ?" + + " AND holder_key IS NOT NULL FOR UPDATE", Integer.class, physical, row.operationId()).isEmpty()) { + jdbc.update("UPDATE ma_session_worktree SET state = 'BLOCKED', failure_code = 'maintenance_stop_unverified'," + + " revision = revision + 1 WHERE tenant_id = ? AND worktree_id = ?", row.tenantId(), row.worktreeId()); + return null; + } + if (jdbc.update("UPDATE ma_operation SET claim_owner = ?, claim_until = ?, claim_generation = claim_generation + 1" + + " WHERE operation_key = ? AND delivery_state = 'PENDING' AND (claim_until IS NULL OR claim_until <= ?)", + owner, now + leaseMillis, key(row), now) != 1) { + return null; + } + return find(jdbc, row.tenantId(), row.sessionId()); + }); + } + + public boolean renew(Row row, String owner, long leaseMillis) { + return jdbc.update("UPDATE ma_operation SET claim_until = ? WHERE operation_key = ? AND claim_owner = ?" + + " AND claim_generation = ? AND claim_until > ?", System.currentTimeMillis() + leaseMillis, + key(row), owner, row.generation(), System.currentTimeMillis()) == 1; + } + + public boolean complete(Row row, String owner, String state, String identity, String error) { + return transaction.execute(status -> { + if (!lockClaim(row, owner)) { + return false; + } + jdbc.update("UPDATE ma_session_worktree SET state = ?, identity_json = COALESCE(?, identity_json)," + + " failure_code = ?, revision = revision + 1, gmt_modified = ? WHERE tenant_id = ? AND session_id = ?", + state, identity, error, System.currentTimeMillis(), row.tenantId(), row.sessionId()); + jdbc.update("UPDATE ma_operation SET state = ?, phase = ?, delivery_state = 'DONE', failure_code = ?," + + " completed_at = ?, claim_owner = NULL, claim_until = NULL WHERE operation_key = ?", + state, state, error, System.currentTimeMillis(), key(row)); + return true; + }); + } + + public void defer(Row row, String owner, String error) { + defer(row, owner, error, false); + } + + public void retry(Row row, String owner, String error) { + defer(row, owner, error, true); + } + + private void defer(Row row, String owner, String error, boolean failure) { + transaction.executeWithoutResult(status -> { + if (lockClaim(row, owner)) { + long delay = failure ? Math.min(60000, 1000L << Math.min(row.attempts(), 6)) : 1000; + jdbc.update("UPDATE ma_operation SET available_at = ?, attempt_count = attempt_count + ?," + + " claim_owner = NULL, claim_until = NULL WHERE operation_key = ?", + System.currentTimeMillis() + delay, failure ? 1 : 0, key(row)); + jdbc.update("UPDATE ma_session_worktree SET failure_code = ? WHERE tenant_id = ? AND session_id = ?", + error, row.tenantId(), row.sessionId()); + } + }); + } + + public void resumeStopped() { + List candidates = jdbc.query(SELECT + " WHERE w.state = 'BLOCKED' AND w.failure_code = 'maintenance_stop_unverified'" + + " AND w.worktree_id > ? ORDER BY w.worktree_id LIMIT 20", SessionWorktreeStore::row, resumeAfter); + resumeAfter = candidates.isEmpty() ? "" : candidates.getLast().worktreeId(); + for (Row row : candidates) { + transaction.executeWithoutResult(status -> { + WorkspaceStorageKindGuard.lockDomain(jdbc, row.tenantId()); + Row current = find(jdbc, row.tenantId(), row.sessionId()); + if (current == null || !"BLOCKED".equals(current.state()) || !current.operationId().equals(row.operationId()) + || current.generation() != row.generation()) { + return; + } + var binding = storageBinding(row.plan()); + if (maintenanceHeld(row) || !WorkspaceMaintenanceRecovery.stopped(jdbc, binding, row.operationId(), row.generation())) { + return; + } + String state = row.operationId().equals(row.worktreeId()) ? "PREPARING" : "REMOVING"; + jdbc.update("UPDATE ma_operation SET state = ?, phase = ?, delivery_state = 'PENDING', available_at = 0," + + " claim_owner = NULL, claim_until = NULL, failure_code = NULL WHERE operation_key = ?", state, state, key(row)); + jdbc.update("UPDATE ma_session_worktree SET state = ?, failure_code = NULL, revision = revision + 1" + + " WHERE tenant_id = ? AND session_id = ? AND state = 'BLOCKED'", state, row.tenantId(), row.sessionId()); + }); + } + } + + public boolean maintenanceHeld(Row row) { + return !jdbc.queryForList("SELECT 1 FROM ma_storage_owner WHERE storage_key = ? AND maintenance_id = ?" + + " AND holder_key IS NOT NULL", Integer.class, WorkspaceExecutionStore.storageKey(jdbc, storageBinding(row.plan())), + row.operationId()).isEmpty(); + } + + public Row cleanup(String tenant, String actor, String workspace, String id, String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isBlank() || idempotencyKey.length() > 512) { + throw error("invalid_idempotency_key"); + } + return transaction.execute(status -> { + WorkspaceStorageKindGuard.lockDomain(jdbc, tenant); + Row row = get(tenant, actor, workspace, id, true); + String operation = UUID.nameUUIDFromBytes((tenant + "\0" + actor + "\0" + id + "\0" + idempotencyKey) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", ""); + if (row.operationId().equals(operation) || "REMOVED".equals(row.state())) { + return row; + } + if (!jdbc.queryForList("SELECT 1 FROM ma_operation WHERE operation_kind = 'SESSION_WORKTREE'" + + " AND tenant_id = ? AND operation_id = ?", Integer.class, tenant, operation).isEmpty()) { + return row; + } + String blocker = cleanupBlocker(row); + if (!List.of("READY", "FAILED").contains(row.state()) || blocker != null) { + throw error(blocker == null ? "worktree_not_ready" : blocker); + } + createOperation(tenant, row.sessionId(), row.plan(), operation, "REMOVING", + json(Map.of("worktreeId", id, "actor", actor, "idempotencyKey", idempotencyKey))); + jdbc.update("UPDATE ma_session_worktree SET state = 'REMOVING', operation_id = ?, revision = revision + 1," + + " failure_code = NULL WHERE tenant_id = ? AND worktree_id = ?", operation, tenant, id); + return find(jdbc, tenant, row.sessionId()); + }); + } + + public String cleanupBlocker(Row row) { + if (!registry.accessOf(row.tenantId(), row.actor(), row.workspaceId()).canCreate()) { + return "worktree_forbidden"; + } + var states = jdbc.queryForList("SELECT status FROM ma_session WHERE tenant_id = ? AND session_id = ?", + String.class, row.tenantId(), row.sessionId()); + if (states.size() != 1 || !List.of("CLOSED", "ARCHIVED", "DELETED").contains(states.getFirst())) { + return "worktree_session_active"; + } + if (!jdbc.queryForList("SELECT 1 FROM ma_runtime_session WHERE tenant_id = ? AND harness_session_id = ?" + + " AND (session_state <> 'RELEASED' OR execution_storage_key IS NOT NULL)", Integer.class, + row.tenantId(), row.sessionId()).isEmpty()) { + return "worktree_runtime_busy"; + } + if (!jdbc.queryForList("SELECT 1 FROM ma_workspace WHERE tenant_id = ? AND parent_session_id = ?" + + " AND workspace_kind = 'CHILD_GIT' AND child_state IN ('preparing','ready','merging','applying','applied','discarding','blocked')", + Integer.class, row.tenantId(), row.sessionId()).isEmpty()) { + return "worktree_children_busy"; + } + if (!jdbc.queryForList("SELECT 1 FROM ma_operation WHERE tenant_id = ? AND session_id = ?" + + " AND operation_kind IN ('WORKSPACE_UPLOAD','CHILD_SESSION_START','CHILD_SESSION_CLOSE')" + + " AND completed_at IS NULL AND state NOT IN ('SUCCEEDED','FAILED','CANCELLED')", + Integer.class, row.tenantId(), row.sessionId()).isEmpty()) { + return "worktree_operations_busy"; + } + return null; + } + + private boolean lockClaim(Row row, String owner) { + return !jdbc.queryForList("SELECT 1 FROM ma_operation WHERE operation_key = ? AND claim_owner = ?" + + " AND claim_generation = ? AND claim_until > ? FOR UPDATE", Integer.class, + key(row), owner, row.generation(), System.currentTimeMillis()).isEmpty(); + } + + private static long blockIdentity(JdbcTemplate jdbc, Row row, String code) { + // The refused caller may roll back its own admission transaction. + var update = new TransactionTemplate(new org.springframework.jdbc.datasource.DataSourceTransactionManager( + java.util.Objects.requireNonNull(jdbc.getDataSource()))); + update.setPropagationBehavior(org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW); + return java.util.Objects.requireNonNull(update.execute(status -> { + jdbc.update("UPDATE ma_session_worktree SET state = 'BLOCKED', failure_code = ?, revision = revision + 1" + + " WHERE tenant_id = ? AND session_id = ? AND state = 'READY'", code, row.tenantId(), row.sessionId()); + return jdbc.queryForObject("SELECT revision FROM ma_session_worktree WHERE tenant_id = ? AND session_id = ?", + Long.class, row.tenantId(), row.sessionId()); + })); + } + + private String storageIdentity(ContextBinding binding) { + var alias = jdbc.queryForList("SELECT physical_key, registration_revision, mount_revision, fence_operation_id" + + " FROM ma_storage WHERE alias_key = ? FOR UPDATE", + com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageAliasKey(binding.getTenantId(), binding.getStorageId())); + var owner = jdbc.queryForList("SELECT mount_revision, mount_registration_id, mount_state, mount_operation_id" + + " FROM ma_storage_owner WHERE storage_key = ? FOR UPDATE", WorkspaceExecutionStore.storageKey(jdbc, binding)); + return org.apache.commons.codec.digest.DigestUtils.sha256Hex(json(List.of(alias, owner))); + } + + private static String key(Row row) { + return WorkspaceOperations.key(WorkspaceOperations.Kind.SESSION_WORKTREE, row.tenantId(), row.operationId()); + } + + public static ContextBinding storageBinding(Plan plan) { + return new ContextBinding(plan.tenantId(), plan.workspaceId(), plan.workspaceGeneration(), plan.storageId(), + ".", plan.contextConfigRef(), plan.contextRevision()); + } + + private static Row row(ResultSet result, int index) throws SQLException { + return readRow(result, ""); + } + + static Row readRow(ResultSet result, String prefix) throws SQLException { + try { + return new Row(result.getString(prefix + "tenant_id"), result.getString(prefix + "session_id"), result.getString(prefix + "workspace_id"), + result.getString(prefix + "worktree_id"), result.getString(prefix + "state"), JSON.readValue(result.getString(prefix + "plan_json"), Plan.class), + result.getString(prefix + "identity_json"), result.getString(prefix + "operation_id"), result.getString(prefix + "claim_owner"), + result.getLong(prefix + "claim_generation"), result.getObject(prefix + "claim_until") instanceof Number claim ? claim.longValue() : null, result.getString(prefix + "phase"), + result.getInt(prefix + "attempt_count"), result.getLong(prefix + "revision"), result.getString(prefix + "failure_code"), result.getString(prefix + "owner_actor_key")); + } catch (java.io.IOException exception) { + throw new IllegalStateException("Invalid Session worktree record", exception); + } + } + + private static String json(Object value) { + try { + return JSON.writeValueAsString(value); + } catch (java.io.IOException exception) { + throw new IllegalStateException("Invalid Session worktree identity", exception); + } + } + + private static ManagedAgentException error(String code) { + return SessionWorktreeGit.error(code, "The Session worktree cannot perform this operation (" + code + ")."); + } + + private static ManagedAgentException notFound() { + return new ManagedAgentException(HttpStatus.NOT_FOUND, "worktree_not_found", "Session worktree not found."); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationRoots.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationRoots.java index 8cfd31a909b..b984f0f14a6 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationRoots.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationRoots.java @@ -25,9 +25,11 @@ import java.util.Objects; import java.util.UUID; final class WorkspaceCreationRoots { + static final String IMPORTED_PREFIX = "imported-"; private final JdbcTemplate jdbc; private final Path base; private final Object baseKey; + private final Path stateDirectory; private final WorkspaceLocalDirectories localDirectories; private static final ObjectMapper JSON = JsonMapper.builder().build(); @@ -35,6 +37,8 @@ final class WorkspaceCreationRoots { this.jdbc = jdbc; localDirectories = new WorkspaceLocalDirectories(properties); var broker = properties.getRuntimeBroker(); + stateDirectory = StringUtils.isBlank(broker.getStateDirectory()) ? null + : Path.of(broker.getStateDirectory()).toAbsolutePath().normalize(); String configured = broker.getWorkspaceCreationRoot(); if (StringUtils.isBlank(configured)) { base = null; @@ -70,10 +74,10 @@ final class WorkspaceCreationRoots { } Root registration(String tenant, String storage, boolean pending) { - if (storage == null || !storage.matches("created-[a-f0-9]{64}")) { + if (storage == null || !storage.matches("(?:created|imported)-[a-f0-9]{64}")) { return null; } - String key = storage.substring("created-".length()); + String key = storage.substring(storage.indexOf('-') + 1); List> rows = jdbc.query( "SELECT receipt_key, tenant_id, scope_kind, scope_id, idempotency_key, state," + " result_json FROM ma_request_receipt WHERE receipt_key = ? AND namespace = ?" @@ -146,6 +150,31 @@ final class WorkspaceCreationRoots { } } + static boolean sharedStorage(String storage) { + return storage != null && storage.matches("imported-[a-f0-9]{64}"); + } + + Path importedMetadataDirectory(String storage, boolean create) { + if (!sharedStorage(storage) || stateDirectory == null) { + throw WorkspaceExecutionStore.unavailable(); + } + try { + directoryKey(stateDirectory); + Path imports = stateDirectory.resolve("imported-projects"); + Path metadata = imports.resolve(storage); + if (create) { + createDirectory(imports); + createDirectory(metadata); + } else { + directoryKey(imports); + directoryKey(metadata); + } + return metadata; + } catch (IOException error) { + throw WorkspaceExecutionStore.unavailable(error); + } + } + static Details details(String resultJson, String receiptKey) { try { Details value = JSON.readValue(resultJson, Details.class); @@ -154,7 +183,8 @@ final class WorkspaceCreationRoots { || value.displayName().length() > 128 || value.displayName().codePoints().anyMatch(Character::isISOControl) || !("workspace-" + receiptKey).equals(value.workspaceId()) - || value.sourceWorkspaceId() == null && (!("created-" + receiptKey).equals(value.storageId()) + || value.sourceWorkspaceId() == null && (!(("created-" + receiptKey).equals(value.storageId()) + || (IMPORTED_PREFIX + receiptKey).equals(value.storageId()) && value.localRoot() != null) || value.sourceGeneration() != null || value.localRoot() == null && value.cwdRelative() != null || value.localRoot() != null && (value.cwdRelative() == null diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStore.java index a7426a64eda..0bdfff6eab5 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStore.java @@ -128,7 +128,7 @@ public class WorkspaceCreationStore { Path root = localDirectories.root(project); String relative = new WorkspaceSelection("workspace-" + receiptKey, root.relativize(project).toString()).cwdRelative(); - details = new Details("workspace-" + receiptKey, "created-" + receiptKey, name, + details = new Details("workspace-" + receiptKey, WorkspaceCreationRoots.IMPORTED_PREFIX + receiptKey, name, UUID.randomUUID().toString(), null, null, relative, guard.identity(root)); } else { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java index 07b6c2e9bd1..a26568cada5 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventory.java @@ -257,6 +257,11 @@ final class WorkspaceCsiApplicationInventory { + "hook_occurrence_hash:CHAR,hook_ordinal:BIGINT,hook_definition_hash:CHAR,record_kind:VARCHAR," + "state:VARCHAR,action_id:VARBINARY,action_version:BIGINT,decision_receipt_id:VARBINARY," + "decision_digest:CHAR,prompt_id:VARBINARY,turn_id:VARBINARY,options_json:LONGTEXT"), + declaration("ma_session_worktree", "tenant_id,session_id", + "tenant_id:VARBINARY,session_id:VARBINARY,workspace_id:VARBINARY,storage_id:VARBINARY," + + "physical_key:CHAR,worktree_id:CHAR,owner_actor_key:VARBINARY,state:VARCHAR,revision:BIGINT," + + "plan_json:LONGTEXT,identity_json:LONGTEXT,operation_id:VARBINARY,failure_code:VARCHAR," + + "gmt_create:BIGINT,gmt_modified:BIGINT"), declaration("ma_snapshot", "tenant_id,session_id,snapshot_id", "tenant_id:VARBINARY,session_id:VARBINARY,snapshot_id:VARBINARY,snapshot_version:BIGINT," + "covered_sequence:BIGINT,projection_version:INT,manifest_resource_key:CHAR,manifest_sha256:CHAR," @@ -2348,6 +2353,8 @@ final class WorkspaceCsiApplicationInventory { case "ma_agent_definition" -> query(table, "tenant_id=T&agent_id=agent&revision=agentRevision"); case "ma_workspace_access", "ma_channel_instance", "ma_extension_operation", "ma_extension_package", "ma_extension_settings" -> query(table, "tenant_id=T&workspace_id=W"); + case "ma_session_worktree" -> query(table, "tenant_id=T&session_id=sessions", + "tenant_id=T&workspace_id=W", "physical_key=F"); case "ma_workspace" -> query(table, "tenant_id=T&workspace_id=W", "tenant_id=T&storage_id=D", "tenant_id=T&parent_session_id=sessions"); case "ma_storage" -> query(table, "alias_key=A", "physical_key=F", "tenant_id=T&storage_id=D"); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceExecutionStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceExecutionStore.java index 6388173e7fa..8266b3789fa 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceExecutionStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceExecutionStore.java @@ -213,6 +213,9 @@ public class WorkspaceExecutionStore { binding.getContextConfigRef())) { throw unavailable(); } + if (!cancellation && !committedStop && authority == null && !legacyClose) { + requireWorktreeAncestorsReady(jdbc, session.tenantId(), session.sessionId()); + } var resident = residents.find(binding); if (resident == null) { WorkspaceStorageKindGuard.requireExecutionAlias(jdbc, binding.getTenantId(), binding.getStorageId()); @@ -316,6 +319,24 @@ public class WorkspaceExecutionStore { } } + private static void requireWorktreeAncestorsReady(JdbcTemplate jdbc, String tenantId, String sessionId) { + java.util.Set visited = new java.util.HashSet<>(); + String current = sessionId; + while (visited.add(current)) { + SessionWorktreeStore.requireReady(jdbc, tenantId, current); + var parents = jdbc.query("SELECT parent_session_id FROM ma_session WHERE tenant_id = ? AND session_id = ?", + (row, index) -> row.getString(1), tenantId, current); + if (parents.size() != 1) { + throw unavailable(); + } + if (parents.getFirst() == null) { + return; + } + current = parents.getFirst(); + } + throw unavailable(); + } + static byte[] inheritedExecutionActor(JdbcTemplate jdbc, ContextBinding binding, String sessionId) { java.util.Set visited = new java.util.HashSet<>(); String current = sessionId; @@ -433,6 +454,9 @@ public class WorkspaceExecutionStore { } public boolean beginRewind(ContextBinding binding, RuntimeSessionRecord session) { + if (storageGuard != null) { + storageGuard.requireOwnedRoot(binding.getTenantId(), binding.getStorageId()); + } return Boolean.TRUE.equals(transaction.execute(status -> { WorkspaceStorageKindGuard.lockDomain(jdbc, binding.getTenantId()); String key = storageKey(binding); @@ -562,8 +586,9 @@ public class WorkspaceExecutionStore { WorkspaceStorageKindGuard.requireLocalAlias(jdbc, binding.getTenantId(), binding.getStorageId()); String key = storageKey(binding); WorkspaceMigrationAdmission.requireOpen(jdbc, binding.getTenantId(), binding.getStorageId()); - List generations = jdbc.queryForList("SELECT claim_generation FROM ma_operation WHERE operation_key = ? AND operation_kind = 'CHILD_WORKSPACE' AND claim_until > " + ManagedSchemaTime.SQL_NOW + " FOR UPDATE", - Long.class, WorkspaceOperations.key(WorkspaceOperations.Kind.CHILD_WORKSPACE, binding.getTenantId(), maintenanceId)); + WorkspaceOperations.Kind kind = WorkspaceMaintenanceRecovery.kind(jdbc, binding, maintenanceId); + List generations = jdbc.queryForList("SELECT claim_generation FROM ma_operation WHERE operation_key = ? AND operation_kind = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + " FOR UPDATE", + Long.class, WorkspaceOperations.key(kind, binding.getTenantId(), maintenanceId), kind.name()); if (generations.size() != 1 || generations.getFirst() != claimGeneration) { throw unavailable(); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStore.java index debcb34da5d..00cfa0f3c2f 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStore.java @@ -56,11 +56,31 @@ public class WorkspaceFileStore { String text, int byteLength, String sha256) { } + public record Locator(String sessionId, String workspaceId, String cwdRelative) { + public Locator { + if (sessionId != null) { + if (!sessionId.matches("[A-Za-z0-9._:-]{1,128}") || workspaceId != null || cwdRelative != null) { + throw invalidLocator(); + } + } else { + if (workspaceId == null || cwdRelative == null) { + throw invalidLocator(); + } + cwdRelative = new WorkspaceSelection(workspaceId, cwdRelative).cwdRelative(); + } + } + } + public QueryResult query(String tenant, String actor, String workspace, String cwd, String directory, String query, String revision) { + return query(tenant, actor, new Locator(null, workspace, cwd), directory, query, revision); + } + + public QueryResult query(String tenant, String actor, Locator locator, + String directory, String query, String revision) { Map request = Map.of("protocolVersion", 1, "operation", "query", "directory", directory, "query", query); - var result = exchange(tenant, actor, workspace, cwd, revision, request); + var result = exchange(tenant, actor, locator, revision, request); try { Entry[] entries = mapper.convertValue(result.files().get("entries"), Entry[].class); if (entries == null || entries.length > 50 || java.util.Arrays.stream(entries).anyMatch(entry -> entry == null @@ -77,7 +97,11 @@ public class WorkspaceFileStore { } public FileResult read(String tenant, String actor, String workspace, String cwd, String path, String revision) { - var result = exchange(tenant, actor, workspace, cwd, revision, + return read(tenant, actor, new Locator(null, workspace, cwd), path, revision); + } + + public FileResult read(String tenant, String actor, Locator locator, String path, String revision) { + var result = exchange(tenant, actor, locator, revision, Map.of("protocolVersion", 1, "operation", "read", "path", path)); var file = result.files(); try { @@ -94,15 +118,15 @@ public class WorkspaceFileStore { } } - record Scope(ContextBinding binding, String kind, String revision) { + record Scope(ContextBinding binding, String kind, String revision, Map sessionFileScope) { } private record Exchange(String revision, Map files) { } - private Exchange exchange(String tenant, String actor, String workspace, String cwd, + private Exchange exchange(String tenant, String actor, Locator locator, String expectedRevision, Map files) { - Scope before = scope(tenant, actor, workspace, cwd); + Scope before = scope(tenant, actor, locator); if (expectedRevision != null && !expectedRevision.equals(before.revision())) { throw changed(); } @@ -124,6 +148,9 @@ public class WorkspaceFileStore { request.put("allowedRoots", List.of(root.get("root"))); request.put("cwdRelative", before.binding().getCwdRelative()); request.put("localRootIdentity", Map.of("device", root.get("device"), "inode", root.get("inode"))); + if (before.sessionFileScope() != null) { + request.put("sessionFileScope", before.sessionFileScope()); + } request.put("files", files); result = WorkspaceFileProcess.read(properties.getRuntimeBroker().getNodeExecutable(), properties.getRuntimeBroker().getWorkerEntry(), request); @@ -133,7 +160,7 @@ public class WorkspaceFileStore { } else { throw new ManagedAgentException(HttpStatus.CONFLICT, "workspace_files_unsupported", "This Workspace storage does not support file references."); } - Scope after = scope(tenant, actor, workspace, cwd); + Scope after = scope(tenant, actor, locator); if (!before.revision().equals(after.revision())) { throw changed(); } @@ -149,11 +176,42 @@ public class WorkspaceFileStore { } Scope scope(String tenant, String actor, String workspace, String cwd) { + return scope(tenant, actor, new Locator(null, workspace, cwd)); + } + + Scope scope(String tenant, String actor, Locator locator) { return Objects.requireNonNull(transaction.execute(status -> { - var binding = registry.resolveForCreation(tenant, actor, new WorkspaceSelection(workspace, cwd)).binding(); + ContextBinding binding = locator.sessionId() == null + ? registry.resolveForCreation(tenant, actor, new WorkspaceSelection(locator.workspaceId(), locator.cwdRelative())).binding() + : sessionBinding(tenant, actor, locator.sessionId()); if (!properties.getHarness().isWorkspaceFilesEnabled()) { throw new ManagedAgentException(HttpStatus.CONFLICT, "workspace_files_disabled", "Workspace file access is disabled."); } + Map sessionFileScope = null; + long worktreeRevision = 0; + if (locator.sessionId() != null) { + SessionWorktreeStore.requireReady(jdbc, tenant, locator.sessionId()); + var worktree = SessionWorktreeStore.descriptor(jdbc, tenant, locator.sessionId()); + if (worktree != null) { + String prefix = ".qwen-managed-worktrees/" + worktree.worktreeId(); + if (!binding.getCwdRelative().equals(prefix) && !binding.getCwdRelative().startsWith(prefix + "/")) { + throw unavailable(); + } + worktreeRevision = worktree.revision(); + var root = guard.fileRoot(binding); + try { + var identity = java.nio.file.Files.readAttributes(java.nio.file.Path.of((String) root.get("root")) + .resolve(".qwen-managed-worktrees").resolve(worktree.worktreeId()), "unix:dev,ino", java.nio.file.LinkOption.NOFOLLOW_LINKS); + SessionWorktreeStore.requireReady(jdbc, tenant, locator.sessionId()); + sessionFileScope = Map.of("sessionId", locator.sessionId(), "contextDigest", binding.getContextDigest(), + "worktreeId", worktree.worktreeId(), "worktreeRevision", worktreeRevision, "cwdRelative", binding.getCwdRelative(), + "worktreeRootIdentity", Map.of("device", Long.toUnsignedString(((Number) identity.get("dev")).longValue()), + "inode", Long.toUnsignedString(((Number) identity.get("ino")).longValue()))); + } catch (java.io.IOException error) { + throw unavailable(); + } + } + } var rows = jdbc.queryForList("SELECT storage_kind, registration_json, registration_revision, mount_revision, fence_operation_id FROM ma_storage WHERE alias_key = ? FOR UPDATE", JdbcRuntimeBindingRepository.storageAliasKey(tenant, binding.getStorageId())); if (rows.size() != 1 || rows.getFirst().get("fence_operation_id") != null) { @@ -163,15 +221,42 @@ public class WorkspaceFileStore { + " WHERE storage_key = (SELECT physical_key FROM ma_storage WHERE alias_key = ?)", JdbcRuntimeBindingRepository.storageAliasKey(tenant, binding.getStorageId())); try { - String revision = DigestUtils.sha256Hex(mapper.writeValueAsBytes(List.of(actor, - binding.getContextDigest(), rows, owner))); - return new Scope(binding, (String) rows.getFirst().get("storage_kind"), revision); + var identity = locator.sessionId() == null ? List.of(actor, binding.getContextDigest(), rows, owner) + : List.of(actor, binding.getContextDigest(), rows, owner, locator.sessionId(), worktreeRevision); + String revision = DigestUtils.sha256Hex(mapper.writeValueAsBytes(identity)); + return new Scope(binding, (String) rows.getFirst().get("storage_kind"), revision, sessionFileScope); } catch (com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); } })); } + private ContextBinding sessionBinding(String tenant, String actor, String sessionId) { + var bindings = jdbc.query("SELECT * FROM ma_session WHERE tenant_id = ? AND session_id = ?" + + " AND status <> 'DELETED' FOR UPDATE", + (result, row) -> ManagedAgentStore.readBinding(result), tenant, sessionId); + if (bindings.size() != 1 || bindings.getFirst() == null + || !registry.accessOf(tenant, actor, bindings.getFirst().getWorkspaceId()).canCreate()) { + throw sessionNotFound(); + } + var binding = bindings.getFirst(); + if (jdbc.queryForList("SELECT 1 FROM ma_workspace WHERE tenant_id = ? AND workspace_id = ? AND state = 'ACTIVE'" + + " AND workspace_generation = ? AND storage_id = ?", Integer.class, tenant, binding.getWorkspaceId(), + binding.getWorkspaceGeneration(), binding.getStorageId()).isEmpty()) { + throw unavailable(); + } + registry.requireSshNodeAccess(tenant, actor, binding.getStorageId()); + return binding; + } + + private static ManagedAgentException invalidLocator() { + return new ManagedAgentException(HttpStatus.BAD_REQUEST, "invalid_request", "Select either a Session or a Workspace directory."); + } + + private static ManagedAgentException sessionNotFound() { + return new ManagedAgentException(HttpStatus.NOT_FOUND, "session_not_found", "Session not found."); + } + private static ManagedAgentException changed() { return new ManagedAgentException(HttpStatus.CONFLICT, "workspace_file_scope_changed", "Workspace changed; select the file again."); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStore.java index 8b71b32c9c9..7b1139e834f 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStore.java @@ -4,7 +4,6 @@ import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.workspace.SshWorkspaceService; import com.alibaba.qwen.code.managedagent.workspace.SshWorkspaceService.FileTarget; -import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; import com.alibaba.qwen.code.runtimebroker.BrokerValues; import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; @@ -77,7 +76,7 @@ public class WorkspaceFileUploadStore { int sizeBytes, String sha256, String code) { } - private record Frozen(String tenant, String actor, String workspace, String cwd, String revision, + private record Frozen(String tenant, String actor, String workspace, String cwd, String revision, String session, String uploadId, String path, int sizeBytes, String sha256, String physical, String node, String entry, Map request, FileTarget remote) { } @@ -87,10 +86,14 @@ public class WorkspaceFileUploadStore { public UploadResult upload(String tenant, String actor, String workspace, String cwd, String path, String revision, String uploadId, InputStream input, long contentLength) { + return upload(tenant, actor, new WorkspaceFileStore.Locator(null, workspace, cwd), path, revision, uploadId, input, contentLength); + } + + public UploadResult upload(String tenant, String actor, WorkspaceFileStore.Locator locator, String path, + String revision, String uploadId, InputStream input, long contentLength) { validate(path, revision, uploadId); - cwd = new WorkspaceSelection(workspace, cwd).cwdRelative(); - if (!".".equals(cwd)) { - validate(cwd, revision, uploadId); + if (locator.sessionId() == null && !".".equals(locator.cwdRelative())) { + validate(locator.cwdRelative(), revision, uploadId); } if (!slots.tryAcquire()) { throw busy(); @@ -99,7 +102,7 @@ public class WorkspaceFileUploadStore { try { Operation saved = find(tenant, uploadId, false); if (saved == null) { - WorkspaceFileStore.Scope scope = files.scope(tenant, actor, workspace, cwd); + WorkspaceFileStore.Scope scope = files.scope(tenant, actor, locator); if (!revision.equals(scope.revision())) { throw changed(); } @@ -107,9 +110,10 @@ public class WorkspaceFileUploadStore { authorize(saved, actor); } byte[] bytes = read(input, contentLength); - String digest = digest(List.of(actor, workspace, cwd, path, revision, uploadId, - bytes.length, DigestUtils.sha256Hex(bytes))); - Operation operation = admit(tenant, actor, workspace, cwd, path, revision, uploadId, + String digest = digest(locator.sessionId() == null + ? List.of(actor, locator.workspaceId(), locator.cwdRelative(), path, revision, uploadId, bytes.length, DigestUtils.sha256Hex(bytes)) + : List.of(actor, List.of("session", locator.sessionId()), path, revision, uploadId, bytes.length, DigestUtils.sha256Hex(bytes))); + Operation operation = admit(tenant, actor, locator, path, revision, uploadId, bytes.length, DigestUtils.sha256Hex(bytes), digest); running.add(key); UploadStatus result = operation.result(); @@ -212,7 +216,7 @@ public class WorkspaceFileUploadStore { } } - private Operation admit(String tenant, String actor, String workspace, String cwd, String path, + private Operation admit(String tenant, String actor, WorkspaceFileStore.Locator locator, String path, String revision, String uploadId, int size, String sha256, String digest) { return transaction.execute(status -> { WorkspaceStorageKindGuard.lockDomain(jdbc, tenant); @@ -224,7 +228,7 @@ public class WorkspaceFileUploadStore { } return previous; } - var scope = files.scope(tenant, actor, workspace, cwd); + var scope = files.scope(tenant, actor, locator); if (!revision.equals(scope.revision())) { throw changed(); } @@ -244,24 +248,30 @@ public class WorkspaceFileUploadStore { } Map request = null; FileTarget remote = null; + String workspace = scope.binding().getWorkspaceId(); + String cwd = scope.binding().getCwdRelative(); if ("LOCAL".equals(scope.kind())) { var root = guard.fileRoot(scope.binding()); - request = Map.of("protocolVersion", 1, "operation", "probe", "nodeId", "local-files", + request = new LinkedHashMap<>(Map.of("protocolVersion", 1, "operation", "probe", "nodeId", "local-files", "stateDirectory", properties.getRuntimeBroker().getStateDirectory(), "root", root.get("root"), "allowedRoots", List.of(root.get("root")), "cwdRelative", cwd, - "localRootIdentity", Map.of("device", root.get("device"), "inode", root.get("inode"))); + "localRootIdentity", Map.of("device", root.get("device"), "inode", root.get("inode")))); + if (scope.sessionFileScope() != null) { + request.put("sessionFileScope", scope.sessionFileScope()); + } } else { remote = ssh.freezeFiles(scope.binding()); } - Frozen target = new Frozen(tenant, actor, workspace, cwd, revision, uploadId, path, size, sha256, physical, + Frozen target = new Frozen(tenant, actor, workspace, cwd, revision, locator.sessionId(), uploadId, path, size, sha256, physical, properties.getRuntimeBroker().getNodeExecutable(), properties.getRuntimeBroker().getWorkerEntry(), request, remote); String key = key(tenant, uploadId); - jdbc.update("INSERT INTO ma_operation (operation_key, scope_key, operation_kind, operation_id, tenant_id, workspace_id," + jdbc.update("INSERT INTO ma_operation (operation_key, scope_key, operation_kind, operation_id, tenant_id, workspace_id, session_id," + " target_kind, target_key, target_id, storage_key, physical_key, actor_key, request_digest, request_json, state, phase," - + " available_at, gmt_create, gmt_modified) VALUES (?, ?, ?, ?, ?, ?, 'STORAGE', ?, ?, ?, ?, ?, ?, ?, 'RUNNING', 'RUNNING', " + + " available_at, gmt_create, gmt_modified) VALUES (?, ?, ?, ?, ?, ?, ?, 'STORAGE', ?, ?, ?, ?, ?, ?, ?, 'RUNNING', 'RUNNING', " + ManagedSchemaTime.SQL_NOW + " + 60000, " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", key, ManagedSchemaKeys.key(tenant), KIND, uploadId.getBytes(StandardCharsets.UTF_8), tenant.getBytes(StandardCharsets.UTF_8), - workspace.getBytes(StandardCharsets.UTF_8), ManagedSchemaKeys.key(scope.binding().getStorageId()), + workspace.getBytes(StandardCharsets.UTF_8), locator.sessionId() == null ? null : locator.sessionId().getBytes(StandardCharsets.UTF_8), + ManagedSchemaKeys.key(scope.binding().getStorageId()), scope.binding().getStorageId().getBytes(StandardCharsets.UTF_8), JdbcRuntimeBindingRepository.storageAliasKey(tenant, scope.binding().getStorageId()), physical, ManagedWorkspaceRegistry.actorKey(tenant, actor), digest, encode(target)); @@ -356,7 +366,10 @@ public class WorkspaceFileUploadStore { private void authorize(Operation operation, String actor) { if (!operation.target().actor().equals(actor) - || !registry.accessOf(operation.target().tenant(), actor, operation.target().workspace()).canCreate()) { + || !registry.accessOf(operation.target().tenant(), actor, operation.target().workspace()).canCreate() + || operation.target().session() != null + && jdbc.queryForList("SELECT 1 FROM ma_session WHERE tenant_id = ? AND session_id = ? AND status <> 'DELETED'", + Integer.class, operation.target().tenant(), operation.target().session()).isEmpty()) { throw notFound(); } } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMaintenanceRecovery.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMaintenanceRecovery.java index 2435eb775cb..406d57b71c9 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMaintenanceRecovery.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMaintenanceRecovery.java @@ -21,7 +21,6 @@ public final class WorkspaceMaintenanceRecovery { .enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_LONG_FOR_INTS) .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) .enable(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION); - private static final WorkspaceOperations.Kind KIND = WorkspaceOperations.Kind.CHILD_WORKSPACE; private static final String WORK_KIND = "RECOVERY_EVIDENCE"; private static final String[] MOUNT_FIELDS = {"storage_key", "physical_key", "mount_revision", "mount_root", "mount_host_id", "mount_device", "mount_inode", "mount_birth_time", "mount_registration_id"}; @@ -43,8 +42,12 @@ public final class WorkspaceMaintenanceRecovery { throw blocked(); } var owner = owner(jdbc, binding, true); - var child = WorkspaceOperations.rows(jdbc, "SELECT parent_session_id FROM ma_workspace WHERE tenant_id = ?" - + " AND child_workspace_id = ? AND workspace_kind = 'CHILD_GIT'", binding.getTenantId(), maintenance); + var operationKind = kind(jdbc, binding, maintenance); + var child = operationKind == WorkspaceOperations.Kind.CHILD_WORKSPACE + ? WorkspaceOperations.rows(jdbc, "SELECT parent_session_id FROM ma_workspace WHERE tenant_id = ?" + + " AND child_workspace_id = ? AND workspace_kind = 'CHILD_GIT'", binding.getTenantId(), maintenance) + : WorkspaceOperations.rows(jdbc, "SELECT session_id AS parent_session_id FROM ma_operation WHERE tenant_id = ?" + + " AND operation_id = ? AND operation_kind = ?", binding.getTenantId(), maintenance, operationKind.name()); if (owner == null || child.size() != 1 || !maintenance.equals(owner.get("maintenance_id")) || !(owner.get("maintenance_generation") instanceof Number saved) || saved.longValue() != generation) { throw blocked(); @@ -52,21 +55,21 @@ public final class WorkspaceMaintenanceRecovery { String session = (String) child.getFirst().get("parent_session_id"); String execution = UUID.randomUUID().toString(); String step = stepTarget(maintenance, generation, execution); - Map identity = WorkspaceOperations.values("schemaVersion", 1, "kind", "child-git-maintenance", + Map identity = WorkspaceOperations.values("schemaVersion", 1, "kind", operationKind == WorkspaceOperations.Kind.CHILD_WORKSPACE ? "child-git-maintenance" : "session-git-maintenance", "maintenanceId", maintenance, "generation", generation, "executionId", execution, "tenantId", binding.getTenantId(), "workspaceId", binding.getWorkspaceId(), "workspaceGeneration", binding.getWorkspaceGeneration(), "storageId", binding.getStorageId(), "holderKey", owner.get("holder_key"), "mount", mount(owner), "host", nativeHost()); byte[] bytes = bytes(identity); String resource = WorkspaceEvidenceResources.publish(jdbc, binding.getTenantId(), binding.getWorkspaceId(), session, step, bytes); - operations.insertWork(KIND, maintenance, WORK_KIND, step, maintenance + ":" + generation + ":" + execution, session, + operations.insertWork(operationKind, maintenance, WORK_KIND, step, maintenance + ":" + generation + ":" + execution, session, WorkspaceOperations.values("execution_id", execution, "evidence_resource_key", resource, "evidence_sha256", WorkspaceEvidenceResources.digest(bytes)), "EXECUTING"); var pointer = WorkspaceOperations.values("execution_id", execution, "step_target", step); - if (operations.findWork(KIND, maintenance, WORK_KIND, slot, true) == null) { - operations.insertWork(KIND, maintenance, WORK_KIND, slot, maintenance + ":" + generation, session, pointer, "INDEX"); + if (operations.findWork(operationKind, maintenance, WORK_KIND, slot, true) == null) { + operations.insertWork(operationKind, maintenance, WORK_KIND, slot, maintenance + ":" + generation, session, pointer, "INDEX"); } else { - operations.updateWork(KIND, maintenance, WORK_KIND, slot, pointer); + operations.updateWork(operationKind, maintenance, WORK_KIND, slot, pointer); } return execution; } @@ -76,14 +79,14 @@ public final class WorkspaceMaintenanceRecovery { if (work == null || !"EXECUTING".equals(work.get("state")) || !execution.equals(work.get("execution_id"))) { return false; } - new WorkspaceOperations(jdbc, binding.getTenantId()).updateWork(KIND, maintenance, WORK_KIND, + new WorkspaceOperations(jdbc, binding.getTenantId()).updateWork(kind(jdbc, binding, maintenance), maintenance, WORK_KIND, stepTarget(maintenance, generation, execution), Map.of("state", "RETURNED")); return true; } private static Map currentStep(JdbcTemplate jdbc, ContextBinding binding, String maintenance, long generation, boolean lock) { var operations = new WorkspaceOperations(jdbc, binding.getTenantId()); - var slot = operations.findWork(KIND, maintenance, WORK_KIND, target(maintenance, generation), lock); + var slot = operations.findWork(kind(jdbc, binding, maintenance), maintenance, WORK_KIND, target(maintenance, generation), lock); if (slot == null) { return null; } @@ -92,7 +95,12 @@ public final class WorkspaceMaintenanceRecovery { || !stepTarget(maintenance, generation, execution).equals(slot.get("step_target"))) { throw blocked(); } - return operations.findWork(KIND, maintenance, WORK_KIND, (String) slot.get("step_target"), lock); + return operations.findWork(kind(jdbc, binding, maintenance), maintenance, WORK_KIND, (String) slot.get("step_target"), lock); + } + + static boolean stopped(JdbcTemplate jdbc, ContextBinding binding, String maintenance, long generation) { + var step = currentStep(jdbc, binding, maintenance, generation, true); + return step != null && "STOPPED".equals(step.get("state")); } public Map inspect(ContextBinding binding, String maintenance, long generation) { @@ -101,7 +109,7 @@ public final class WorkspaceMaintenanceRecovery { throw blocked(); } Map original = identity(work); - validateIdentity(binding, maintenance, generation, original); + validateIdentity(binding, maintenance, generation, original, kind(jdbc, binding, maintenance)); Map result = new LinkedHashMap<>(original); result.put("state", work.get("state")); result.put("recoveryBlocker", "EXECUTING".equals(work.get("state")) ? "maintenance_stop_unverified" : null); @@ -117,12 +125,12 @@ public final class WorkspaceMaintenanceRecovery { return transaction.execute(status -> { WorkspaceStorageKindGuard.lockDomain(jdbc, binding.getTenantId()); var operations = new WorkspaceOperations(jdbc, binding.getTenantId()); - var work = operations.findWork(KIND, maintenance, WORK_KIND, stepTarget(maintenance, generation, executionId), true); + var work = operations.findWork(kind(jdbc, binding, maintenance), maintenance, WORK_KIND, stepTarget(maintenance, generation, executionId), true); if (work == null) { throw blocked(); } var original = identity(work); - validateIdentity(binding, maintenance, generation, original); + validateIdentity(binding, maintenance, generation, original, kind(jdbc, binding, maintenance)); if (!executionId.equals(original.get("executionId"))) { throw blocked(); } @@ -147,7 +155,7 @@ public final class WorkspaceMaintenanceRecovery { throw blocked(); } guard.verifyMaintenanceRecovery(binding); - var operation = operations.find(KIND, maintenance, true); + var operation = operations.find(kind(jdbc, binding, maintenance), maintenance, true); if (operation == null) { throw blocked(); } @@ -156,13 +164,13 @@ public final class WorkspaceMaintenanceRecovery { "original", original, "observedHost", observedHost)); String session = (String) work.get("session_id"); String resource = WorkspaceEvidenceResources.publish(jdbc, binding.getTenantId(), binding.getWorkspaceId(), session, proofTarget, proof); - operations.insertWork(KIND, maintenance, WORK_KIND, proofTarget, maintenance + ":" + generation + ":" + executionId + ":stopped", session, + operations.insertWork(kind(jdbc, binding, maintenance), maintenance, WORK_KIND, proofTarget, maintenance + ":" + generation + ":" + executionId + ":stopped", session, WorkspaceOperations.values("evidence_resource_key", resource, "evidence_sha256", WorkspaceEvidenceResources.digest(proof)), "STOPPED"); - operations.updateWork(KIND, maintenance, WORK_KIND, stepTarget(maintenance, generation, executionId), Map.of("state", "STOPPED")); + operations.updateWork(kind(jdbc, binding, maintenance), maintenance, WORK_KIND, stepTarget(maintenance, generation, executionId), Map.of("state", "STOPPED")); if (jdbc.update("UPDATE ma_storage_owner SET holder_key = NULL, maintenance_id = NULL, maintenance_generation = NULL" + " WHERE storage_key = ? AND maintenance_id = ? AND maintenance_generation = ? AND holder_key = ?", WorkspaceExecutionStore.storageKey(jdbc, binding), maintenance, generation, original.get("holderKey")) != 1 - || jdbc.update("UPDATE ma_operation SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_owner = NULL, claim_until = NULL WHERE operation_key = ? AND claim_generation = ?", operations.key(KIND, maintenance), generation) != 1) { + || jdbc.update("UPDATE ma_operation SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_owner = NULL, claim_until = NULL WHERE operation_key = ? AND claim_generation = ?", operations.key(kind(jdbc, binding, maintenance), maintenance), generation) != 1) { throw blocked(); } return inspect(binding, maintenance, generation); @@ -177,9 +185,9 @@ public final class WorkspaceMaintenanceRecovery { } } - private static void validateIdentity(ContextBinding binding, String maintenance, long generation, Map original) { + private static void validateIdentity(ContextBinding binding, String maintenance, long generation, Map original, WorkspaceOperations.Kind kind) { if (!(original.get("schemaVersion") instanceof Number version) || version.intValue() != 1 - || !"child-git-maintenance".equals(original.get("kind")) + || !(kind == WorkspaceOperations.Kind.CHILD_WORKSPACE ? "child-git-maintenance" : "session-git-maintenance").equals(original.get("kind")) || !(original.get("executionId") instanceof String execution) || execution.isBlank() || !binding.getTenantId().equals(original.get("tenantId")) || !binding.getStorageId().equals(original.get("storageId")) || !binding.getWorkspaceId().equals(original.get("workspaceId")) @@ -191,6 +199,15 @@ public final class WorkspaceMaintenanceRecovery { } } + static WorkspaceOperations.Kind kind(JdbcTemplate jdbc, ContextBinding binding, String maintenance) { + var kinds = jdbc.queryForList("SELECT operation_kind FROM ma_operation WHERE tenant_id = ? AND operation_id = ?" + + " AND operation_kind IN ('CHILD_WORKSPACE','SESSION_WORKTREE')", String.class, binding.getTenantId(), maintenance); + if (kinds.size() != 1) { + throw blocked(); + } + return WorkspaceOperations.Kind.valueOf(kinds.getFirst()); + } + private static Map owner(JdbcTemplate jdbc, ContextBinding binding, boolean lock) { var rows = WorkspaceOperations.rows(jdbc, "SELECT " + ManagedSchemaProjection.STORAGE_OWNER.columns() + " FROM ma_storage_owner WHERE storage_key = ? AND storage_kind = 'LOCAL'" + (lock ? " FOR UPDATE" : ""), WorkspaceExecutionStore.storageKey(jdbc, binding)); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationStore.java index 0237c31efd7..b2fe93792cf 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationStore.java @@ -365,6 +365,10 @@ public final class WorkspaceMigrationStore { check(count("SELECT COUNT(*) FROM ma_workspace WHERE workspace_kind = 'CHILD_GIT' AND tenant_id = ?" + " AND storage_id = ? AND child_state NOT IN ('merged', 'discarded', 'failed', 'conflicted')") == 0, "migration_work_unsettled"); + check(jdbc.queryForObject("SELECT COUNT(*) FROM ma_session_worktree WHERE physical_key IN" + + " (SELECT physical_key FROM ma_storage WHERE alias_key = ?) AND state <> 'REMOVED'", Integer.class, + com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageAliasKey(tenant, storage)) == 0, + "migration_work_unsettled"); long now = jdbc.queryForObject("SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", (row, index) -> Math.addExact(Math.multiplyExact(row.getLong(1), 1000), row.getLong(2) / 1000)); String after = ""; diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceOperations.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceOperations.java index 7fa04a4275b..4788265ede6 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceOperations.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceOperations.java @@ -16,7 +16,7 @@ import java.util.Map; final class WorkspaceOperations { enum Kind { - WORKSPACE_RECOVERY, WORKSPACE_OPERATOR_RECOVERY, STORAGE_MIGRATION, CSI_RETIREMENT, WORKSPACE_RETIREMENT, CHILD_WORKSPACE + WORKSPACE_RECOVERY, WORKSPACE_OPERATOR_RECOVERY, STORAGE_MIGRATION, CSI_RETIREMENT, WORKSPACE_RETIREMENT, CHILD_WORKSPACE, SESSION_WORKTREE } private static final ObjectMapper JSON = new ObjectMapper(); @@ -63,7 +63,7 @@ final class WorkspaceOperations { com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository.storageAliasKey(tenant, storage)); if (aliases.size() == 1 && "LOCAL".equals(aliases.getFirst())) { physical = LocalStorageOwners.key(jdbc, tenant, storage); - if (kind == Kind.CHILD_WORKSPACE) { + if (kind == Kind.CHILD_WORKSPACE || kind == Kind.SESSION_WORKTREE) { WorkspaceMigrationAdmission.requireOpen(jdbc, tenant, storage); } } @@ -179,6 +179,7 @@ final class WorkspaceOperations { ChildWorkspaceStore.MERGING, ChildWorkspaceStore.APPLYING, ChildWorkspaceStore.APPLIED, ChildWorkspaceStore.MERGED, ChildWorkspaceStore.CONFLICTED, ChildWorkspaceStore.DISCARDING, ChildWorkspaceStore.DISCARDED, ChildWorkspaceStore.FAILED, ChildWorkspaceStore.BLOCKED); + case SESSION_WORKTREE -> java.util.Set.of("PREPARING", "READY", "FAILED", "BLOCKED", "REMOVING", "REMOVED"); }; if (!allowed.contains(state)) { throw new IllegalArgumentException("Unknown state for Workspace operation " + kind); @@ -201,7 +202,7 @@ final class WorkspaceOperations { || "PREPARED".equals(from) && "COMPLETED".equals(to)); case WORKSPACE_RETIREMENT -> "DRAINING".equals(from) && "PLATFORM_CLEANED".equals(to) || "PLATFORM_CLEANED".equals(from) && "RELEASED".equals(to); - case CSI_RETIREMENT, CHILD_WORKSPACE -> false; + case CSI_RETIREMENT, CHILD_WORKSPACE, SESSION_WORKTREE -> false; }; if (!allowed) { throw new IllegalStateException("Workspace operation transition conflicts"); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceStorageGuard.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceStorageGuard.java index 223534a205f..3feadb96fe2 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceStorageGuard.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceStorageGuard.java @@ -2,6 +2,7 @@ package com.alibaba.qwen.code.managedagent.persistence; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; @@ -118,7 +119,7 @@ public class WorkspaceStorageGuard { } String markerStatus = "match"; try { - if (!marker(row).equals(readMarker(root))) { + if (!marker(row).equals(readMarker(markerRoot(marker(row), root, false)))) { markerStatus = "mismatch"; } } catch (RuntimeException error) { @@ -145,6 +146,7 @@ public class WorkspaceStorageGuard { public RecoveryRegistration recoveryRegistration(String tenantId, String storageId, long revision, String operationId) { + requireOwnedRoot(tenantId, storageId); if (!enabled || !validId(operationId)) { throw WorkspaceExecutionStore.unavailable(); } @@ -164,6 +166,7 @@ public class WorkspaceStorageGuard { } void verifyCapturedRegistration(RecoveryRegistration expected) { + requireOwnedRoot(expected.tenantId(), expected.storageId()); authorityRead(() -> { WorkspaceStorageKindGuard.lockDomain(jdbc, expected.tenantId()); String physical = LocalStorageOwners.lockCovered(jdbc, expected.tenantId(), expected.storageId()); @@ -316,13 +319,14 @@ public class WorkspaceStorageGuard { throw WorkspaceExecutionStore.unavailable(); } Marker marker = marker(prepared); + Path metadataRoot = markerRoot(marker, root, !"READY".equals(prepared.state())); if ("READY".equals(prepared.state())) { requireMatching(prepared, identity(root), tenantId, storageId); - if (!marker.equals(readMarker(root))) { + if (!marker.equals(readMarker(metadataRoot))) { throw WorkspaceExecutionStore.unavailable(); } } else { - publishMarker(root, marker); + publishMarker(metadataRoot, marker); } transaction.executeWithoutResult(status -> { WorkspaceStorageKindGuard.lockDomain(jdbc, tenantId); @@ -335,7 +339,7 @@ public class WorkspaceStorageGuard { throw WorkspaceExecutionStore.unavailable(); } requireMatching(row, identity(root), tenantId, storageId); - if (!marker.equals(marker(row)) || !marker.equals(readMarker(root))) { + if (!marker.equals(marker(row)) || !marker.equals(readMarker(metadataRoot))) { throw WorkspaceExecutionStore.unavailable(); } if ("READY".equals(row.state()) && row.operationId() == null && row.revision() > 0) { @@ -384,6 +388,7 @@ public class WorkspaceStorageGuard { } public void fence(String tenantId, String storageId, long revision, String operationId) { + requireOwnedRoot(tenantId, storageId); if (!enabled || !validId(operationId)) { throw WorkspaceExecutionStore.unavailable(); } @@ -423,6 +428,7 @@ public class WorkspaceStorageGuard { public void restoreOriginal(String tenantId, String storageId, long revision, String operationId) { + requireOwnedRoot(tenantId, storageId); if (!enabled || !validId(operationId)) { throw WorkspaceExecutionStore.unavailable(); } @@ -477,7 +483,7 @@ public class WorkspaceStorageGuard { } requireMatching(row, actual, tenantId, storageId); verifyHistory(tenantId, storageId, false); - if (!marker(row).equals(readMarker(root))) { + if (!marker(row).equals(readMarker(markerRoot(marker(row), root, false)))) { throw WorkspaceExecutionStore.unavailable(); } Path directory = root.resolve(cwd).normalize(); @@ -571,7 +577,7 @@ public class WorkspaceStorageGuard { } requireMatching(row, actual, tenantId, storageId); verifyHistory(tenantId, storageId, true); - if (!marker(row).equals(readMarkerProbe(root))) { + if (!marker(row).equals(readMarkerProbe(markerRoot(marker(row), root, false)))) { throw WorkspaceExecutionStore.unavailable(); } Path directory = root.resolve(cwd).normalize(); @@ -644,6 +650,7 @@ public class WorkspaceStorageGuard { } RecoveryRegistration migrationSource(String tenant, String storage, long revision, String fence) { + requireOwnedRoot(tenant, storage); WorkspaceStorageKindGuard.requireLocalAlias(jdbc, tenant, storage); return authorityRead(() -> { WorkspaceStorageKindGuard.lockDomain(jdbc, tenant); @@ -667,6 +674,7 @@ public class WorkspaceStorageGuard { } byte[] migrationMarker(String tenant, String storage, Identity target, String registration) { + requireOwnedRoot(tenant, storage); try { return json.writeValueAsBytes(new Marker(2, tenant, storage, target.root(), target.hostId(), target.device(), target.inode(), target.birthTime(), registration)); @@ -704,6 +712,7 @@ public class WorkspaceStorageGuard { } void publishMigrationMarker(Path root, byte[] expectedBytes, RecoveryRegistration original, String operation) { + requireOwnedRoot(original.tenantId(), original.storageId()); Marker expected; try { expected = json.readValue(expectedBytes, Marker.class); @@ -759,6 +768,7 @@ public class WorkspaceStorageGuard { } void promoteMigration(RecoveryRegistration source, Identity target, String registration, String operation) { + requireOwnedRoot(source.tenantId(), source.storageId()); transaction.executeWithoutResult(status -> { WorkspaceStorageKindGuard.lockDomain(jdbc, source.tenantId()); requireNonOverlapping(target); @@ -932,6 +942,22 @@ public class WorkspaceStorageGuard { row.device(), row.inode(), row.birthTime(), row.registrationId()); } + void requireOwnedRoot(String tenant, String storage) { + if (sharedImport(tenant, storage)) { + throw new RuntimeBrokerException(409, "workspace_shared_directory", + "Shared imported directories do not support exclusive storage maintenance.", false); + } + } + + private boolean sharedImport(String tenant, String storage) { + return WorkspaceCreationRoots.sharedStorage(storage) && !roots.containsKey(new Storage(tenant, storage)); + } + + private Path markerRoot(Marker marker, Path root, boolean create) { + return sharedImport(marker.tenantId(), marker.storageId()) + ? createdRoots.importedMetadataDirectory(marker.storageId(), create) : root; + } + private Marker readMarker(Path root) { Path file = root.resolve(MARKER); if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java index 04022d27d3d..5fdc33e7c17 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/AgentStateStore.java @@ -255,6 +255,33 @@ public interface AgentStateStore { Admission replayWorkspaceSessionCommand(String tenantId, String actorId, String idempotencyKey, String requestDigest); + default Admission insertWorkspaceSessionCommand(String tenantId, String actorId, + String idempotencyKey, String requestDigest, String agentId, String requestedRevision, + String title, List> input, String payloadDigest, WorkspaceSelection selection, + String approvalMode, boolean approvalSelectionSupported, Boolean planMode, boolean planModeSupported, + List> skills, boolean publicChildrenSupported, String modelId, + List extensionPins, + Set extensionLifecycleProfiles, + com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Plan worktree) { + if (worktree != null) { + throw new UnsupportedOperationException("Session worktrees are unavailable"); + } + return insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, + agentId, requestedRevision, title, input, payloadDigest, selection, approvalMode, + approvalSelectionSupported, planMode, planModeSupported, skills, publicChildrenSupported, + modelId, extensionPins, extensionLifecycleProfiles); + } + + default com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor findSessionWorktree( + String tenantId, String sessionId) { + return null; + } + + default Map findSessionWorktrees( + String tenantId, List sessionIds) { + return Map.of(); + } + Admission insertTurnCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, List> input, String payloadDigest); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinator.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinator.java index cccddb58490..cf52d9bc44a 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinator.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinator.java @@ -220,11 +220,14 @@ public class HarnessCoordinator { public void warmSession(String tenantId, String sessionId) { if (!sessionCreateWarm || !runtimeWarmer.isEnabled() - || !harness.isWorkspaceFilesAvailable()) { + || !harness.isWorkspaceFilesAvailable() || !worktreeReady(tenantId, sessionId)) { return; } executor.execute(() -> { try { + if (!worktreeReady(tenantId, sessionId)) { + return; + } runtimeWarmer.warm(sessionId).whenComplete( (ignored, error) -> { if (error != null) { @@ -237,6 +240,11 @@ public class HarnessCoordinator { }); } + private boolean worktreeReady(String tenantId, String sessionId) { + var worktree = store.findSessionWorktree(tenantId, sessionId); + return worktree == null || "ready".equals(worktree.state()); + } + private static void sessionWarmFailed(String tenantId, String sessionId, Throwable error) { LOG.warn("Managed Runtime create warm failed tenant={} session={}", @@ -449,6 +457,10 @@ public class HarnessCoordinator { AtomicBoolean recoveryPath) { SessionRecord session = store.requireSession(claimed.tenantId(), claimed.sessionId()); + if (!"CANCELLING".equals(claimed.status()) + && !worktreeReady(claimed.tenantId(), claimed.sessionId())) { + return false; + } if (!"CANCELLING".equals(claimed.status()) && AgentStateStore.HARNESS_RECOVERY_BLOCKED.equals(claimed.errorCode())) { return observeBlockedHarness(claimed, session, leaseLost); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java index c0654126ff2..273fddec058 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentService.java @@ -56,6 +56,8 @@ import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedArtifactReader; import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionProjection; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService; import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; import com.alibaba.qwen.code.managedagent.workspace.WorkspaceToolProfiles; import com.alibaba.qwen.code.managedagent.workspace.runtime.RuntimeWarmer; @@ -105,8 +107,6 @@ public class ManagedAgentService { private static final int TURN_ID_MAX_LENGTH = 64; // Catch-up reads of a stream use pages of this size. static final int STREAM_PAGE = 100; - // A context stays ready until cwd changes arrive (W2). - private static final String WORKSPACE_STATE = "ready"; // "text" is the spelling that clients used before the contract. private static final Set INPUT_TYPES = Set.of("input_text", "text"); @@ -120,6 +120,7 @@ public class ManagedAgentService { private final HarnessCoordinator coordinator; private final HarnessConnector harness; private RuntimeWarmer runtimeWarmer; + private SessionWorktreeService sessionWorktrees; private org.springframework.beans.factory.ObjectProvider recoveryServices; private ManagedAgentProperties.Dispatch renameDispatch = new ManagedAgentProperties.Dispatch(); private Clock renameClock = Clock.systemUTC(); @@ -135,6 +136,11 @@ public class ManagedAgentService { this.runtimeWarmer = runtimeWarmer; } + @Autowired(required = false) + public void setSessionWorktrees(SessionWorktreeService value) { + this.sessionWorktrees = value; + } + @Autowired void setRecoveryServices(org.springframework.beans.factory.ObjectProvider recoveryServices) { this.recoveryServices = recoveryServices; @@ -378,6 +384,17 @@ public class ManagedAgentService { List blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode, List> skills, String modelId, List extensionPins) { + return createWorkspaceSession(tenantId, actorId, idempotencyKey, agentId, agentRevision, + title, metadata, blocks, selection, approvalMode, planMode, skills, modelId, extensionPins, null); + } + + public CommandAdmission createWorkspaceSession(String tenantId, + String actorId, String idempotencyKey, String agentId, + String agentRevision, String title, Map metadata, + List blocks, WorkspaceSelection selection, String approvalMode, Boolean planMode, + List> skills, String modelId, + List extensionPins, + SessionWorktreeService.Request worktree) { validateIdempotencyKey(idempotencyKey); var additions = AgentDefinitionExtensions.sessionSkills(skills); if (actorId == null || actorId.isEmpty()) { @@ -412,6 +429,9 @@ public class ManagedAgentService { if (extensionPins != null) { semantic.put("extensionPins", extensionPins); } + if (worktree != null) { + semantic.put("worktree", worktree.baseRef() == null ? Map.of() : Map.of("baseRef", worktree.baseRef())); + } String requestDigest = digests.digest(semantic); String payloadDigest = input.isEmpty() ? null : SubmitHarnessTurn.computePayloadDigest(input); @@ -443,20 +463,31 @@ public class ManagedAgentService { Set extensionLifecycleProfiles = extensionPins == null ? Set.of() : java.util.stream.Stream.of(WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP) .filter(harness::supportsLifecycle).collect(java.util.stream.Collectors.toSet()); + if (worktree != null && sessionWorktrees == null) { + throw new ManagedAgentException(HttpStatus.CONFLICT, "worktree_unavailable", "Session worktrees are unavailable."); + } + SessionWorktreeStore.Plan worktreePlan = worktree == null ? null + : sessionWorktrees.preflight(tenantId, actorId, selection, worktree); + boolean approvalSelectionSupported = harness.supportsApprovalSelection() + && ("qwen-code".equals(agentId) || harness.supportsAgentDefinitionApprovalSelection()); try { - admission = store.insertWorkspaceSessionCommand(tenantId, + admission = worktreePlan == null ? store.insertWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, agentId, agentRevision, effectiveTitle, input, payloadDigest, selection, approvalMode, - harness.supportsApprovalSelection() && ("qwen-code".equals(agentId) - || harness.supportsAgentDefinitionApprovalSelection()), + approvalSelectionSupported, planMode, harness.supportsPlanMode(), additions, harness.supportsPublicChildAgents(), modelId, extensionPins, - extensionLifecycleProfiles); + extensionLifecycleProfiles) + : store.insertWorkspaceSessionCommand(tenantId, + actorId, idempotencyKey, requestDigest, agentId, agentRevision, + effectiveTitle, input, payloadDigest, selection, approvalMode, + approvalSelectionSupported, planMode, harness.supportsPlanMode(), additions, + harness.supportsPublicChildAgents(), modelId, extensionPins, extensionLifecycleProfiles, worktreePlan); } catch (DuplicateKeyException error) { admission = store.replayWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest); } // A creation with input warms through its first Turn's dispatch. - if (admission.turnId() == null) { + if (admission.turnId() == null && worktreeReady(tenantId, admission.sessionId())) { coordinator.warmSession(tenantId, admission.sessionId()); } dispatch(tenantId, admission, true); @@ -666,6 +697,7 @@ public class ManagedAgentService { // retire it. requireVisibleSession(session); requireSubmitterFacts(session, actorId); + requireReadyWorktree(session); requireHarness(); String payloadDigest = SubmitHarnessTurn.computePayloadDigest(input); Admission admission; @@ -828,6 +860,7 @@ public class ManagedAgentService { SessionRecord current = requireVisibleSession(tenantId, sessionId); requireSubmitterRole(current, actorId); requireSubmitterFacts(current, actorId); + requireReadyWorktree(current); String effectiveTitle = validRenameTitle(title); requireHarness(); if (!harness.supportsFencedTitles()) { @@ -995,7 +1028,7 @@ public class ManagedAgentService { int limit = limit(requestedLimit); SessionCursor decoded = decodeCursor(cursor, archiveState, titleQuery); SessionPage page = filteredSessions(tenantId, actorId, decoded, limit, archiveState, titleQuery); - return new PublicList<>("list", publicSessions(page.sessions()), page.hasMore(), + return new PublicList<>("list", publicSessions(page.sessions(), page.worktrees()), page.hasMore(), nextSessionCursor(page, archiveState, titleQuery)); } @@ -1018,7 +1051,7 @@ public class ManagedAgentService { int limit = limit(requestedLimit); SessionCursor decoded = decodeCursor(cursor, archiveState, titleQuery); SessionPage page = filteredSessions(tenantId, actorId, decoded, limit, archiveState, titleQuery); - return new WebShellPage<>(webShellSessions(page.sessions(), actorId), + return new WebShellPage<>(webShellSessions(page.sessions(), actorId, page.worktrees()), nextSessionCursor(page, archiveState, titleQuery), page.hasMore()); } @@ -1195,6 +1228,13 @@ public class ManagedAgentService { /** A page assembled from grouped batch reads instead of per-row ones. */ private List publicSessions(List sessions) { + return publicSessions(sessions, sessions.isEmpty() ? Map.of() : store.findSessionWorktrees( + sessions.getFirst().tenantId(), sessions.stream().filter(session -> session.workspace() != null) + .map(SessionRecord::sessionId).toList())); + } + + private List publicSessions(List sessions, + Map worktrees) { if (sessions.isEmpty()) { return List.of(); } @@ -1213,7 +1253,7 @@ public class ManagedAgentService { activeTurns.get(session.sessionId()), coveredSequences.getOrDefault(session.sessionId(), 0L), retention(session, closed), compressions.getOrDefault(session.sessionId(), - new AgentStateStore.ContextCompressionState(0, null)))) + new AgentStateStore.ContextCompressionState(0, null)), worktrees.get(session.sessionId()))) .toList(); } @@ -1242,7 +1282,7 @@ public class ManagedAgentService { private PublicSession publicSession(SessionRecord session, TurnSummary activeTurn, long snapshotCoveredSequence, - boolean retention, AgentStateStore.ContextCompressionState compression) { + boolean retention, AgentStateStore.ContextCompressionState compression, SessionWorktreeStore.Descriptor worktree) { Map metadata = session.title() == null ? Map.of() : Map.of("title", session.title()); // Every route these capabilities gate reads through @@ -1280,16 +1320,24 @@ public class ManagedAgentService { visible, hasActions(session), close, keep, keep, visible && supportsDelete(session, retention), - foregroundShell(session), + foregroundShell(session, worktree), visible && "ACTIVE".equals(session.status()) && session.workspace() != null - && store.workspaceFilesEnabled() && compression.available() && harness.supportsContextCompression(), - childAgentsCapability(session)), - publicWorkspace(session), compression.available() ? compression.compactionRevision() : null, compression.operationId()); + && ready(worktree) && store.workspaceFilesEnabled() && compression.available() && harness.supportsContextCompression(), + childAgentsCapability(session, worktree)), + publicWorkspace(session, worktree), compression.available() ? compression.compactionRevision() : null, compression.operationId()) + .withWorktree(worktree); } /** A page assembled from grouped batch reads instead of per-row ones. */ private List webShellSessions( List sessions, String actorId) { + return webShellSessions(sessions, actorId, sessions.isEmpty() ? Map.of() : store.findSessionWorktrees( + sessions.getFirst().tenantId(), sessions.stream().filter(session -> session.workspace() != null) + .map(SessionRecord::sessionId).toList())); + } + + private List webShellSessions(List sessions, String actorId, + Map worktrees) { if (sessions.isEmpty()) { return List.of(); } @@ -1321,15 +1369,16 @@ public class ManagedAgentService { // Cwd admission has no Turn execution-profile restriction. One role // grant read covers the workspaces of both shapes; the executable // facts read covers both id sets, retaining each capability's gates. - Set shaped = sessions.stream().filter(this::maySubmitShape) + Set shaped = sessions.stream().filter(session -> maySubmitShape(session, worktrees.get(session.sessionId()))) .map(SessionRecord::sessionId) .collect(java.util.stream.Collectors.toSet()); - Set contextShaped = sessions.stream().filter(this::mayChangeContextShape) + Set contextShaped = sessions.stream().filter(session -> mayChangeContextShape(session, worktrees.get(session.sessionId()))) .map(SessionRecord::sessionId) .collect(java.util.stream.Collectors.toSet()); Set candidateIds = new java.util.HashSet<>(shaped); candidateIds.addAll(contextShaped); - sessions.stream().filter(this::mayCompressShape).map(SessionRecord::sessionId).forEach(candidateIds::add); + sessions.stream().filter(session -> mayCompressShape(session, worktrees.get(session.sessionId()))) + .map(SessionRecord::sessionId).forEach(candidateIds::add); Set grantWorkspaces = candidateIds.isEmpty() ? Set.of() : sessions.stream() .filter(session -> candidateIds.contains( @@ -1358,17 +1407,17 @@ public class ManagedAgentService { retention(session, closed), session.workspace() == null || lifecycleOwners.contains(session.sessionId()), lifecycleOperations.get(session.sessionId()), - maySubmitWorkspaceTurn(session, grants, executable), - mayChangeContext(session, grants, executable), + maySubmitWorkspaceTurn(session, grants, executable, worktrees.get(session.sessionId())), + mayChangeContext(session, grants, executable, worktrees.get(session.sessionId())), modelContexts.get(session.sessionId()), modelSelections.get(session.sessionId()), modelSelectionSupported, renameSupported, - compressionSupported && mayCompressContext(session, grants, executable), + compressionSupported && mayCompressContext(session, grants, executable, worktrees.get(session.sessionId())), compressions.getOrDefault(session.sessionId(), new AgentStateStore.ContextCompressionState(0, null)), approvalSelections.get(session.sessionId()), approvalSupported && WorkspaceToolProfiles.supportsApprovalSelection(session) && (session.agentDefinitionDigest() == null || definitionApprovalSupported) - && lifecycleOwners.contains(session.sessionId()))) + && lifecycleOwners.contains(session.sessionId()), worktrees.get(session.sessionId()))) .toList(); } @@ -1396,7 +1445,7 @@ public class ManagedAgentService { WebShellModelContext modelContext, WebShellModelSelection modelSelection, boolean modelSelectionSupported, boolean renameSupported, boolean compressionSupported, AgentStateStore.ContextCompressionState compression, - WebShellApprovalSelection approvalSelection, boolean approvalSupported) { + WebShellApprovalSelection approvalSelection, boolean approvalSupported, SessionWorktreeStore.Descriptor worktree) { boolean visible = session.deletedAt() == null; boolean publicChild = session.publicChildAgentsVersion() == 1 && store.findChildLineage(session.tenantId(), session.sessionId()) != null; @@ -1421,7 +1470,7 @@ public class ManagedAgentService { latestTurn == null ? null : webShellTurn(latestTurn), webShellEnvironment(environmentEvent), session.lastSequence(), - webShellWorkspace(session), + webShellWorkspace(session, worktree), // Every Session serves its task list and detail; the tasks come from the // Stage H records its Session store holds (H0c). The // tombstone gate matches publicSession: every route these @@ -1430,14 +1479,14 @@ public class ManagedAgentService { hasArtifacts(session), hasActions(session), maySubmit && approvalExecutable, close, keep, keep, visible && lifecycleOwner && supportsDelete(session, retention), - false, foregroundShell(session), + false, foregroundShell(session, worktree), visible && modelSelectionSupported && mayChangeContext, - visible && approvalSupported && mayChangeContext && !Boolean.FALSE.equals(foregroundShell(session)), + visible && approvalSupported && mayChangeContext && !Boolean.FALSE.equals(foregroundShell(session, worktree)), visible && maySubmit && harness.supportsInputAttachments(), visible && "ACTIVE".equals(session.status()) && renameSupported && (session.workspace() == null || maySubmit), visible && approvalExecutable && compressionSupported && compression.available(), - childAgentsCapability(session)), + childAgentsCapability(session, worktree)), session.workspace() != null || session.agentDefinitionDigest() != null ? approvalSelection == null ? session.approvalMode() : approvalSelection.mode() : null, modelContext, modelSelection, approvalSelection == null ? null : approvalSelection.revision(), @@ -1445,7 +1494,7 @@ public class ManagedAgentService { approvalSelection != null && approvalSelection.planMode(), visible && approvalSupported && mayChangeContext && harness.supportsPlanMode() && store.supportsPlanMode(session), compression.available() ? compression.compactionRevision() : null, compression.operationId(), - lifecycleOperation); + lifecycleOperation).withWorktree(worktree); } private boolean supportsDelete(SessionRecord session, boolean retention) { @@ -1457,37 +1506,45 @@ public class ManagedAgentService { && harness.supportsLifecycle(session.toolProfile()); } - private Boolean childAgentsCapability(SessionRecord session) { + private Boolean childAgentsCapability(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { if (session.publicChildAgentsVersion() != 1) { return null; } - return session.deletedAt() == null && harness.supportsPublicChildAgents() + return session.deletedAt() == null && ready(worktree) && harness.supportsPublicChildAgents() && store.findChildLineage(session.tenantId(), session.sessionId()) == null; } private Boolean foregroundShell(SessionRecord session) { + return foregroundShell(session, store.findSessionWorktree(session.tenantId(), session.sessionId())); + } + + private Boolean foregroundShell(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { if (session.workspace() == null || !WorkspaceToolProfiles.isPublicShell(session.toolProfile())) { return null; } - return session.deletedAt() == null && store.workspaceFilesEnabled() && store.workspaceShellEnabled() + return session.deletedAt() == null && ready(worktree) && store.workspaceFilesEnabled() && store.workspaceShellEnabled() && WorkspaceToolProfiles.supportsApprovalMode(session.approvalMode()) && (!"yolo".equals(session.approvalMode()) || supportsApprovalSelection(session)); } - private static WebShellWorkspace webShellWorkspace(SessionRecord session) { + private static WebShellWorkspace webShellWorkspace(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { return session.workspace() == null ? null : new WebShellWorkspace(session.workspace().getWorkspaceId(), session.workspace().getCwdRelative(), session.workspace().getContextRevision(), - WORKSPACE_STATE); + workspaceState(worktree)); } - private static PublicWorkspace publicWorkspace(SessionRecord session) { + private static PublicWorkspace publicWorkspace(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { return session.workspace() == null ? null : new PublicWorkspace(session.workspace().getWorkspaceId(), session.workspace().getCwdRelative(), session.workspace().getContextRevision(), - WORKSPACE_STATE); + workspaceState(worktree)); + } + + private static String workspaceState(SessionWorktreeStore.Descriptor worktree) { + return ready(worktree) ? "ready" : "preparing".equals(worktree.state()) ? "changing" : "recovery_blocked"; } private static Map webShellEnvironment( @@ -1616,7 +1673,10 @@ public class ManagedAgentService { // Turn and the outage hold keeps spending the budget: there the // sweep is the spender, not the rescuer. if (admission.turnId() != null && harness.isAvailable() - && (!workspaceBound || harness.isWorkspaceFilesAvailable())) { + && (!workspaceBound || harness.isWorkspaceFilesAvailable()) + && (worktreeReady(tenantId, admission.sessionId()) + || store.findTurn(tenantId, admission.sessionId(), admission.turnId()) + .filter(turn -> "CANCELLING".equals(turn.status())).isPresent())) { if (org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()) { org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization( new org.springframework.transaction.support.TransactionSynchronization() { @@ -1629,6 +1689,14 @@ public class ManagedAgentService { } } + private boolean worktreeReady(String tenantId, String sessionId) { + return ready(store.findSessionWorktree(tenantId, sessionId)); + } + + private static boolean ready(SessionWorktreeStore.Descriptor worktree) { + return worktree == null || "ready".equals(worktree.state()); + } + String lifecycleDigest(String sessionId, String operation) { return digests.digest(Map.of( "sessionId", sessionId, "operation", operation)); @@ -1666,6 +1734,15 @@ public class ManagedAgentService { } } + private void requireReadyWorktree(SessionRecord session) { + var worktree = store.findSessionWorktree(session.tenantId(), session.sessionId()); + if (!ready(worktree)) { + throw new ManagedAgentException(HttpStatus.CONFLICT, + worktree.errorCode() == null ? "worktree_" + worktree.state() : worktree.errorCode(), + "The Session worktree is not ready."); + } + } + // The digest is computed from the validated title, so validation always // runs ahead of beginSessionMutation and its command row. private String renameDigest(String sessionId, String title) { @@ -1715,7 +1792,7 @@ public class ManagedAgentService { private void requireCancellerShape(SessionRecord session, String actorId) { - if (session.workspace() != null && !maySubmitShape(session)) { + if (session.workspace() != null && !maySubmitShape(session, null)) { requireLegacyWorkspace(session, actorId); } } @@ -1756,7 +1833,7 @@ public class ManagedAgentService { private boolean maySubmitWorkspaceTurn(SessionRecord session, String actorId) { - if (!maySubmitShape(session) || actorId == null + if (!maySubmitShape(session, null) || actorId == null || actorId.isEmpty()) { return false; } @@ -1780,14 +1857,14 @@ public class ManagedAgentService { session.sessionId()); } - // The page twin of the singular: the same rule answered from the two + // The page rule also requires readiness; its permission facts use the // batch reads the assembler already made — the caller's role from the // grant batch, the creator-keyed facts of each submit-shaped Session // from the facts batch. private boolean maySubmitWorkspaceTurn(SessionRecord session, Map grants, - java.util.Set executable) { - if (!maySubmitShape(session)) { + java.util.Set executable, SessionWorktreeStore.Descriptor worktree) { + if (!maySubmitShape(session, worktree)) { return false; } var grant = grants.get(session.workspace().getWorkspaceId()); @@ -1799,8 +1876,8 @@ public class ManagedAgentService { // authority checks for changing an existing Session context. private boolean mayChangeContext(SessionRecord session, Map grants, - java.util.Set executable) { - if (!mayChangeContextShape(session)) { + java.util.Set executable, SessionWorktreeStore.Descriptor worktree) { + if (!mayChangeContextShape(session, worktree)) { return false; } var grant = grants.get(session.workspace().getWorkspaceId()); @@ -1808,7 +1885,7 @@ public class ManagedAgentService { && executable.contains(session.sessionId()); } - private boolean maySubmitShape(SessionRecord session) { + private boolean maySubmitShape(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { if (session.workspace() == null || !harness.isWorkspaceFilesAvailable()) { return false; @@ -1818,29 +1895,32 @@ public class ManagedAgentService { // that fails them can never execute, so admission must not certify // it. Empty bound creation skips that validation by design. return "ACTIVE".equals(session.status()) && session.deletedAt() == null + && ready(worktree) && ("qwen-code".equals(session.agentId()) || session.agentDefinitionDigest() != null) && WorkspaceExecutionProfile.CONTEXT_CONFIG_REF.equals( session.workspace().getContextConfigRef()); } - private boolean mayCompressShape(SessionRecord session) { + private boolean mayCompressShape(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { return session.workspace() != null && "ACTIVE".equals(session.status()) - && session.deletedAt() == null && store.workspaceFilesEnabled(); + && session.deletedAt() == null && store.workspaceFilesEnabled() + && ready(worktree); } private boolean mayCompressContext(SessionRecord session, - Map grants, Set executable) { - if (!mayCompressShape(session)) { + Map grants, Set executable, SessionWorktreeStore.Descriptor worktree) { + if (!mayCompressShape(session, worktree)) { return false; } var grant = grants.get(session.workspace().getWorkspaceId()); return grant != null && grant.canCreateSession() && executable.contains(session.sessionId()); } - private boolean mayChangeContextShape(SessionRecord session) { + private boolean mayChangeContextShape(SessionRecord session, SessionWorktreeStore.Descriptor worktree) { return session.workspace() != null && "ACTIVE".equals(session.status()) && session.deletedAt() == null && store.workspaceFilesEnabled() + && ready(worktree) && (runtimeWarmer == null || runtimeWarmer.supportsWorkspaceContextChange(session.workspace())); } diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/ChildWorktreeGit.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/ChildWorktreeGit.java index ea71f6a600b..11fae0550bb 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/ChildWorktreeGit.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/ChildWorktreeGit.java @@ -138,6 +138,15 @@ public final class ChildWorktreeGit { return version; } + public byte[] sessionCommand(Path directory, Path root, List arguments) { + Result result = run(directory, root, null, arguments); + if (result.exit != 0) { + throw new ChildWorkspaceException(ChildWorkspaceException.GIT, false, + "git " + arguments.getFirst() + " failed: " + summary(result.err)); + } + return result.out; + } + private static boolean tooOld(Matcher version) { int major = Integer.parseInt(version.group(1)); int minor = Integer.parseInt(version.group(2)); @@ -183,6 +192,14 @@ public final class ChildWorktreeGit { * the root, a non-bare and non-sparse checkout, and a safe config. */ public Layout layout(Path root, String parentCwdRelative) { + return layout(root, parentCwdRelative, false); + } + + public Layout sessionLayout(Path root, String cwdRelative) { + return layout(root, cwdRelative, true); + } + + private Layout layout(Path root, String parentCwdRelative, boolean allowStorageRoot) { Path directory = root.resolve(parentCwdRelative).normalize(); try { if (!directory.startsWith(root) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) @@ -199,7 +216,7 @@ public final class ChildWorktreeGit { throw ChildWorkspaceException.layout("The parent's working directory is not in a Git repository"); } Path top = Path.of(firstLine(located.out)); - Repository repository = open(root, root.relativize(top).toString()); + Repository repository = open(root, root.relativize(top).toString(), allowStorageRoot); String offset = repository.top().relativize(directory).toString().replace('\\', '/'); return new Layout(relative(root, repository.top()), offset.isEmpty() ? "." : offset); } @@ -210,8 +227,16 @@ public final class ChildWorktreeGit { * repository the model may have rearranged since. */ public Repository open(Path root, String repositoryRelative) { + return open(root, repositoryRelative, false); + } + + public Repository sessionOpen(Path root, String repositoryRelative) { + return open(root, repositoryRelative, true); + } + + private Repository open(Path root, String repositoryRelative, boolean allowStorageRoot) { Path top = root.resolve(repositoryRelative).normalize(); - if (!top.startsWith(root) || top.equals(root) + if (!top.startsWith(root) || !allowStorageRoot && top.equals(root) || top.startsWith(root.resolve(CONTAINER))) { throw ChildWorkspaceException.layout( "The repository must lie strictly inside the storage root, outside " + CONTAINER); diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGit.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGit.java new file mode 100644 index 00000000000..882d06e69b2 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGit.java @@ -0,0 +1,328 @@ +package com.alibaba.qwen.code.managedagent.workspace; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Plan; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.List; +import java.util.Map; + +public final class SessionWorktreeGit implements AutoCloseable { + public static final String CONTAINER = ".qwen-managed-worktrees"; + private static final ObjectMapper JSON = new ObjectMapper() + .enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_LONG_FOR_INTS); + private final ChildWorktreeGit git; + + public SessionWorktreeGit(ChildWorktreeGit git) { + this.git = git; + git.requireSupportedVersion(); + } + + public record Source(String repositoryRelative, String offset, String commonDirectory, + String commonIdentity, String commit, List branches) { } + + public Source source(Path root, String cwd, String baseRef) { + if (cwd.split("/")[0].equals(CONTAINER)) { + throw error("worktree_source_private", "Select the original project directory."); + } + String ref = baseRef == null ? "HEAD" : baseRef; + if (ref.isBlank() || ref.length() > 1024 || ref.startsWith("-") || ref.contains("..") + || ref.codePoints().anyMatch(value -> Character.isISOControl(value) || "~^:@{\\".indexOf(value) >= 0)) { + throw error("worktree_invalid_ref", "Choose an existing local branch, tag or commit."); + } + var layout = sourceLayout(root, cwd); + requireSeparateRepository(root, layout.repositoryRelative()); + var repository = git.sessionOpen(root, layout.repositoryRelative()); + String commit; + try { + commit = command(repository.top(), root, "rev-parse", "--verify", "--end-of-options", ref + "^{commit}").strip(); + } catch (ChildWorkspaceException exception) { + throw error("worktree_invalid_ref", "Choose an existing local branch, tag or commit."); + } + if (!commit.matches("[a-f0-9]{40}|[a-f0-9]{64}")) { + throw error("worktree_invalid_ref", "The selected ref is not a commit."); + } + if (!".".equals(layout.offset()) + && !"tree".equals(command(repository.top(), root, "cat-file", "-t", commit + ":" + layout.offset()).strip())) { + throw error("worktree_directory_missing", "The selected directory does not exist at that commit."); + } + if (command(repository.top(), root, "ls-tree", "-r", commit).lines().anyMatch(line -> line.startsWith("160000 "))) { + throw error("worktree_submodule_unsupported", "Session worktrees do not support submodules yet."); + } + List branches = command(repository.top(), root, "for-each-ref", "--format=%(refname:short)", "refs/heads") + .lines().limit(200).toList(); + return new Source(layout.repositoryRelative(), layout.offset(), repository.commonDir().toString(), + identity(repository.commonDir()), commit, branches); + } + + public String currentBranch(Path root, String cwd) { + if (cwd.split("/")[0].equals(CONTAINER)) { + throw error("worktree_source_private", "Select the original project directory."); + } + var layout = sourceLayout(root, cwd); + var repository = git.sessionOpen(root, layout.repositoryRelative()); + String branch = command(repository.top(), root, "branch", "--show-current").strip(); + return branch.isEmpty() ? "HEAD" : branch; + } + + private ChildWorktreeGit.Layout sourceLayout(Path root, String cwd) { + try { + return git.sessionLayout(root, cwd); + } catch (ChildWorkspaceException exception) { + if (exception.getMessage().contains("not in a Git repository")) { + throw error("worktree_not_git", "Select a Git project directory."); + } + throw exception; + } + } + + public String prepare(Plan plan) { + Path root = Path.of(plan.root()); + requireRoot(plan); + requireSeparateRepository(root, plan.repositoryRelative()); + Path container = root.resolve(CONTAINER); + try { + if (!Files.exists(container, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectory(container); + } + requireDirectory(container); + Path marker = marker(plan); + Path target = root.resolve(plan.worktreeRoot()); + byte[] expected = JSON.writeValueAsBytes(plan); + if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw error("worktree_path_conflict", "The allocated directory already exists and was preserved."); + } + writeRecord(marker, expected); + } + requireMarker(plan); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (Files.exists(completion(plan), LinkOption.NOFOLLOW_LINKS)) { + throw error("worktree_identity_conflict", "The completed worktree is missing; its record was preserved."); + } + var repository = git.sessionOpen(root, plan.repositoryRelative()); + if (!repository.commonDir().toString().equals(plan.commonDirectory())) { + throw error("worktree_identity_conflict", "The source Git repository changed."); + } + String existing = command(repository.top(), root, "for-each-ref", "--format=%(refname)", "refs/heads/" + plan.branch()); + if (!existing.isBlank()) { + throw error("worktree_branch_conflict", "The allocated branch already exists; its contents were preserved."); + } + command(repository.top(), root, "worktree", "add", "-b", plan.branch(), "--", target.toString(), plan.baseCommit()); + requireDirectory(root.resolve(plan.cwdRelative())); + writeRecord(completion(plan), captureIdentity(plan, null).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + requireDirectory(root.resolve(plan.cwdRelative())); + return verify(plan, null); + } catch (IOException exception) { + throw error("worktree_identity_conflict", "The worktree ownership record is unavailable."); + } + } + + public String cleanupBlocker(Plan plan, String savedIdentity) { + if (absent(plan)) { + return null; + } + verify(plan, savedIdentity); + Path root = Path.of(plan.root()); + Path target = root.resolve(plan.worktreeRoot()); + var repository = git.sessionOpen(root, plan.worktreeRoot()); + for (String name : List.of("index.lock", "HEAD.lock", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", + "BISECT_LOG", "rebase-merge", "rebase-apply", "sequencer")) { + if (Files.exists(repository.gitDir().resolve(name), LinkOption.NOFOLLOW_LINKS)) { + return "worktree_git_busy"; + } + } + for (String file : command(target, root, "ls-files", "-v", "-z").split("\0")) { + if (!file.isEmpty() && (file.charAt(0) == 'S' || Character.isLowerCase(file.charAt(0)))) { + return "worktree_index_flags"; + } + } + if (!command(target, root, "status", "--porcelain=v1", "--untracked-files=all", "--ignored=matching").isEmpty()) { + return "worktree_dirty"; + } + if (command(target, root, "ls-files", "--stage").lines().anyMatch(line -> line.startsWith("160000 "))) { + return "worktree_submodule_unsupported"; + } + try (var paths = Files.walk(target)) { + if (paths.anyMatch(path -> !path.equals(target.resolve(".git")) && path.getFileName().toString().equals(".git"))) { + return "worktree_nested_repository"; + } + } catch (IOException exception) { + return "worktree_identity_conflict"; + } + if (command(target, root, "for-each-ref", "--contains=HEAD", "--format=%(refname)", + "refs/heads", "refs/tags", "refs/remotes").isBlank()) { + return "worktree_unretained_commit"; + } + return null; + } + + public void cleanup(Plan plan, String savedIdentity) { + String blocker = cleanupBlocker(plan, savedIdentity); + if (blocker != null) { + throw error(blocker, "The worktree cannot be cleaned while it contains protected work or Git activity."); + } + if (absent(plan)) { + return; + } + Path root = Path.of(plan.root()); + command(Path.of(plan.commonDirectory()), root, "--git-dir=" + plan.commonDirectory(), + "worktree", "remove", "--", root.resolve(plan.worktreeRoot()).toString()); + } + + public static String verify(Plan plan, String savedIdentity) { + String actual = captureIdentity(plan, savedIdentity); + try { + if (!JSON.readTree(actual).equals(JSON.readTree(regularText(completion(plan))))) { + throw new IOException("Checkout completion identity changed"); + } + return actual; + } catch (IOException exception) { + throw error("worktree_identity_conflict", "Initial checkout completion cannot be verified; the directory was preserved."); + } + } + + private static String captureIdentity(Plan plan, String savedIdentity) { + requireRoot(plan); + requireMarker(plan); + Path root = Path.of(plan.root()); + Path target = root.resolve(plan.worktreeRoot()); + try { + requireDirectory(target); + Path gitfile = target.resolve(".git"); + String pointer = regularText(gitfile); + if (!pointer.startsWith("gitdir: ")) { + throw new IOException("Invalid gitfile"); + } + Path gitdir = target.resolve(pointer.substring(8).strip()).normalize().toRealPath(); + Path common = gitdir.resolve(regularText(gitdir.resolve("commondir")).strip()).normalize().toRealPath(); + if (!common.toString().equals(plan.commonDirectory()) || !gitdir.getParent().equals(common.resolve("worktrees")) + || !gitdir.resolve(regularText(gitdir.resolve("gitdir")).strip()).normalize().toRealPath().equals(gitfile.toRealPath())) { + throw new IOException("Git registration changed"); + } + String actual = JSON.writeValueAsString(Map.of("target", identity(target), "gitfile", identity(gitfile), + "gitdir", gitdir.toString(), "gitdirIdentity", identity(gitdir))); + if (savedIdentity != null && !JSON.readTree(actual).equals(JSON.readTree(savedIdentity))) { + throw new IOException("Worktree identity changed"); + } + return actual; + } catch (IOException exception) { + throw error("worktree_identity_conflict", "The original worktree is missing or its Git identity changed."); + } + } + + public static void requireRoot(Plan plan) { + if (!identity(Path.of(plan.root())).equals(plan.rootIdentity()) + || !identity(Path.of(plan.commonDirectory())).equals(plan.commonIdentity())) { + throw error("worktree_identity_conflict", "The original storage or Git directory changed."); + } + } + + public static String currentBranch(String verifiedIdentity) { + try { + String head = regularText(Path.of(JSON.readTree(verifiedIdentity).get("gitdir").asText()).resolve("HEAD")).strip(); + return head.startsWith("ref: refs/heads/") ? head.substring("ref: refs/heads/".length()) : "HEAD"; + } catch (IOException exception) { + throw error("worktree_identity_conflict", "The worktree HEAD cannot be read."); + } + } + + public static String identity(Path path) { + try { + BasicFileAttributes stat = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (stat.isSymbolicLink() || !path.toRealPath().equals(path) || stat.fileKey() == null) { + throw new IOException("Unverified path"); + } + return stat.fileKey() + ":" + stat.creationTime(); + } catch (IOException exception) { + throw error("worktree_identity_conflict", "The original filesystem identity is unavailable."); + } + } + + private static void requireDirectory(Path path) throws IOException { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || !path.toRealPath().equals(path)) { + throw new IOException("Not a real directory"); + } + } + + private static void requireSeparateRepository(Path root, String repositoryRelative) { + if (root.resolve(repositoryRelative).normalize().equals(root)) { + throw error("worktree_layout_unsupported", "Select a project below the storage root so Session worktrees stay outside its repository."); + } + } + + private static Path marker(Plan plan) { + return Path.of(plan.root()).resolve(CONTAINER).resolve(plan.worktreeId() + ".json"); + } + + private static Path completion(Plan plan) { + return Path.of(plan.root()).resolve(CONTAINER).resolve(plan.worktreeId() + ".ready"); + } + + private static void writeRecord(Path path, byte[] content) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + ByteBuffer bytes = ByteBuffer.wrap(content); + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(true); + } + } + + private boolean absent(Plan plan) { + requireRoot(plan); + Path target = Path.of(plan.root()).resolve(plan.worktreeRoot()); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (Files.exists(marker(plan), LinkOption.NOFOLLOW_LINKS)) { + requireMarker(plan); + } + String registered = command(Path.of(plan.commonDirectory()), Path.of(plan.root()), + "--git-dir=" + plan.commonDirectory(), "worktree", "list", "--porcelain", "-z"); + if (List.of(registered.split("\u0000")).contains("worktree " + target)) { + throw error("worktree_registration_incomplete", "The missing worktree still has a Git registration."); + } + return true; + } + + private static void requireMarker(Plan plan) { + try { + if (!JSON.readTree(regularText(marker(plan))).equals(JSON.valueToTree(plan))) { + throw new IOException("Owner changed"); + } + } catch (IOException exception) { + throw error("worktree_identity_conflict", "The worktree ownership record changed."); + } + } + + private static String regularText(Path path) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) > 64 * 1024) { + throw new IOException("Not a small regular file"); + } + return Files.readString(path); + } + + private String command(Path directory, Path root, String... args) { + return new String(git.sessionCommand(directory, root, List.of(args)), java.nio.charset.StandardCharsets.UTF_8); + } + + public static ManagedAgentException error(String code, String message) { + return new ManagedAgentException(HttpStatus.CONFLICT, code, message); + } + + @Override + public void close() { + git.close(); + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeService.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeService.java new file mode 100644 index 00000000000..bebc2eff5d8 --- /dev/null +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeService.java @@ -0,0 +1,268 @@ +package com.alibaba.qwen.code.managedagent.workspace; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Plan; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Row; +import com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore; +import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.annotation.PreDestroy; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +public class SessionWorktreeService { + private static final long LEASE_MS = 120_000; + private final SessionWorktreeStore store; + private final WorkspaceExecutionStore execution; + private final ManagedAgentProperties properties; + private final ManagedAgentStore sessions; + private final HarnessCoordinator coordinator; + private final String owner = "session-worktree-" + UUID.randomUUID(); + private final ScheduledExecutorService renewals = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "session-worktree-renewal"); + thread.setDaemon(true); + return thread; + }); + private SessionWorktreeGit git; + + public SessionWorktreeService(SessionWorktreeStore store, WorkspaceExecutionStore execution, + ManagedAgentProperties properties, ManagedAgentStore sessions, HarnessCoordinator coordinator) { + this.store = store; + this.execution = execution; + this.properties = properties; + this.sessions = sessions; + this.coordinator = coordinator; + } + + public record Request(String baseRef) { } + public record Options(boolean supported, String reason, String baseCommit, List branches, + String currentBranch) { } + public record Inspection(Descriptor worktree, boolean canCleanup, String cleanupBlocker) { } + + public static Request parse(JsonNode value, boolean webShell) { + String key = webShell ? "baseRef" : "base_ref"; + if (value == null) { + return null; + } + if (!value.isObject() || value.size() > 1 || value.size() == 1 && !value.has(key) + || value.has(key) && (!value.get(key).isTextual() || value.get(key).asText().isBlank())) { + throw SessionWorktreeGit.error("invalid_worktree", "Worktree accepts only an optional local base ref."); + } + return new Request(value.has(key) ? value.get(key).asText() : null); + } + + public boolean enabled() { + var broker = properties.getRuntimeBroker(); + return broker.isSessionWorktreesEnabled() && broker.isEnabled() && broker.isDurableLocalProcess() + && "local-process".equals(broker.getProvisioner()) + && "session".equals(broker.getBoundSessionIsolationClass()) + && java.nio.charset.StandardCharsets.UTF_8.name().equalsIgnoreCase(System.getProperty("sun.jnu.encoding", "")) + && !System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("windows"); + } + + public Options options(String tenant, String actor, WorkspaceSelection selection, String baseRef) { + store.authorizeOptions(tenant, actor, selection.workspaceId()); + if (!enabled()) { + return new Options(false, "worktree_unsupported", null, List.of(), null); + } + String currentBranch = null; + try { + var source = store.source(tenant, actor, selection); + currentBranch = git().currentBranch(source.root(), source.resolved().binding().getCwdRelative()); + var located = git().source(source.root(), source.resolved().binding().getCwdRelative(), baseRef); + return new Options(true, null, located.commit(), located.branches(), currentBranch); + } catch (RuntimeException exception) { + return new Options(false, code(exception), null, List.of(), currentBranch); + } + } + + public Plan preflight(String tenant, String actor, WorkspaceSelection selection, Request request) { + if (!enabled()) { + throw SessionWorktreeGit.error("worktree_unsupported", "Session worktrees require a supported local Runtime."); + } + var source = store.source(tenant, actor, selection); + try { + var binding = source.resolved().binding(); + var located = git().source(source.root(), binding.getCwdRelative(), request.baseRef()); + String id = UUID.randomUUID().toString().replace("-", ""); + String target = SessionWorktreeGit.CONTAINER + "/" + id; + String cwd = ".".equals(located.offset()) ? target : target + "/" + located.offset(); + return new Plan(tenant, binding.getWorkspaceId(), binding.getWorkspaceGeneration(), binding.getStorageId(), + binding.getCwdRelative(), binding.getContextConfigRef(), binding.getContextRevision(), source.root().toString(), + SessionWorktreeGit.identity(source.root()), located.repositoryRelative(), located.offset(), located.commonDirectory(), + located.commonIdentity(), request.baseRef(), located.commit(), id, target, cwd, "worktree-managed-" + id, + source.storageIdentity()); + } catch (ChildWorkspaceException exception) { + throw SessionWorktreeGit.error("worktree_layout_unsupported", exception.getMessage()); + } + } + + public List list(String tenant, String actor, String workspace) { + return store.list(tenant, actor, workspace); + } + + public Inspection inspect(String tenant, String actor, String workspace, String id) { + Row row = store.get(tenant, actor, workspace, id, false); + Descriptor descriptor = store.describe(tenant, row.sessionId()); + String blocker = store.cleanupBlocker(row); + if (blocker == null && "READY".equals(row.state())) { + try { + blocker = git().cleanupBlocker(row.plan(), row.identityJson()); + } catch (RuntimeException exception) { + blocker = code(exception); + } + } + if (!List.of("ready", "failed").contains(descriptor.state()) && blocker == null) { + blocker = descriptor.errorCode() == null ? "worktree_" + descriptor.state() : descriptor.errorCode(); + } + if (!row.actor().equals(actor) && blocker == null) { + blocker = "worktree_forbidden"; + } + return new Inspection(descriptor, blocker == null, blocker); + } + + public Descriptor cleanup(String tenant, String actor, String workspace, String id, String key) { + return store.cleanup(tenant, actor, workspace, id, key).descriptor(); + } + + @Scheduled(scheduler = "sessionWorktreeScheduler", fixedDelayString = "${qwen.managed-agent.session-worktree.scan-delay:1000}") + public void scan() { + store.resumeStopped(); + for (String[] failure : store.failedInitialSessions()) { + sessions.failWorktreeInitialTurns(failure[0], failure[1], failure[2] == null ? "worktree_unavailable" : failure[2]); + } + for (Row candidate : store.due()) { + drive(candidate); + } + } + + public void drive(Row candidate) { + Row row = store.claim(candidate, owner, LEASE_MS); + if (row == null) { + return; + } + AtomicBoolean live = new AtomicBoolean(true); + var renewal = renewals.scheduleWithFixedDelay(() -> { + try { + if (!store.renew(row, owner, LEASE_MS)) { + live.set(false); + } + } catch (RuntimeException exception) { + live.set(false); + } + }, LEASE_MS / 3, LEASE_MS / 3, TimeUnit.MILLISECONDS); + boolean preparing = "PREPARING".equals(row.state()); + try { + if (preparing && !store.active(row) && row.identityJson() == null) { + if (java.nio.file.Files.exists(java.nio.file.Path.of(row.plan().root()).resolve(row.plan().worktreeRoot()), + java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + store.complete(row, owner, "READY", SessionWorktreeGit.verify(row.plan(), null), null); + } else { + store.complete(row, owner, "FAILED", null, "worktree_session_closed"); + } + return; + } + String storedBlocker = preparing ? null : store.cleanupBlocker(row); + if (storedBlocker != null) { + store.complete(row, owner, "READY", null, storedBlocker); + return; + } + var binding = SessionWorktreeStore.storageBinding(row.plan()); + String step = execution.holdForMaintenance(binding, row.operationId(), row.generation()); + String identity = null; + boolean stopped = true; + try { + identity = ChildWorktreeGit.whileLive(live::get, () -> { + if (preparing) { + return git().prepare(row.plan()); + } + String blocker = store.cleanupBlocker(row); + if (blocker != null) { + throw SessionWorktreeGit.error(blocker, "The Session worktree is still in use or no longer authorized."); + } + git().cleanup(row.plan(), row.identityJson()); + return null; + }); + } catch (ChildWorkspaceException exception) { + stopped = !ChildWorkspaceException.STOP_UNVERIFIED.equals(exception.code()); + throw exception; + } finally { + if (stopped) { + execution.releaseMaintenance(binding, row.operationId(), row.generation(), step); + } + } + if (store.complete(row, owner, preparing ? "READY" : "REMOVED", identity, null) + && preparing && store.active(row)) { + coordinator.warmSession(row.tenantId(), row.sessionId()); + } + } catch (RuntimeException exception) { + String code = code(exception); + if (store.maintenanceHeld(row)) { + code = "maintenance_stop_unverified"; + } + if ("workspace_busy".equals(code)) { + store.defer(row, owner, code); + } else if (!"maintenance_stop_unverified".equals(code) && row.attempts() < 15 + && (exception instanceof ChildWorkspaceException child && child.retryable() + || exception instanceof com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException broker && broker.isRetryable())) { + store.retry(row, owner, code); + } else { + String state = "maintenance_stop_unverified".equals(code) || "worktree_identity_conflict".equals(code) + ? "BLOCKED" : preparing ? "FAILED" : "READY"; + store.complete(row, owner, state, null, code); + if (preparing) { + sessions.failWorktreeInitialTurns(row.tenantId(), row.sessionId(), code); + } + } + } finally { + renewal.cancel(false); + } + } + + private synchronized SessionWorktreeGit git() { + if (git == null) { + var broker = properties.getRuntimeBroker(); + git = new SessionWorktreeGit(new ChildWorktreeGit(broker.getChildWorkspaceGit(), broker.getChildWorkspaceGitTimeout())); + } + return git; + } + + private static String code(RuntimeException exception) { + if (exception instanceof ManagedAgentException managed) { + return managed.getCode(); + } + if (exception instanceof ChildWorkspaceException child) { + return switch (child.code()) { + case ChildWorkspaceException.STOP_UNVERIFIED -> "maintenance_stop_unverified"; + case ChildWorkspaceException.LAYOUT -> "worktree_layout_unsupported"; + case ChildWorkspaceException.UNSAFE_CONFIG -> "worktree_config_unsupported"; + default -> "worktree_git_failed"; + }; + } + if (exception instanceof com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException broker) { + return broker.getCode(); + } + return "worktree_git_unavailable"; + } + + @PreDestroy + void stop() { + renewals.shutdownNow(); + if (git != null) { + git.close(); + } + } +} diff --git a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java index f9b6a3144e0..1acd2da6f48 100644 --- a/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java +++ b/packages/managed-agent-server/src/main/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaContract.java @@ -7,6 +7,7 @@ public final class ManagedSchemaContract { public static final Set TABLES = Set.of( "ma_session", + "ma_session_worktree", "ma_session_head", "ma_turn", "ma_user_message_queue", @@ -60,6 +61,7 @@ public final class ManagedSchemaContract { CHILD_SESSION_START, CHILD_SESSION_CLOSE, CHILD_WORKSPACE, + SESSION_WORKTREE, OUTPUT_UPLOAD, OUTPUT_VERIFY, OUTPUT_FINISH, diff --git a/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql b/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql index 165f7f3a20d..d80307e8080 100644 --- a/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql +++ b/packages/managed-agent-server/src/main/resources/db/managed-schema/V1__managed_schema.sql @@ -684,7 +684,7 @@ CREATE TABLE ma_operation ( CONSTRAINT uq_ma_operation_request UNIQUE (scope_key, operation_kind, target_key, actor_key, idempotency_digest), CONSTRAINT ck_ma_operation_generation CHECK (claim_generation >= 0 AND attempt_count >= 0 AND payload_version > 0), CONSTRAINT ck_ma_operation_mode CHECK ((CASE execution_mode WHEN 'SYNC' THEN 4 WHEN 'ASYNC' THEN 5 ELSE -1 END = CHAR_LENGTH(execution_mode))), - CONSTRAINT ck_ma_operation_kind CHECK ((CASE operation_kind WHEN 'CLOSE' THEN 5 WHEN 'ARCHIVE' THEN 7 WHEN 'DELETE' THEN 6 WHEN 'UNARCHIVE' THEN 9 WHEN 'RENAME' THEN 6 WHEN 'CWD_CHANGE' THEN 10 WHEN 'CONTEXT_COMPRESSION' THEN 19 WHEN 'ACTION_RESPONSE' THEN 15 WHEN 'TASK_CANCEL' THEN 11 WHEN 'CHILD_SESSION_START' THEN 19 WHEN 'CHILD_SESSION_CLOSE' THEN 19 WHEN 'CHILD_WORKSPACE' THEN 15 WHEN 'OUTPUT_UPLOAD' THEN 13 WHEN 'OUTPUT_VERIFY' THEN 13 WHEN 'OUTPUT_FINISH' THEN 13 WHEN 'OBJECT_PUT' THEN 10 WHEN 'TOOL_RESULT_PROJECTION' THEN 22 WHEN 'SESSION_RESOURCE_GC' THEN 19 WHEN 'CAPTURE_GC' THEN 10 WHEN 'WORKSPACE_OPERATOR_RECOVERY' THEN 27 WHEN 'WORKSPACE_RECOVERY' THEN 18 WHEN 'STORAGE_MIGRATION' THEN 17 WHEN 'CSI_RETIREMENT' THEN 14 WHEN 'CHANNEL_INGRESS' THEN 15 WHEN 'WORKSPACE_CREATE' THEN 16 WHEN 'SUBMIT_INPUT' THEN 12 WHEN 'CANCEL' THEN 6 WHEN 'WORKSPACE_RETIREMENT' THEN 20 WHEN 'WORKSPACE_UPLOAD' THEN 16 ELSE -1 END = CHAR_LENGTH(operation_kind))), + CONSTRAINT ck_ma_operation_kind CHECK ((CASE operation_kind WHEN 'CLOSE' THEN 5 WHEN 'ARCHIVE' THEN 7 WHEN 'DELETE' THEN 6 WHEN 'UNARCHIVE' THEN 9 WHEN 'RENAME' THEN 6 WHEN 'CWD_CHANGE' THEN 10 WHEN 'CONTEXT_COMPRESSION' THEN 19 WHEN 'ACTION_RESPONSE' THEN 15 WHEN 'TASK_CANCEL' THEN 11 WHEN 'CHILD_SESSION_START' THEN 19 WHEN 'CHILD_SESSION_CLOSE' THEN 19 WHEN 'CHILD_WORKSPACE' THEN 15 WHEN 'SESSION_WORKTREE' THEN 16 WHEN 'OUTPUT_UPLOAD' THEN 13 WHEN 'OUTPUT_VERIFY' THEN 13 WHEN 'OUTPUT_FINISH' THEN 13 WHEN 'OBJECT_PUT' THEN 10 WHEN 'TOOL_RESULT_PROJECTION' THEN 22 WHEN 'SESSION_RESOURCE_GC' THEN 19 WHEN 'CAPTURE_GC' THEN 10 WHEN 'WORKSPACE_OPERATOR_RECOVERY' THEN 27 WHEN 'WORKSPACE_RECOVERY' THEN 18 WHEN 'STORAGE_MIGRATION' THEN 17 WHEN 'CSI_RETIREMENT' THEN 14 WHEN 'CHANNEL_INGRESS' THEN 15 WHEN 'WORKSPACE_CREATE' THEN 16 WHEN 'SUBMIT_INPUT' THEN 12 WHEN 'CANCEL' THEN 6 WHEN 'WORKSPACE_RETIREMENT' THEN 20 WHEN 'WORKSPACE_UPLOAD' THEN 16 ELSE -1 END = CHAR_LENGTH(operation_kind))), progress_format VARCHAR(64), progress_version BIGINT, progress_stage VARCHAR(16), @@ -1281,3 +1281,28 @@ CREATE INDEX ix_ma_head_wake ON ma_session_head (wake_scan_pending, wake_retry_a CREATE INDEX ix_ma_ref_binding ON ma_resource_ref (owner_kind, runtime_binding_key, owner_revision); CREATE INDEX ix_ma_head_session ON ma_session_head (tenant_key, session_id, head_kind); CREATE INDEX ix_ma_runtime_execution_storage ON ma_runtime_session (execution_storage_key); + +CREATE TABLE ma_session_worktree ( + tenant_id VARBINARY(512) NOT NULL, + session_id VARBINARY(512) NOT NULL, + workspace_id VARBINARY(512) NOT NULL, + storage_id VARBINARY(512) NOT NULL, + physical_key CHAR(64) NOT NULL, + worktree_id CHAR(32) NOT NULL, + owner_actor_key VARBINARY(2048) NOT NULL, + state VARCHAR(16) NOT NULL, + revision BIGINT NOT NULL, + plan_json LONGTEXT NOT NULL, + identity_json LONGTEXT, + operation_id VARBINARY(512) NOT NULL, + failure_code VARCHAR(128), + gmt_create BIGINT UNSIGNED NOT NULL, + gmt_modified BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (tenant_id, session_id), + CONSTRAINT uq_ma_session_worktree UNIQUE (tenant_id, worktree_id), + CONSTRAINT ck_ma_session_worktree_state CHECK ((CASE state WHEN 'PREPARING' THEN 9 WHEN 'READY' THEN 5 WHEN 'FAILED' THEN 6 WHEN 'BLOCKED' THEN 7 WHEN 'REMOVING' THEN 8 WHEN 'REMOVED' THEN 7 ELSE -1 END = CHAR_LENGTH(state))), + CONSTRAINT ck_ma_session_worktree_revision CHECK (revision > 0) +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX ix_ma_session_worktree_workspace ON ma_session_worktree (tenant_id, workspace_id, gmt_create); +CREATE INDEX ix_ma_session_worktree_storage ON ma_session_worktree (physical_key, state); diff --git a/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json b/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json index 7e074995048..91ae05bf6d9 100644 --- a/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json +++ b/packages/managed-agent-server/src/main/resources/openapi/managed-agent-public-api.openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Qwen Managed Agent Public and WebShell API", - "version": "1.94.0", + "version": "1.95.0", "description": "Canonical first-release contract for the public Managed Agent resources and the WebShell adapter. Implemented routes and fields describe the current matching Server, Broker, Harness and WebShell build. Partial entries have remaining implementation or contract-test work; planned entries are not current server capabilities and must be excluded from production SDKs. Turn submission and cancellation always return the durable operation identifier and its persisted admission and delivery state. Session-list cursors are bound to the normalized archive state and title query. Earlier development API shapes, checkpoints and database catalogs are unsupported; initialize fresh state for this build. Runtime feature capabilities and deployment policy still govern optional execution modes." }, "servers": [ @@ -6886,7 +6886,7 @@ { "name": "workspaceId", "in": "query", - "required": true, + "required": false, "schema": { "type": "string", "minLength": 1, @@ -6896,7 +6896,7 @@ { "name": "cwdRelative", "in": "query", - "required": true, + "required": false, "schema": { "type": "string", "minLength": 1, @@ -6933,6 +6933,16 @@ "maxLength": 36, "format": "uuid" } + }, + { + "name": "sessionId", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "description": "Exactly one locator: Session ID, or Workspace ID plus cwdRelative." } ], "requestBody": { @@ -7046,6 +7056,409 @@ } } } + }, + "/api/agent/web-shell/v1/workspaces/worktrees/options": { + "post": { + "operationId": "webShellSessionWorktreeOptions", + "tags": ["WebShell"], + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "summary": "Options a main Session worktree", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreeOptionsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreeOptions" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/api/agent/web-shell/v1/workspaces/worktrees/query": { + "post": { + "operationId": "webShellSessionWorktreeQuery", + "tags": ["WebShell"], + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "summary": "Query a main Session worktree", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreeQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreePage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/api/agent/web-shell/v1/workspaces/worktrees/inspect": { + "post": { + "operationId": "webShellSessionWorktreeInspect", + "tags": ["WebShell"], + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "summary": "Inspect a main Session worktree", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreeInspectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreeInspection" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/api/agent/web-shell/v1/workspaces/worktrees/cleanup": { + "post": { + "operationId": "webShellSessionWorktreeCleanup", + "tags": ["WebShell"], + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "summary": "Cleanup a main Session worktree", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktreeCleanupRequest" + } + } + } + }, + "responses": { + "202": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebShellSessionWorktree" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + } + } + }, + "/v1/agents/workspaces/{workspaceId}/worktrees/options": { + "get": { + "operationId": "sessionWorktreeOptions", + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "cwd_relative", + "in": "query", + "schema": { + "type": "string", + "default": "." + } + }, + { + "name": "base_ref", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionWorktreeOptions" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + }, + "summary": "Options a main Session worktree" + } + }, + "/v1/agents/workspaces/{workspaceId}/worktrees": { + "get": { + "operationId": "sessionWorktreeQuery", + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionWorktreePage" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + }, + "summary": "Query a main Session worktree" + } + }, + "/v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}": { + "get": { + "operationId": "sessionWorktreeInspect", + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "worktreeId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionWorktreeInspection" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + }, + "summary": "Inspect a main Session worktree" + } + }, + "/v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}/cleanup": { + "post": { + "operationId": "sessionWorktreeCleanup", + "x-qwen-implementation-status": "implemented", + "x-qwen-admission-rule": "session_worktree", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "worktreeId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Session worktree result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionWorktree" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + } + }, + "summary": "Cleanup a main Session worktree" + } } }, "components": { @@ -7522,6 +7935,19 @@ "$ref": "#/components/schemas/ManagedExtensionPackagePin" }, "description": "Optional immutable initial extension selection. Omission keeps the legacy configuration path; an explicit empty array starts the unified configuration format. Included in creation idempotency and applied before the first model request. Requires session-extension-packages/1 and an extension-capable original Runtime." + }, + "worktree": { + "type": "object", + "additionalProperties": false, + "properties": { + "base_ref": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + }, + "required": [], + "description": "Opt into a main Session worktree on supported LOCAL storage. Omission uses the current directory; an empty object freezes HEAD at first admission. Requires an explicit Workspace. Preparation is durable and gates Runtime execution; replay preserves the original commit, directory and branch." } } }, @@ -7756,6 +8182,16 @@ }, "recovery": { "$ref": "#/components/schemas/SessionRecovery" + }, + "worktree": { + "oneOf": [ + { + "$ref": "#/components/schemas/SessionWorktree" + }, + { + "type": "null" + } + ] } } }, @@ -8532,6 +8968,19 @@ "$ref": "#/components/schemas/ManagedExtensionPackagePin" }, "description": "Optional immutable initial extension selection. Omission keeps the legacy configuration path; an explicit empty array starts the unified configuration format. Included in creation idempotency and applied before the first model request. Requires session-extension-packages/1 and an extension-capable original Runtime." + }, + "worktree": { + "type": "object", + "additionalProperties": false, + "properties": { + "baseRef": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + }, + "required": [], + "description": "Opt into a main Session worktree on supported LOCAL storage. Omission uses the current directory; an empty object freezes HEAD at first admission. Requires an explicit Workspace. Preparation is durable and gates Runtime execution; replay preserves the original commit, directory and branch." } } }, @@ -8817,6 +9266,16 @@ }, "recovery": { "$ref": "#/components/schemas/SessionRecovery" + }, + "worktree": { + "oneOf": [ + { + "$ref": "#/components/schemas/WebShellSessionWorktree" + }, + { + "type": "null" + } + ] } } }, @@ -14241,7 +14700,7 @@ "WebShellWorkspaceFileQueryRequest": { "type": "object", "additionalProperties": false, - "required": ["workspaceId", "cwdRelative", "directory"], + "required": ["directory"], "properties": { "workspaceId": { "type": "string", @@ -14265,18 +14724,38 @@ "expectedScopeRevision": { "type": "string", "maxLength": 64 + }, + "sessionId": { + "type": "string", + "format": "uuid" } - } + }, + "oneOf": [ + { + "required": ["sessionId"], + "not": { + "anyOf": [ + { + "required": ["workspaceId"] + }, + { + "required": ["cwdRelative"] + } + ] + } + }, + { + "required": ["workspaceId", "cwdRelative"], + "not": { + "required": ["sessionId"] + } + } + ] }, "WebShellWorkspaceFileReadRequest": { "type": "object", "additionalProperties": false, - "required": [ - "workspaceId", - "cwdRelative", - "path", - "expectedScopeRevision" - ], + "required": ["path", "expectedScopeRevision"], "properties": { "workspaceId": { "type": "string", @@ -14297,8 +14776,33 @@ "type": "string", "minLength": 1, "maxLength": 64 + }, + "sessionId": { + "type": "string", + "format": "uuid" } - } + }, + "oneOf": [ + { + "required": ["sessionId"], + "not": { + "anyOf": [ + { + "required": ["workspaceId"] + }, + { + "required": ["cwdRelative"] + } + ] + } + }, + { + "required": ["workspaceId", "cwdRelative"], + "not": { + "required": ["sessionId"] + } + } + ] }, "WebShellWorkspaceFileEntry": { "type": "object", @@ -15958,6 +16462,283 @@ "format": "uuid" } } + }, + "SessionWorktree": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "base_commit": { + "type": "string" + }, + "cwd_relative": { + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "preparing", + "ready", + "failed", + "blocked", + "removing", + "removed" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "error_code": { + "type": ["string", "null"] + } + }, + "required": [ + "id", + "session_id", + "workspace_id", + "branch", + "base_commit", + "cwd_relative", + "operation_id", + "state", + "revision" + ] + }, + "WebShellSessionWorktree": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "baseCommit": { + "type": "string" + }, + "cwdRelative": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "preparing", + "ready", + "failed", + "blocked", + "removing", + "removed" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "errorCode": { + "type": ["string", "null"] + } + }, + "required": [ + "id", + "sessionId", + "workspaceId", + "branch", + "baseCommit", + "cwdRelative", + "operationId", + "state", + "revision" + ] + }, + "WebShellSessionWorktreeOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "supported": { + "type": "boolean" + }, + "reason": { + "type": ["string", "null"] + }, + "baseCommit": { + "type": ["string", "null"] + }, + "currentBranch": { + "type": ["string", "null"], + "description": "Source checkout branch independent of base ref; HEAD when detached, null when unavailable." + }, + "branches": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["supported", "branches"] + }, + "SessionWorktreeOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "supported": { + "type": "boolean" + }, + "reason": { + "type": ["string", "null"] + }, + "base_commit": { + "type": ["string", "null"] + }, + "current_branch": { + "type": ["string", "null"], + "description": "Source checkout branch independent of base ref; HEAD when detached, null when unavailable." + }, + "branches": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["supported", "branches"] + }, + "WebShellSessionWorktreePage": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebShellSessionWorktree" + } + } + }, + "required": ["data"] + }, + "WebShellSessionWorktreeInspection": { + "type": "object", + "additionalProperties": false, + "properties": { + "worktree": { + "$ref": "#/components/schemas/WebShellSessionWorktree" + }, + "canCleanup": { + "type": "boolean" + }, + "cleanupBlocker": { + "type": ["string", "null"] + } + }, + "required": ["worktree", "canCleanup"] + }, + "SessionWorktreePage": { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionWorktree" + } + } + }, + "required": ["data"] + }, + "SessionWorktreeInspection": { + "type": "object", + "additionalProperties": false, + "properties": { + "worktree": { + "$ref": "#/components/schemas/SessionWorktree" + }, + "can_cleanup": { + "type": "boolean" + }, + "cleanup_blocker": { + "type": ["string", "null"] + } + }, + "required": ["worktree", "can_cleanup"] + }, + "WebShellSessionWorktreeOptionsRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string" + }, + "cwdRelative": { + "type": "string" + }, + "baseRef": { + "type": "string" + } + }, + "required": ["workspaceId", "cwdRelative"] + }, + "WebShellSessionWorktreeQueryRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string" + } + }, + "required": ["workspaceId"] + }, + "WebShellSessionWorktreeInspectRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string" + }, + "worktreeId": { + "type": "string" + } + }, + "required": ["workspaceId", "worktreeId"] + }, + "WebShellSessionWorktreeCleanupRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "workspaceId": { + "type": "string" + }, + "worktreeId": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + } + }, + "required": ["workspaceId", "worktreeId", "idempotencyKey"] } } } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java index c95a597f450..cc33f1bc157 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/BackfillWitnessesTest.java @@ -30,7 +30,7 @@ class BackfillWitnessesTest { + " table_schema = 'public' AND table_type='BASE" + " TABLE'", Integer.class)) - .isEqualTo(39); + .isEqualTo(40); assertThat( new JdbcTemplate(source) .queryForObject( diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java index 4479aed6b92..033aa219bf4 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentApiContractTest.java @@ -152,6 +152,15 @@ class ManagedAgentApiContractTest { private static final OpenApiContract CONTRACT = OpenApiContract.load(); private static final Map, List> RECORD_SCHEMAS = Map.ofEntries( + entry(com.alibaba.qwen.code.managedagent.api.SessionWorktreeController.OptionsRequest.class, List.of("WebShellSessionWorktreeOptionsRequest")), + entry(com.alibaba.qwen.code.managedagent.api.SessionWorktreeController.QueryRequest.class, List.of("WebShellSessionWorktreeQueryRequest")), + entry(com.alibaba.qwen.code.managedagent.api.SessionWorktreeController.InspectRequest.class, List.of("WebShellSessionWorktreeInspectRequest")), + entry(com.alibaba.qwen.code.managedagent.api.SessionWorktreeController.CleanupRequest.class, List.of("WebShellSessionWorktreeCleanupRequest")), + entry(com.alibaba.qwen.code.managedagent.api.SessionWorktreeController.Page.class, List.of("WebShellSessionWorktreePage")), + entry(com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService.Options.class, List.of("WebShellSessionWorktreeOptions")), + entry(com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService.Inspection.class, List.of("WebShellSessionWorktreeInspection")), + entry(com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Descriptor.class, List.of("WebShellSessionWorktree")), + entry(ApiModels.PublicSessionWorktree.class, List.of("SessionWorktree")), entry(ApiModels.WebShellLifecycleOperation.class, List.of("WebShellLifecycleOperation")), entry(ApiModels.WebShellExportRequest.class, List.of("WebShellExportRequest")), entry(ApiModels.WebShellExportPage.class, List.of("WebShellExportPage")), @@ -348,6 +357,7 @@ class ManagedAgentApiContractTest { private FixtureHarness harness; @Autowired + @Qualifier("managedJdbcTemplate") private JdbcTemplate jdbc; @Autowired @@ -1665,6 +1675,27 @@ class ManagedAgentApiContractTest { post(WEB_SHELL + "/user-messages/command").header(TENANT, tenant).principal(actor(tenant)), "{\"sessionId\":\"missing\",\"idempotencyKey\":\"queue-missing\",\"command\":{\"action\":\"resume\"}}"); exchangeExtensions(drift, workspaceTenant, actor, publicBoundId); + exchange(drift, "webShellSessionWorktreeOptions", 200, + post(WEB_SHELL + "/workspaces/worktrees/options").header(TENANT, workspaceTenant).principal(actor), + "{\"workspaceId\":\"ws-default\",\"cwdRelative\":\".\"}"); + exchange(drift, "webShellSessionWorktreeQuery", 200, + post(WEB_SHELL + "/workspaces/worktrees/query").header(TENANT, workspaceTenant).principal(actor), + "{\"workspaceId\":\"ws-default\"}"); + exchange(drift, "webShellSessionWorktreeInspect", 404, + post(WEB_SHELL + "/workspaces/worktrees/inspect").header(TENANT, workspaceTenant).principal(actor), + "{\"workspaceId\":\"ws-default\",\"worktreeId\":\"missing\"}"); + exchange(drift, "webShellSessionWorktreeCleanup", 404, + post(WEB_SHELL + "/workspaces/worktrees/cleanup").header(TENANT, workspaceTenant).principal(actor), + "{\"workspaceId\":\"ws-default\",\"worktreeId\":\"missing\",\"idempotencyKey\":\"cleanup\"}"); + exchange(drift, "sessionWorktreeOptions", 200, + get("/v1/agents/workspaces/ws-default/worktrees/options").header(TENANT, workspaceTenant).principal(actor), null); + exchange(drift, "sessionWorktreeQuery", 200, + get("/v1/agents/workspaces/ws-default/worktrees").header(TENANT, workspaceTenant).principal(actor), null); + exchange(drift, "sessionWorktreeInspect", 404, + get("/v1/agents/workspaces/ws-default/worktrees/missing").header(TENANT, workspaceTenant).principal(actor), null); + exchange(drift, "sessionWorktreeCleanup", 404, + post("/v1/agents/workspaces/ws-default/worktrees/missing/cleanup").header(TENANT, workspaceTenant).principal(actor) + .header(IDEMPOTENCY_KEY, "cleanup"), null); assertThat(exercised).containsExactlyInAnyOrderElementsOf( CONTRACT.operations().stream() .filter(operation -> !"planned".equals( diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java index 09b9d5dbe7c..6929bf80468 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedWorkspaceAdmissionTest.java @@ -15,7 +15,9 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionMutationKind; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentDefinitionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedUserMessageStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; import com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore; import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; import com.alibaba.qwen.code.managedagent.session.ManagedAgentService; @@ -44,6 +46,7 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import java.time.Clock; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.UUID; @@ -2354,6 +2357,159 @@ class ManagedWorkspaceAdmissionTest { return workspaceFilesService(tenant, workspaceFiles, null); } + @Test + void preparingWorktreesProjectTheirTargetWithoutAdmittingMoreInput() throws Exception { + String tenant = "tenant-" + UUID.randomUUID(); + String session = boundSession(tenant); + persistWorktree(tenant, session, "PREPARING"); + ManagedAgentService enabled = boundServiceWithWorkingHarness(); + var summary = enabled.getWebShellSession(tenant, "actor-a", session); + assertThat(summary.workspace().state()).isEqualTo("changing"); + assertThat(summary.workspace().cwdRelative()).startsWith(".qwen-managed-worktrees/"); + assertThat(summary.worktree().state()).isEqualTo("preparing"); + assertThat(summary.capabilities().workspaceTurns()).isFalse(); + assertRefused(() -> enabled.submitTurn(tenant, "actor-a", "next", session, + List.of(new InputBlock("text", "go"))), "worktree_preparing"); + mvc.perform(get("/v1/agents/sessions/{sessionId}", session).header(TenantContextFilter.HEADER, tenant).principal(actor(tenant, "actor-a"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.worktree.session_id").value(session)) + .andExpect(jsonPath("$.worktree.base_commit").value("a".repeat(40))) + .andExpect(jsonPath("$.worktree.state").value("preparing")); + } + + @ParameterizedTest + @ValueSource(strings = {"PREPARING", "FAILED", "BLOCKED", "REMOVING", "REMOVED"}) + void unreadyWorktreesAllowQueueManagementButKeepRuntimeActionsGated(String state) throws Exception { + String tenant = "tenant-" + UUID.randomUUID(); + String session = boundSession(tenant); + grant(tenant, "ws-a", "operator2", true); + grant(tenant, "ws-a", "reader", false); + HarnessConnector harness = mock(HarnessConnector.class); + when(harness.isAvailable()).thenReturn(true); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(harness.supportsFencedTitles()).thenReturn(true); + when(harness.supportsUserMessageQueue()).thenReturn(true); + var enabled = boundServiceWith(harness); + var messages = new ManagedUserMessageStore(jdbc, mapper, new RequestDigests(), enabled); + var transaction = new TransactionTemplate(transactionManager); + var input = List.of(new InputBlock("text", "queued")); + var first = transaction.execute(status -> messages.command(tenant, "actor-a", session, "seed", + new ManagedUserMessageStore.Command("submit", null, null, input))).messages().getFirst(); + persistWorktree(tenant, session, state); + + assertThat(transaction.execute(status -> messages.queryMessages(tenant, "operator2", session)).messages()).hasSize(1); + var edited = transaction.execute(status -> messages.command(tenant, "operator2", session, "edit", + new ManagedUserMessageStore.Command("edit", first.id(), 1L, List.of(new InputBlock("text", "edited"))))); + assertThat(edited.messages().getFirst().revision()).isEqualTo(2); + assertThat(edited.messages().getFirst().input().getFirst().get("text")).isEqualTo("edited"); + assertRefused(() -> transaction.execute(status -> messages.command(tenant, "operator2", session, "new", + new ManagedUserMessageStore.Command("submit", null, null, input))), "worktree_" + state.toLowerCase(java.util.Locale.ROOT)); + assertRefused(() -> transaction.execute(status -> messages.command(tenant, "operator2", session, "resume", + new ManagedUserMessageStore.Command("resume", null, null, null))), "worktree_" + state.toLowerCase(java.util.Locale.ROOT)); + assertThat(transaction.execute(status -> messages.command(tenant, "operator2", session, "withdraw", + new ManagedUserMessageStore.Command("withdraw", first.id(), 2L, null))).messages()).isEmpty(); + assertRefused(() -> enabled.submitTurn(tenant, "operator2", "submit", session, input), + "worktree_" + state.toLowerCase(java.util.Locale.ROOT)); + assertRefused(() -> enabled.renameSession(tenant, "operator2", "rename", session, "renamed"), + "worktree_" + state.toLowerCase(java.util.Locale.ROOT)); + assertThat(enabled.getWebShellSession(tenant, "operator2", session).capabilities().rename()).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id = ? AND session_id = ?", Integer.class, tenant, session)).isZero(); + verify(harness, never()).createOrLoad(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyBoolean()); + assertRefused(() -> enabled.authorizeUserMessages(tenant, "reader", session), "session_operation_forbidden"); + jdbc.update("UPDATE ma_workspace_access SET role = 'READER' WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", + tenant, "ws-a", ManagedWorkspaceRegistry.actorKey(tenant, "actor-a")); + assertRefused(() -> enabled.authorizeUserMessages(tenant, "operator2", session), "workspace_unavailable"); + } + + @Test + void sessionPagesPreserveWorktreeGatingAndBlockInvalidReadyIdentities() throws Exception { + for (String state : List.of("PREPARING", "FAILED", "READY")) { + String tenant = "tenant-" + UUID.randomUUID(); + String session = boundSession(tenant); + persistWorktree(tenant, session, state); + var enabled = boundServiceWithWorkingHarness(); + var publicPage = enabled.listPublicSessions(tenant, "actor-a", null, 1).data(); + assertThat(publicPage).hasSize(1); + String expected = "READY".equals(state) ? "blocked" : state.toLowerCase(java.util.Locale.ROOT); + assertThat(publicPage.getFirst().worktree().sessionId()).isEqualTo(session); + assertThat(publicPage.getFirst().worktree().state()).isEqualTo(expected); + var page = enabled.listWebShellSessions(tenant, "actor-a", null, 1).data(); + assertThat(page).hasSize(1); + assertThat(page.getFirst().worktree().state()).isEqualTo(expected); + assertThat(page.getFirst().capabilities().workspaceTurns()).isFalse(); + if ("READY".equals(state)) { + assertThat(jdbc.queryForObject("SELECT state FROM ma_session_worktree WHERE tenant_id = ? AND session_id = ?", + String.class, tenant, session)).isEqualTo("BLOCKED"); + } + } + } + + @Test + void worktreePreparationGatesDispatchButPreservesCancellation() throws Exception { + String tenant = "tenant-" + UUID.randomUUID(); + String session = boundSession(tenant); + var enabled = boundServiceWithWorkingHarness(); + var admission = enabled.submitTurn(tenant, "actor-a", "initial", session, List.of(new InputBlock("text", "go"))); + persistWorktree(tenant, session, "PREPARING"); + assertThat(store.findDispatchable(System.currentTimeMillis(), 1000)) + .noneMatch(target -> target.sessionId().equals(session)); + assertThat(store.claimTurn(tenant, session, admission.turnId(), "owner", Duration.ofSeconds(30))).isEmpty(); + + enabled.cancelTurn(tenant, "actor-a", "cancel-initial", session, admission.turnId()); + assertThat(store.findDispatchable(System.currentTimeMillis(), 1000)) + .anyMatch(target -> target.sessionId().equals(session)); + assertThat(store.claimTurn(tenant, session, admission.turnId(), "owner", Duration.ofSeconds(30))).isPresent(); + store.cancelBeforeAdmission(tenant, session, admission.turnId(), "owner"); + assertThat(store.findTurn(tenant, session, admission.turnId()).orElseThrow().status()).isEqualTo("CANCELLED"); + } + + @Test + void failedAndBlockedWorktreesStillAllowCancellation() throws Exception { + for (String state : List.of("FAILED", "BLOCKED")) { + String tenant = "tenant-" + UUID.randomUUID(); + String session = boundSession(tenant); + var enabled = boundServiceWithWorkingHarness(); + var admission = enabled.submitTurn(tenant, "actor-a", "initial", session, List.of(new InputBlock("text", "go"))); + persistWorktree(tenant, session, state); + enabled.cancelTurn(tenant, "actor-a", "cancel-initial", session, admission.turnId()); + assertThat(store.findTurn(tenant, session, admission.turnId()).orElseThrow().status()).isEqualTo("CANCELLING"); + } + } + + @Test + void failedWorktreePreparationSettlesInitialInputOnce() throws Exception { + String tenant = "tenant-" + UUID.randomUUID(); + String session = boundSession(tenant); + var admission = boundServiceWithWorkingHarness().submitTurn(tenant, "actor-a", "initial", session, + List.of(new InputBlock("text", "go"))); + persistWorktree(tenant, session, "FAILED"); + store.failWorktreeInitialTurns(tenant, session, "worktree_prepare_failed"); + store.failWorktreeInitialTurns(tenant, session, "worktree_prepare_failed"); + assertThat(store.findTurn(tenant, session, admission.turnId()).orElseThrow().status()).isEqualTo("FAILED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_public_event WHERE tenant_id = ? AND session_id = ?" + + " AND event_type = 'turn.failed'", Integer.class, tenant, session)).isEqualTo(1); + } + + private void persistWorktree(String tenant, String session, String state) throws Exception { + String id = UUID.randomUUID().toString().replace("-", ""); + String cwd = ".qwen-managed-worktrees/" + id; + var binding = store.requireSession(tenant, session).workspace(); + var plan = new SessionWorktreeStore.Plan(tenant, "ws-a", 1, "storage-a", ".", + binding.getContextConfigRef(), 1, "/workspace", "root-identity", ".", ".", + "/workspace/.git", "common-identity", "main", "a".repeat(40), id, cwd, cwd, "qwen/" + id); + jdbc.update("UPDATE ma_session SET cwd_relative = ? WHERE tenant_id = ? AND session_id = ?", cwd, tenant, session); + jdbc.update("INSERT INTO ma_operation (operation_key, scope_key, operation_id, operation_kind, tenant_id," + + " session_id, target_kind, target_key, request_digest, request_json, state, phase, gmt_create, gmt_modified)" + + " VALUES (?, ?, ?, 'SESSION_WORKTREE', ?, ?, 'SESSION', ?, 'digest', '{}', ?, ?, 1, 1)", + ManagedSchemaKeys.key(tenant, id), ManagedSchemaKeys.key(tenant), id, tenant, session, + ManagedSchemaKeys.key(session), state, state); + jdbc.update("INSERT INTO ma_session_worktree (tenant_id, session_id, workspace_id, storage_id, physical_key," + + " worktree_id, owner_actor_key, state, revision, plan_json, operation_id, gmt_create, gmt_modified)" + + " VALUES (?, ?, 'ws-a', 'storage-a', ?, ?, ?, ?, 1, ?, ?, 1, 1)", tenant, session, + "a".repeat(64), id, ManagedWorkspaceRegistry.actorKey(tenant, "actor-a"), state, mapper.writeValueAsString(plan), id); + } + private ManagedAgentService workspaceFilesService(String tenant, boolean workspaceFiles, HarnessCoordinator coordinator) { ManagedAgentProperties properties = new ManagedAgentProperties(); @@ -2414,6 +2570,11 @@ class ManagedWorkspaceAdmissionTest { public void dispatch(String tenantId, String sessionId, String turnId) { } + + @Override + public void cancel(String tenantId, String sessionId, + String turnId) { + } }; return new ManagedAgentService(gated, new RequestDigests(), noopCoordinator, harness, registry); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java index 88570762885..1682bb804bb 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/SurfaceAdmissionAcceptanceTest.java @@ -373,6 +373,15 @@ class SurfaceAdmissionAcceptanceTest { void belowReadAndBelowFamilyProbesFollowTheRuleClass(SurfaceRegistry entry) throws Exception { switch (entry.ruleClass()) { + case SESSION_WORKTREE -> { + expect(entry, null, 401, "actor_required"); + if (entry.template().endsWith("/options")) { + expect(entry, STRANGER, 404, "workspace_not_found"); + expect(entry, READER, 403, "workspace_forbidden"); + } else { + expect(entry, STRANGER, 404, "worktree_not_found"); + } + } case SESSION_EXTENSION_READER -> { expect(entry, null, 401, "actor_required"); expect(entry, STRANGER, 404, "session_not_found"); @@ -1492,6 +1501,7 @@ class SurfaceAdmissionAcceptanceTest { ? pendingActionPublic : pendingActionWeb : pendingAction); variables.put("workspaceId", "ws"); + variables.put("worktreeId", "worktree-probe"); variables.put("extensionId", "ext_11111111-1111-4111-8111-111111111111"); variables.put("packageRevision", "1"); variables.put("packageDigest", digest()); @@ -1607,6 +1617,7 @@ class SurfaceAdmissionAcceptanceTest { Capability capability = entry.capabilities().iterator().next(); boolean publicSurface = entry.surface() == Surface.PUBLIC; return switch (capability) { + case SESSION_WORKTREE -> "{\"workspaceId\":\"ws\",\"cwdRelative\":\".\",\"worktreeId\":\"worktree-probe\",\"idempotencyKey\":\"cleanup-probe\"}"; case STORE_SNAPSHOT_PUBLISH -> "{\"workspaceId\":\"ws\",\"writerId\":\"writer-probe\"," + "\"writerGeneration\":1,\"snapshotId\":\"snapshot-probe\",\"expectedJournalRevision\":1," + "\"expectedCommittedSequence\":0}"; diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java index e847686aa80..dfff7950ea9 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/SurfaceRegistry.java @@ -23,6 +23,22 @@ import java.util.Set; * exactly one rule class. */ public enum SurfaceRegistry { + WEBSHELL_WORKTREE_OPTIONS(Method.POST, "/api/agent/web-shell/v1/workspaces/worktrees/options", + Surface.WEBSHELL, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + WEBSHELL_WORKTREE_QUERY(Method.POST, "/api/agent/web-shell/v1/workspaces/worktrees/query", + Surface.WEBSHELL, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + WEBSHELL_WORKTREE_INSPECT(Method.POST, "/api/agent/web-shell/v1/workspaces/worktrees/inspect", + Surface.WEBSHELL, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + WEBSHELL_WORKTREE_CLEANUP(Method.POST, "/api/agent/web-shell/v1/workspaces/worktrees/cleanup", + Surface.WEBSHELL, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + PUBLIC_WORKTREE_OPTIONS(Method.GET, "/v1/agents/workspaces/{workspaceId}/worktrees/options", + Surface.PUBLIC, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + PUBLIC_WORKTREE_QUERY(Method.GET, "/v1/agents/workspaces/{workspaceId}/worktrees", + Surface.PUBLIC, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + PUBLIC_WORKTREE_INSPECT(Method.GET, "/v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}", + Surface.PUBLIC, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), + PUBLIC_WORKTREE_CLEANUP(Method.POST, "/v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}/cleanup", + Surface.PUBLIC, RuleClass.SESSION_WORKTREE, EnumSet.of(Capability.SESSION_WORKTREE)), PUBLIC_USER_MESSAGES_QUERY(Method.GET, "/v1/agents/sessions/{sessionId}/user-messages", Surface.PUBLIC, RuleClass.OPERATOR, EnumSet.of(Capability.USER_MESSAGE_QUERY)), PUBLIC_USER_MESSAGES_COMMAND(Method.POST, "/v1/agents/sessions/{sessionId}/user-messages", @@ -671,6 +687,7 @@ public enum SurfaceRegistry { * carries both {@link #TURN_SUBMIT} and {@link #TURN_CANCEL}. */ public enum Capability { + SESSION_WORKTREE, EXTERNAL_STORAGE_REGISTER, USER_MESSAGE_QUERY, USER_MESSAGE_COMMAND, STORE_USER_MESSAGE_CLAIM, SESSION_CREATE, @@ -783,6 +800,7 @@ public enum SurfaceRegistry { * acceptance suite walks these classes to build its probe matrix. */ public enum RuleClass { + SESSION_WORKTREE, /** Trusted actor with a read grant on the persisted Workspace; operation reads also retain actor ownership. */ SESSION_EXTENSION_READER, SESSION_EXTENSION_OPERATOR, diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileControllerTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileControllerTest.java index e977ba2a364..6c44694ea9d 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileControllerTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/api/WorkspaceFileControllerTest.java @@ -18,6 +18,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -26,13 +27,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class WorkspaceFileControllerTest { + WorkspaceFileStore files; WorkspaceFileUploadStore uploads; MockMvc mvc; @BeforeEach void setup() { uploads = mock(WorkspaceFileUploadStore.class); - mvc = MockMvcBuilders.standaloneSetup(new WorkspaceFileController(mock(WorkspaceFileStore.class), uploads)) + files = mock(WorkspaceFileStore.class); + mvc = MockMvcBuilders.standaloneSetup(new WorkspaceFileController(files, uploads)) .setControllerAdvice(new ApiExceptionHandler()) .setCustomArgumentResolvers(new TenantContextArgumentResolver()).build(); } @@ -41,11 +44,12 @@ class WorkspaceFileControllerTest { void rawUploadPreservesBinaryBytesAndReturnsTheScopedResult() throws Exception { byte[] bytes = new byte[] {0, (byte) 0xff, 1}; String id = UUID.randomUUID().toString(); - when(uploads.upload(anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), anyString(), any(), anyLong())) + when(uploads.upload(anyString(), anyString(), any(WorkspaceFileStore.Locator.class), anyString(), anyString(), anyString(), any(), anyLong())) .thenAnswer(call -> { assertThat(call.getArgument(0)).isEqualTo("tenant"); assertThat(call.getArgument(1)).isEqualTo("actor"); - assertThat(call.getArgument(7).readAllBytes()).isEqualTo(bytes); + assertThat(call.getArgument(2)).isEqualTo(new WorkspaceFileStore.Locator(null, "workspace", ".")); + assertThat(call.getArgument(6).readAllBytes()).isEqualTo(bytes); return new WorkspaceFileUploadStore.UploadResult("a".repeat(64), id, "binary.bin", bytes.length, DigestUtils.sha256Hex(bytes)); }); mvc.perform(post("/api/agent/web-shell/v1/workspaces/files/upload") @@ -57,6 +61,29 @@ class WorkspaceFileControllerTest { .andExpect(jsonPath("$.uploadId").value(id)).andExpect(jsonPath("$.sizeBytes").value(3)); } + @Test + void sessionLocatorDoesNotAcceptCallerWorkspaceOrDirectory() throws Exception { + mvc.perform(post("/api/agent/web-shell/v1/workspaces/files/query") + .requestAttr(TenantContextFilter.ATTRIBUTE, new TenantContext("tenant", "actor")) + .contentType(MediaType.APPLICATION_JSON).content("{\"sessionId\":\"session\",\"directory\":\".\"}")) + .andExpect(status().isOk()); + verify(files).query("tenant", "actor", new WorkspaceFileStore.Locator("session", null, null), ".", "", null); + for (String locator : new String[] {"", "\"workspaceId\":\"workspace\",", "\"sessionId\":\"session\",\"cwdRelative\":\".\",", + "\"sessionId\":\"session\",\"workspaceId\":\"workspace\",\"cwdRelative\":\".\","}) { + mvc.perform(post("/api/agent/web-shell/v1/workspaces/files/read") + .requestAttr(TenantContextFilter.ATTRIBUTE, new TenantContext("tenant", "actor")) + .contentType(MediaType.APPLICATION_JSON).content("{" + locator + "\"path\":\"a.txt\",\"expectedScopeRevision\":\"" + "a".repeat(64) + "\"}")) + .andExpect(status().isBadRequest()); + } + mvc.perform(post("/api/agent/web-shell/v1/workspaces/files/upload") + .requestAttr(TenantContextFilter.ATTRIBUTE, new TenantContext("tenant", "actor")) + .param("sessionId", "session").param("workspaceId", "workspace").param("cwdRelative", ".").param("path", "file.bin") + .param("expectedScopeRevision", "a".repeat(64)).param("uploadId", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_OCTET_STREAM).content(new byte[0])) + .andExpect(status().isBadRequest()); + verifyNoInteractions(uploads); + } + @Test void uploadsRequireATrustedActorAndUnencodedOctetStream() throws Exception { for (boolean actor : new boolean[] {false, true}) { diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentPropertiesTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentPropertiesTest.java index 58f083574e9..c2e9b13e007 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentPropertiesTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentPropertiesTest.java @@ -545,6 +545,7 @@ class ManagedAgentPropertiesTest { "events.replay-floor-interval", "message-relay.scan-delay", "runtime-broker.retention.scan-delay", + "session-worktree.scan-delay", "wake-recovery.scan-delay"); var placeholderOnly = new java.util.TreeSet(); for (String key : swept) { diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java index f214fdce245..48b8a81a7fe 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java @@ -145,11 +145,13 @@ class SessionRowLockOrderTest { "ResidentWorkspaceRetirementStore.java", "ResidentWorkspaces.java", "ToolPublicationRetentionStore.java", "WakeRecoveryStore.java", + "WorkspaceFileStore.java", "WorkspaceLifecycleStore.java", "WorkspaceRecoveryStore.java"); private static final Set SIBLING_TURN_READERS = Set.of( "AutomationLedgerStore.java", "ChildResultRelayStore.java", "ManagedActionStore.java", "ManagedToolDetailsStore.java", "ManagedToolResultProjector.java", "ManagedUserMessageStore.java", + "SessionWorktreeStore.java", "WorkspaceCsiApplicationInventory.java", "WorkspaceExecutionStore.java", "WorkspaceMigrationStore.java", "WorkspaceRecoveryStore.java"); @@ -250,6 +252,11 @@ class SessionRowLockOrderTest { Set.of("SessionMessageRelayStore.pending/2")), Map.entry("SessionResourceCollectionCollector.java", Set.of("SessionResourceCollectionCollector.runOnce/0")), + // Value projections and the JDBC row mapper reach neither + // Session locks nor Turn rows. + Map.entry("SessionWorktreeStore.java", + Set.of("Descriptor.worktreeId/0", "Plan.source/0", "Plan.target/1", + "Row.descriptor/0", "Row.descriptor/1", "SessionWorktreeStore.row/2")), // D7-2's parked registration asks its record whether it // reserved, through a record accessor the parse cannot // resolve. @@ -422,7 +429,7 @@ class SessionRowLockOrderTest { "bindRecoveredHarness", "recordAdmission", "recordRecoveryAdmission", "retractContinuationOutput", "retractHarnessTurnOutput", "recordHarnessEvents", - "cancelUnadmittedTurn", "cancelBeforeAdmission", "failTurn", + "cancelUnadmittedTurn", "cancelBeforeAdmission", "failTurn", "failWorktreeInitialTurns", "failRecoveryBlockedTurn", "markTurnFailureRecovery", "requestRecoveryRecheck", "appendPublicEventIfAbsent", "appendLiveSessionEventIfAbsent", @@ -468,7 +475,7 @@ class SessionRowLockOrderTest { "bindHarness", "bindRecoveredHarness", "cancelBeforeAdmission", "changeSessionModel", "changeSessionApproval", "claimTurn", "completeCwdChangeOperation", "completeHarnessTurn", - "deferTurnRetry", "failTurn", "failRecoveryBlockedTurn", + "deferTurnRetry", "failTurn", "failRecoveryBlockedTurn", "failWorktreeInitialTurns", "settleFailedTurn", "findActiveTurns", "findDispatchable", "findLatestTurns", "findReplayFloorTargets", "findTurn", "findTurnModelSelection", "findTurnApprovalSelection", @@ -534,7 +541,7 @@ class SessionRowLockOrderTest { // assertion still green. Pin the boundary on the R2 entry points. @Test void theOrderPinnedEntriesKeepTheirTransactionalBoundary() { - for (String name : List.of("recordAdmission", "failTurn", "failRecoveryBlockedTurn", + for (String name : List.of("recordAdmission", "failTurn", "failRecoveryBlockedTurn", "failWorktreeInitialTurns", "cancelUnadmittedTurn", "cancelBeforeAdmission", "beginContextCompression", "markContextCompressionSubmitted", "markTurnFailureRecovery", "requestRecoveryRecheck")) { diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeMySqlIT.java new file mode 100644 index 00000000000..3fe2f9b3b68 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeMySqlIT.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import org.junit.jupiter.api.AfterEach; + +import javax.sql.DataSource; + +class SessionWorktreeMySqlIT extends SessionWorktreeStoreTest { + private IsolatedManagedSchemaMySqlTestDatabase database; + + @Override + protected DataSource source() { + database = new IsolatedManagedSchemaMySqlTestDatabase(); + return database.source(); + } + + @AfterEach + void closeDatabase() { + if (database != null) { + database.close(); + } + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStoreTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStoreTest.java new file mode 100644 index 00000000000..a907b9b6add --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionWorktreeStoreTest.java @@ -0,0 +1,223 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Plan; +import com.alibaba.qwen.code.managedagent.workspace.ChildWorktreeGit; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeGit; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.sql.DataSource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SessionWorktreeStoreTest { + @TempDir + Path temp; + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private SessionWorktreeStore store; + private String workspace; + + protected DataSource source() { + return new DriverManagerDataSource("jdbc:h2:mem:session-worktree-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); + } + + @BeforeEach + void setup() throws Exception { + DataSource source = source(); + ManagedSchema.initialize(source); + jdbc = new ManagedSchemaJdbcTemplate(source); + var manager = new DataSourceTransactionManager(source); + transaction = new TransactionTemplate(manager); + var properties = new ManagedAgentProperties(); + properties.getRuntimeBroker().setWorkspaceCreationRoot(Files.createDirectory(temp.resolve("created")).toRealPath().toString()); + properties.getRuntimeBroker().setVerifiedWorkspaceRecoveryEnabled(true); + var registry = new ManagedWorkspaceRegistry(jdbc); + var guard = new WorkspaceStorageGuard(jdbc, manager, properties, + path -> new WorkspaceStorageGuard.Identity(path.toString(), "test-host", "1", "2", "2026-10-15T00:00:00.123456789Z")); + workspace = new WorkspaceCreationStore(jdbc, manager, guard, registry, properties, new ObjectMapper(), Clock.systemUTC()) + .create("tenant", "actor", "Worktrees", "create").workspaceId(); + store = new SessionWorktreeStore(jdbc, registry, guard); + } + + @Test + void usesManagedJdbcWhenTheHostProvidesDefaultPersistenceBeans() { + var host = new DriverManagerDataSource("jdbc:h2:mem:host-" + UUID.randomUUID(), "sa", ""); + new ApplicationContextRunner().withUserConfiguration(SessionWorktreeStore.class) + .withBean("managedJdbcTemplate", JdbcTemplate.class, () -> jdbc, definition -> ((org.springframework.beans.factory.support.AbstractBeanDefinition) definition).setDefaultCandidate(false)) + .withBean("hostJdbcTemplate", JdbcTemplate.class, () -> new JdbcTemplate(host)) + .withBean("hostTransactionManager", org.springframework.transaction.PlatformTransactionManager.class, + () -> new DataSourceTransactionManager(host)) + .withBean(ManagedWorkspaceRegistry.class, () -> new ManagedWorkspaceRegistry(jdbc)) + .withBean(WorkspaceStorageGuard.class, () -> org.mockito.Mockito.mock(WorkspaceStorageGuard.class)) + .withBean(SshWorkspaceStore.class, () -> org.mockito.Mockito.mock(SshWorkspaceStore.class)) + .withBean(ManagedAgentProperties.class, ManagedAgentProperties::new) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context.getBean(SessionWorktreeStore.class).describe("tenant", "missing")).isNull(); + }); + } + + @Test + void durablePreparationClaimAndFilesystemIdentityGate() throws Exception { + var location = store.source("tenant", "actor", new WorkspaceSelection(workspace, ".")); + Path repository = Files.createDirectory(location.root().resolve("project")); + git(repository, "init", "-b", "main"); + Files.writeString(repository.resolve("file.txt"), "base\n"); + git(repository, "add", "."); + git(repository, "commit", "-m", "base"); + location = store.source("tenant", "actor", new WorkspaceSelection(workspace, "project")); + var binding = location.resolved().binding(); + try (var git = new SessionWorktreeGit(new ChildWorktreeGit("git", Duration.ofSeconds(10)))) { + var source = git.source(location.root(), "project", null); + String id = UUID.randomUUID().toString().replace("-", ""); + String cwd = SessionWorktreeGit.CONTAINER + "/" + id; + Plan plan = new Plan("tenant", workspace, binding.getWorkspaceGeneration(), binding.getStorageId(), "project", + binding.getContextConfigRef(), 1, location.root().toString(), SessionWorktreeGit.identity(location.root()), + source.repositoryRelative(), source.offset(), source.commonDirectory(), source.commonIdentity(), null, + source.commit(), id, cwd, cwd, "worktree-managed-" + id); + var resolved = location.resolved(); + transaction.executeWithoutResult(status -> { + jdbc.update("INSERT INTO ma_session (tenant_id,session_id,agent_id,gmt_create,gmt_modified) VALUES ('tenant','session','agent',1,1)"); + store.admit("tenant", "actor", "session", plan, resolved); + }); + var sessions = new ManagedAgentStore(jdbc, new ObjectMapper(), Clock.systemUTC(), ignored -> {}, + new ManagedWorkspaceRegistry(jdbc), new ManagedAgentProperties()); + assertThat(sessions.listSessions("tenant", null, null, null, 1).worktrees().get("session").state()) + .isEqualTo("preparing"); + assertThat(SessionWorktreeStore.isReady(jdbc, "tenant", "session")).isFalse(); + assertThat(store.due()).hasSize(1); + var candidate = store.due().getFirst(); + SessionWorktreeStore.Row claim; + try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) { + var start = new java.util.concurrent.CountDownLatch(1); + var first = workers.submit(() -> { + start.await(); + return store.claim(candidate, "first", 120000); + }); + var second = workers.submit(() -> { + start.await(); + return store.claim(candidate, "second", 120000); + }); + start.countDown(); + var claims = java.util.Arrays.asList(first.get(10, java.util.concurrent.TimeUnit.SECONDS), + second.get(10, java.util.concurrent.TimeUnit.SECONDS)); + assertThat(claims).filteredOn(java.util.Objects::nonNull).hasSize(1); + claim = claims.stream().filter(java.util.Objects::nonNull).findFirst().orElseThrow(); + } + assertThat(claim).isNotNull(); + String physical = WorkspaceExecutionStore.storageKey(jdbc, SessionWorktreeStore.storageBinding(plan)); + jdbc.update("UPDATE ma_storage_owner SET holder_key = ?, maintenance_id = ?, maintenance_generation = ? WHERE storage_key = ?", + "live-step", claim.operationId(), claim.generation(), physical); + assertThat(store.claim(claim, "other", 120000)).isNull(); + assertThat(store.describe("tenant", "session").state()).isEqualTo("preparing"); + jdbc.update("UPDATE ma_storage_owner SET holder_key = NULL, maintenance_id = NULL, maintenance_generation = NULL WHERE storage_key = ?", physical); + String identity = git.prepare(plan); + assertThat(store.complete(claim, "foreign", "READY", identity, null)).isFalse(); + assertThat(store.complete(claim, claim.claimedBy(), "READY", identity, null)).isTrue(); + var ready = sessions.listSessions("tenant", null, null, null, 1).worktrees().get("session"); + assertThat(ready.id()).isEqualTo(id); + assertThat(ready.state()).isEqualTo("ready"); + SessionWorktreeStore.requireReady(jdbc, "tenant", "session"); + assertThat(store.due()).isEmpty(); + assertThat(store.list("tenant", "actor", workspace)).hasSize(1); + assertThatThrownBy(() -> SessionWorktreeStore.requireCwd(jdbc, "tenant", "session", "project")) + .hasMessageContaining("worktree_cwd_outside"); + Files.move(location.root().resolve(cwd), location.root().resolve(cwd + "-saved")); + assertThatThrownBy(() -> transaction.execute(status -> { + SessionWorktreeStore.requireReady(jdbc, "tenant", "session"); + return null; + })).hasMessageContaining("worktree_identity_conflict"); + assertThat(sessions.listSessions("tenant", null, null, null, 1).worktrees().get("session").state()) + .isEqualTo("blocked"); + assertThat(SessionWorktreeStore.descriptor(jdbc, "tenant", "session").state()).isEqualTo("blocked"); + assertThatThrownBy(() -> SessionWorktreeStore.requireReady(jdbc, "tenant", "session")) + .hasMessageContaining("worktree_identity_conflict"); + } + } + + @Test + void cleanupReusesItsReceiptAndRemovesTheCheckoutUnderMaintenance() throws Exception { + var location = store.source("tenant", "actor", new WorkspaceSelection(workspace, ".")); + Path repository = Files.createDirectory(location.root().resolve("project")); + git(repository, "init", "-b", "main"); + Files.writeString(repository.resolve("file.txt"), "base\n"); + git(repository, "add", "."); + git(repository, "commit", "-m", "base"); + location = store.source("tenant", "actor", new WorkspaceSelection(workspace, "project")); + var binding = location.resolved().binding(); + try (var git = new SessionWorktreeGit(new ChildWorktreeGit("git", Duration.ofSeconds(10)))) { + var source = git.source(location.root(), "project", null); + String id = UUID.randomUUID().toString().replace("-", ""); + String cwd = SessionWorktreeGit.CONTAINER + "/" + id; + Plan plan = new Plan("tenant", workspace, binding.getWorkspaceGeneration(), binding.getStorageId(), "project", + binding.getContextConfigRef(), 1, location.root().toString(), SessionWorktreeGit.identity(location.root()), + source.repositoryRelative(), source.offset(), source.commonDirectory(), source.commonIdentity(), null, + source.commit(), id, cwd, cwd, "worktree-managed-" + id); + var resolved = location.resolved(); + transaction.executeWithoutResult(status -> { + jdbc.update("INSERT INTO ma_session (tenant_id,session_id,agent_id,gmt_create,gmt_modified) VALUES ('tenant','cleanup-session','agent',1,1)"); + store.admit("tenant", "actor", "cleanup-session", plan, resolved); + }); + var claim = store.claim(store.due().getFirst(), "prepare", 120000); + assertThat(store.complete(claim, "prepare", "READY", git.prepare(plan), null)).isTrue(); + jdbc.update("UPDATE ma_session SET status = 'CLOSED' WHERE tenant_id = ? AND session_id = ?", "tenant", "cleanup-session"); + transaction.executeWithoutResult(status -> { + store.cleanup("tenant", "actor", workspace, id, "rollback-removal"); + status.setRollbackOnly(); + }); + assertThat(store.describe("tenant", "cleanup-session").state()).isEqualTo("ready"); + var removal = store.cleanup("tenant", "actor", workspace, id, "remove"); + assertThat(store.cleanup("tenant", "actor", workspace, id, "remove").operationId()).isEqualTo(removal.operationId()); + var execution = new WorkspaceExecutionStore(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())); + var service = new com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService(store, execution, + new ManagedAgentProperties(), org.mockito.Mockito.mock(ManagedAgentStore.class), + org.mockito.Mockito.mock(com.alibaba.qwen.code.managedagent.session.HarnessCoordinator.class)); + try { + service.drive(removal); + } finally { + org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "stop"); + } + assertThat(store.describe("tenant", "cleanup-session").state()) + .as("cleanup outcome: %s", store.describe("tenant", "cleanup-session")).isEqualTo("removed"); + assertThat(Files.exists(location.root().resolve(cwd))).isFalse(); + assertThat(store.cleanup("tenant", "actor", workspace, id, "remove").state()).isEqualTo("REMOVED"); + } + } + + @Test + void unsupportedOwnerCannotInspectOrAllocateAWorktree() { + assertThatThrownBy(() -> store.source("tenant", "stranger", new WorkspaceSelection(workspace, "."))) + .hasMessageContaining("Workspace not found"); + assertThatThrownBy(() -> store.list("tenant", "stranger", workspace)).hasMessageContaining("not found"); + assertThat(store.list("tenant", "actor", workspace)).isEmpty(); + } + + private static void git(Path directory, String... arguments) throws Exception { + var command = new java.util.ArrayList<>(List.of("git", "-C", directory.toString(), "-c", "user.name=Test", + "-c", "user.email=test@example.invalid", "-c", "core.hooksPath=/dev/null")); + command.addAll(List.of(arguments)); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + assertThat(process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + assertThat(process.exitValue()).as(new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)).isZero(); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsMySqlIT.java new file mode 100644 index 00000000000..2a6846aaae6 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsMySqlIT.java @@ -0,0 +1,24 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import org.junit.jupiter.api.AfterEach; + +import javax.sql.DataSource; + +import java.util.ArrayList; +import java.util.List; + +class SharedLocalProjectsMySqlIT extends SharedLocalProjectsTest { + private final List databases = new ArrayList<>(); + + @Override + protected DataSource dataSource() { + var database = new IsolatedManagedSchemaMySqlTestDatabase(); + databases.add(database); + return database.source(); + } + + @AfterEach + void closeDatabases() { + databases.forEach(IsolatedManagedSchemaMySqlTestDatabase::close); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsTest.java new file mode 100644 index 00000000000..80fdc962126 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SharedLocalProjectsTest.java @@ -0,0 +1,199 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRecord; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.ReceiptNamespace; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.ReceiptScope; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +import javax.sql.DataSource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Clock; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class SharedLocalProjectsTest { + @TempDir + Path temp; + + protected DataSource dataSource() { + return new DriverManagerDataSource("jdbc:h2:mem:shared-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); + } + + private Catalog catalog(String name) throws Exception { + Path home = Files.createDirectory(temp.resolve(name)).toRealPath(); + var properties = new ManagedAgentProperties(); + var broker = properties.getRuntimeBroker(); + broker.setVerifiedWorkspaceRecoveryEnabled(true); + broker.setLocalDirectoryImportEnabled(true); + broker.setStateDirectory(Files.createDirectory(home.resolve("runtime")).toString()); + broker.setWorkspaceCreationRoot(Files.createDirectory(home.resolve("workspaces")).toString()); + DataSource source = dataSource(); + ManagedSchema.initialize(source); + JdbcTemplate jdbc = new ManagedSchemaJdbcTemplate(source); + var manager = new DataSourceTransactionManager(source); + var guard = new WorkspaceStorageGuard(jdbc, manager, properties, + path -> new WorkspaceStorageGuard.Identity(path.toString(), "test-host", "device", + Files.readAttributes(path, BasicFileAttributes.class).fileKey().toString(), "birth")); + var store = new WorkspaceCreationStore(jdbc, manager, guard, new ManagedWorkspaceRegistry(jdbc), + properties, new ObjectMapper(), Clock.systemUTC()); + return new Catalog(name, jdbc, manager, properties, guard, store); + } + + private Path project() throws Exception { + Path project = Files.createDirectories(temp.resolve("projects/project")).toRealPath(); + Files.createDirectory(project.resolve(".git")); + Files.writeString(project.resolve("kept.txt"), "user content\n"); + return project; + } + + private record Catalog(String tenant, JdbcTemplate jdbc, DataSourceTransactionManager manager, + ManagedAgentProperties properties, WorkspaceStorageGuard guard, WorkspaceCreationStore store) { + ManagedWorkspaceRegistry.WorkspaceSummary add(Path project) { + return store.create(tenant, "actor", "Project", "import", null, null, project.toString()); + } + + String storage(ManagedWorkspaceRegistry.WorkspaceSummary workspace) { + return jdbc.queryForObject("SELECT storage_id FROM ma_workspace WHERE tenant_id = ? AND workspace_id = ?", + String.class, tenant, workspace.workspaceId()); + } + + ContextBinding binding(ManagedWorkspaceRegistry.WorkspaceSummary workspace) { + return new ContextBinding(tenant, workspace.workspaceId(), 1, storage(workspace), + workspace.defaultCwdRelative(), "config", 1); + } + + Path marker(ManagedWorkspaceRegistry.WorkspaceSummary workspace) { + return Path.of(properties.getRuntimeBroker().getStateDirectory()).resolve("imported-projects") + .resolve(storage(workspace)).resolve(".qwen-managed-storage.json"); + } + } + + @Test + void independentCatalogsShareProjectWithoutChangingForeignMarker() throws Exception { + Path project = project(); + Path foreign = project.getParent().resolve(".qwen-managed-storage.json"); + Files.writeString(foreign, "foreign registration\n"); + Catalog first = catalog("first"); + Catalog second = catalog("second"); + var a = first.add(project); + var b = second.add(project); + assertThat(a.canCreateSession()).isTrue(); + assertThat(b.canCreateSession()).isTrue(); + first.guard().verify(first.binding(a)); + second.guard().verify(second.binding(b)); + assertThat(first.guard().fileRoot(first.binding(a)).get("root")).isEqualTo(project.getParent().toString()); + assertThat(second.guard().fileRoot(second.binding(b)).get("root")).isEqualTo(project.getParent().toString()); + assertThat(first.add(project)).isEqualTo(a); + assertThat(second.add(project)).isEqualTo(b); + assertThat(Files.readString(foreign)).isEqualTo("foreign registration\n"); + assertThat(Files.readString(project.resolve("kept.txt"))).isEqualTo("user content\n"); + assertThat(first.marker(a)).isNotEqualTo(second.marker(b)); + assertThat(Files.exists(first.marker(a))).isTrue(); + assertThat(Files.exists(second.marker(b))).isTrue(); + } + + @Test + void privateMetadataLossBlocksOnlyItsOwnCatalog() throws Exception { + Path project = project(); + Catalog first = catalog("first"); + Catalog second = catalog("second"); + var a = first.add(project); + var b = second.add(project); + Files.delete(first.marker(a)); + assertThatThrownBy(() -> first.guard().verify(first.binding(a))).isInstanceOf(RuntimeBrokerException.class); + assertThat(first.add(project)).isEqualTo(a); + assertThat(Files.exists(first.marker(a))).isFalse(); + second.guard().verify(second.binding(b)); + Files.writeString(second.marker(b), "{}"); + assertThatThrownBy(() -> second.guard().verify(second.binding(b))).isInstanceOf(RuntimeBrokerException.class); + assertThat(Files.exists(project.getParent().resolve(".qwen-managed-storage.json"))).isFalse(); + } + + @Test + void sourceReplacementIsStillRefusedWithIntactPrivateMetadata() throws Exception { + Path project = project(); + Catalog catalog = catalog("first"); + var imported = catalog.add(project); + Files.move(project.getParent(), temp.resolve("saved-projects")); + Files.createDirectories(project); + assertThat(Files.exists(catalog.marker(imported))).isTrue(); + assertThatThrownBy(() -> catalog.guard().verify(catalog.binding(imported))).isInstanceOf(RuntimeBrokerException.class); + } + + @Test + void sharedRootRejectsExclusiveMaintenanceBeforeChangingState() throws Exception { + Path project = project(); + Catalog catalog = catalog("first"); + var imported = catalog.add(project); + String storage = catalog.storage(imported); + String operation = UUID.randomUUID().toString(); + var registration = new WorkspaceStorageGuard.RecoveryRegistration(catalog.tenant(), storage, + operation, 1, operation, project.getParent().toString(), "host", "device", "inode", "birth"); + List exclusive = List.of( + () -> catalog.guard().fence(catalog.tenant(), storage, 1, operation), + () -> catalog.guard().restoreOriginal(catalog.tenant(), storage, 1, operation), + () -> catalog.guard().recoveryRegistration(catalog.tenant(), storage, 1, operation), + () -> catalog.guard().verifyCapturedRegistration(registration), + () -> catalog.guard().migrationSource(catalog.tenant(), storage, 1, operation), + () -> catalog.guard().publishMigrationMarker(project.getParent(), new byte[0], registration, operation), + () -> catalog.guard().promoteMigration(registration, catalog.guard().identity(project.getParent()), operation, operation), + () -> new WorkspaceExecutionStore(catalog.jdbc(), catalog.manager(), catalog.guard(), catalog.properties()) + .beginRewind(catalog.binding(imported), mock(RuntimeSessionRecord.class))); + for (Runnable action : exclusive) { + assertThatThrownBy(action::run).isInstanceOfSatisfying(RuntimeBrokerException.class, + error -> assertThat(error.getCode()).isEqualTo("workspace_shared_directory")); + } + assertThat(catalog.jdbc().queryForObject("SELECT mount_state FROM ma_storage_owner", String.class)).isEqualTo("READY"); + assertThat(catalog.jdbc().queryForObject("SELECT COUNT(*) FROM ma_operation", Integer.class)).isZero(); + assertThat(Files.readString(project.resolve("kept.txt"))).isEqualTo("user content\n"); + } + + @Test + void legacyImportedReceiptKeepsItsOriginalMarkerContract() throws Exception { + Path project = project(); + Catalog legacy = catalog("legacy"); + byte[] actor = ManagedWorkspaceRegistry.actorKey(legacy.tenant(), "actor"); + String scope = ManagedReceipts.actorScope(actor); + String key = ManagedSchemaKeys.receiptKey(ReceiptNamespace.WORKSPACE_CREATE, + legacy.tenant(), ReceiptScope.ACTOR, scope, "import"); + String digest = ManagedSchemaKeys.key("local-directory-import/1", "Project", project.toString()); + var details = new WorkspaceCreationRoots.Details("workspace-" + key, "created-" + key, + "Project", UUID.randomUUID().toString(), null, null, "project", legacy.guard().identity(project.getParent())); + String encoded = new ObjectMapper().writeValueAsString(details); + new TransactionTemplate(legacy.manager()).executeWithoutResult(status -> { + ManagedReceipts.insert(legacy.jdbc(), ReceiptNamespace.WORKSPACE_CREATE, legacy.tenant(), + ReceiptScope.ACTOR, scope, "import", digest, "CREATE_WORKSPACE", actor, + null, null, "PENDING", null, null, Clock.systemUTC().millis()); + legacy.jdbc().update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?", encoded, key); + }); + var resumed = legacy.add(project); + Path marker = project.getParent().resolve(".qwen-managed-storage.json"); + String original = Files.readString(marker); + assertThat(legacy.storage(resumed)).startsWith("created-"); + var shared = catalog("shared"); + assertThat(shared.add(project).canCreateSession()).isTrue(); + assertThat(Files.readString(marker)).isEqualTo(original); + legacy.guard().verify(legacy.binding(resumed)); + Files.writeString(marker, "{}"); + assertThatThrownBy(() -> legacy.guard().verify(legacy.binding(resumed))).isInstanceOf(RuntimeBrokerException.class); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStoreTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStoreTest.java index ca4fd3b9fdb..6f7bfc4f0ae 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStoreTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCreationStoreTest.java @@ -54,6 +54,7 @@ class WorkspaceCreationStoreTest { jdbc = new ManagedSchemaJdbcTemplate(source); properties = new ManagedAgentProperties(); properties.getRuntimeBroker().setWorkspaceCreationRoot(Files.createDirectory(temp.resolve("created")).toRealPath().toString()); + properties.getRuntimeBroker().setStateDirectory(Files.createDirectory(temp.resolve("runtime")).toRealPath().toString()); properties.getRuntimeBroker().setVerifiedWorkspaceRecoveryEnabled(true); registry = new ManagedWorkspaceRegistry(jdbc); guard = new WorkspaceStorageGuard(jdbc, new DataSourceTransactionManager(source), properties, @@ -393,7 +394,9 @@ class WorkspaceCreationStoreTest { assertThat(guard.createdStorageRoot("tenant", storage(created.workspaceId()))).isEqualTo(project.getParent()); assertThat(Files.readString(project.resolve("README.md"))).isEqualTo("existing content"); assertThat(Files.exists(project.resolve(".qwen-managed-storage.json"))).isFalse(); - assertThat(Files.exists(project.getParent().resolve(".qwen-managed-storage.json"))).isTrue(); + assertThat(Files.exists(project.getParent().resolve(".qwen-managed-storage.json"))).isFalse(); + assertThat(Files.exists(Path.of(properties.getRuntimeBroker().getStateDirectory()) + .resolve("imported-projects").resolve(storage(created.workspaceId())).resolve(".qwen-managed-storage.json"))).isTrue(); properties.getRuntimeBroker().setLocalDirectoryImportEnabled(false); assertThat(new WorkspaceCreationRoots(jdbc, properties).resolve("tenant", storage(created.workspaceId()), false)) .isEqualTo(project.getParent()); @@ -448,7 +451,7 @@ class WorkspaceCreationStoreTest { } @Test - void refusesPrivateSymlinkMissingAndForeignDirectoriesWithoutGrantingAccess() throws Exception { + void refusesPrivateSymlinkAndMissingDirectoriesWithoutGrantingAccess() throws Exception { Path project = localProject("project"); Path privateState = Files.createDirectory(project.getParent().resolve("private-state")); properties.getRuntimeBroker().setStateDirectory(privateState.toString()); @@ -464,8 +467,6 @@ class WorkspaceCreationStoreTest { assertThatThrownBy(() -> store(guard).create("tenant", "actor", "Invalid", "bad-" + UUID.randomUUID(), null, null, path)) .hasFieldOrPropertyWithValue("code", "invalid_local_directory"); } - Files.writeString(project.getParent().resolve(".qwen-managed-storage.json"), "foreign marker"); - assertThatThrownBy(() -> importProject(project, "foreign")).isInstanceOf(RuntimeException.class); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_workspace_access", Integer.class)).isZero(); } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java index e374aa7d541..70f4c7d8a9d 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java @@ -770,7 +770,7 @@ class WorkspaceCsiApplicationInventoryTest { void inventoriesEveryDeclaredTableAndPreservesRealPrivateCreationFacts() throws Exception { var snapshot = inspect(); assertThat(snapshot.tables()).hasSize(com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract.TABLES.size()); - assertThat(snapshot.tables().stream().map(table -> table.columns().size()).mapToInt(Integer::intValue).sum()).isEqualTo(807); + assertThat(snapshot.tables().stream().map(table -> table.columns().size()).mapToInt(Integer::intValue).sum()).isEqualTo(822); assertThat(snapshot.blockers()).noneMatch(blocker -> blocker.code().equals("schema_drift") || blocker.code().equals("query_unavailable")); for (String table : new String[] {"ma_session", "ma_public_event", "ma_event_stream", "ma_storage", "ma_operation", @@ -784,6 +784,17 @@ class WorkspaceCsiApplicationInventoryTest { && blocker.code().equals("missing_or_extra_original")); } + @Test + void sessionWorktreeRowsBlockCsiRetirementInsteadOfBeingOmitted() throws Exception { + jdbc.update("INSERT INTO ma_session_worktree (tenant_id, session_id, workspace_id, storage_id, physical_key," + + " worktree_id, owner_actor_key, state, revision, plan_json, operation_id, gmt_create, gmt_modified)" + + " VALUES ('tenant', ?, 'workspace', 'storage', ?, ?, ?, 'PREPARING', 1, '{}', 'worktree', 1, 1)", + session, "a".repeat(64), "b".repeat(32), ManagedWorkspaceRegistry.actorKey("tenant", "actor")); + var snapshot = inspect(); + assertThat(snapshot.table("ma_session_worktree").rows()).hasSize(1); + assertThat(snapshot.blockers()).anyMatch(blocker -> blocker.table().equals("ma_session_worktree")); + } + @Test void originalUserMessageRowsRemainOutsideTheNativeFirstLane() throws Exception { jdbc.update("INSERT INTO ma_user_message_queue (tenant_id, session_id) VALUES ('tenant', ?)", session); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java index 4c3a507a570..83e7431f779 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java @@ -235,7 +235,7 @@ class WorkspaceCsiNativeActivationGateTest { .map(row -> JSON.valueToTree(new TreeMap<>(row)).toString()).sorted().toList()); } assertThat(rows).containsKeys("ma_delivery", "ma_session", "ma_session_head", "ma_request_receipt"); - assertThat(rows).hasSize(39); + assertThat(rows).hasSize(40); return rows; } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java index 7913f55dbe9..23c0e1e0e4e 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionMainTest.java @@ -645,7 +645,7 @@ class WorkspaceCsiSessionMainTest { .sorted(Comparator.comparing(JsonNode::toString)).toList(); result.put(table, json.valueToTree(rows)); } - assertThat(result).hasSize(39); + assertThat(result).hasSize(40); return result; } diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java index 7f79e5f7922..5162b8faf69 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java @@ -461,7 +461,7 @@ class WorkspaceCsiSqlWorkerMySqlIT { } } assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ?" - + " AND table_type = 'BASE TABLE'", Integer.class, schema)).isEqualTo(39); + + " AND table_type = 'BASE TABLE'", Integer.class, schema)).isEqualTo(40); verify(provisioner).ensureResource(any(), any(), any()); verify(provisioner).provision(any(), any()); String evidence = System.getProperty("csi.acceptance.evidence"); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStoreTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStoreTest.java index b17e285aa31..4494c574fc1 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStoreTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileStoreTest.java @@ -1,6 +1,7 @@ package com.alibaba.qwen.code.managedagent.persistence; import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.config.SshRuntimeSettings; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.workspace.SshWorkspaceService; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; @@ -20,6 +21,7 @@ import javax.sql.DataSource; import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -29,12 +31,16 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; class WorkspaceFileStoreTest { @TempDir Path temp; JdbcTemplate jdbc; WorkspaceFileStore files; + WorkspaceStorageGuard guard; + ManagedWorkspaceRegistry registry; + SshWorkspaceService ssh; ManagedAgentProperties properties; String workspace; @@ -53,13 +59,15 @@ class WorkspaceFileStoreTest { properties.getRuntimeBroker().setWorkspaceCreationRoot(Files.createDirectory(temp.resolve("created")).toRealPath().toString()); properties.getRuntimeBroker().setVerifiedWorkspaceRecoveryEnabled(true); var manager = new DataSourceTransactionManager(source); - var registry = new ManagedWorkspaceRegistry(jdbc); - var guard = new WorkspaceStorageGuard(jdbc, manager, properties, + registry = new ManagedWorkspaceRegistry(jdbc); + guard = new WorkspaceStorageGuard(jdbc, manager, properties, path -> new WorkspaceStorageGuard.Identity(path.toString(), "test-host", "1", "2", "2026-10-15T00:00:00.123456789Z")); var mapper = new ObjectMapper(); workspace = new WorkspaceCreationStore(jdbc, manager, guard, registry, properties, mapper, Clock.systemUTC()) .create("tenant", "actor", "Files", "create").workspaceId(); - files = new WorkspaceFileStore(registry, guard, mock(SshWorkspaceService.class), properties, jdbc, manager, mapper); + ssh = mock(SshWorkspaceService.class); + files = new WorkspaceFileStore(registry, guard, ssh, properties, jdbc, manager, mapper); + Files.createDirectory(Path.of(guard.fileRoot(files.scope("tenant", "actor", workspace, ".").binding()).get("root")).resolve("src")); } Map result() { @@ -71,6 +79,132 @@ class WorkspaceFileStoreTest { return files.query("tenant", "actor", workspace, ".", ".", "", null); } + private WorkspaceFileStore.Locator session(String id) { + var binding = files.scope("tenant", "actor", workspace, ".").binding(); + jdbc.update("INSERT INTO ma_session (tenant_id, session_id, agent_id, gmt_create, gmt_modified, workspace_id, workspace_generation," + + " workspace_storage_id, cwd_relative, context_config_ref, context_revision, workspace_config_ref, workspace_policy_ref, owner_actor_key)" + + " SELECT tenant_id, ?, ?, 0, 0, workspace_id, workspace_generation, storage_id, 'src', ?, 7, config_ref, policy_ref, ?" + + " FROM ma_workspace WHERE tenant_id = ? AND workspace_id = ?", id, "agent", binding.getContextConfigRef(), + ManagedWorkspaceRegistry.actorKey("tenant", "actor"), "tenant", workspace); + return new WorkspaceFileStore.Locator(id, null, null); + } + + @Test + void sessionFilesPreservePinnedContextButRequireTheSshNodeActorAllowlist() { + var locator = session("session"); + var remote = properties.getRuntimeBroker().getSsh(); + remote.setEnabled(true); + remote.setNodes(List.of(new SshRuntimeSettings.Node("mac", "Mac", "mac", "/node", "/qwen.js", "/state", + List.of("/work"), 33222, "0.25.1", List.of(new SshRuntimeSettings.Access("tenant", "actor"))))); + registry.configureSsh(properties); + jdbc.update("UPDATE ma_storage SET storage_kind = 'SSH_LOCAL', registration_json = ?", "{\"nodeId\":\"mac\"}"); + jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role) VALUES (?, ?, ?, 'OPERATOR')", + "tenant", workspace, ManagedWorkspaceRegistry.actorKey("tenant", "unlisted")); + var allowed = files.scope("tenant", "actor", locator); + assertThat(allowed.binding().getCwdRelative()).isEqualTo("src"); + assertThat(allowed.binding().getContextRevision()).isEqualTo(7); + assertThatThrownBy(() -> files.scope("tenant", "unlisted", workspace, ".")) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("runtime_node_forbidden")); + assertThatThrownBy(() -> files.scope("tenant", "unlisted", locator)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("runtime_node_forbidden")); + assertThatThrownBy(() -> files.query("tenant", "unlisted", locator, ".", "", null)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("runtime_node_forbidden")); + assertThatThrownBy(() -> files.read("tenant", "unlisted", locator, "a.txt", allowed.revision())) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("runtime_node_forbidden")); + var uploads = new WorkspaceFileUploadStore(files, registry, guard, ssh, properties, jdbc, + new DataSourceTransactionManager(jdbc.getDataSource()), new ObjectMapper(), Runnable::run); + assertThatThrownBy(() -> uploads.upload("tenant", "unlisted", locator, "a.txt", allowed.revision(), + UUID.randomUUID().toString(), new java.io.ByteArrayInputStream(new byte[] {1}), 1)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("runtime_node_forbidden")); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_operation WHERE operation_kind = 'WORKSPACE_UPLOAD'", Integer.class)).isZero(); + verifyNoInteractions(ssh); + remote.setEnabled(false); + assertThatThrownBy(() -> files.scope("tenant", "actor", locator)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("runtime_node_forbidden")); + } + + @Test + void sessionUsesPersistedContextAndWorkspaceAccessInsteadOfReconstructingScope() { + var locator = session("session"); + try (var process = mockStatic(WorkspaceFileProcess.class)) { + process.when(() -> WorkspaceFileProcess.read(anyString(), anyString(), any())).thenAnswer(call -> { + Map request = call.getArgument(2); + assertThat(request.get("cwdRelative")).isEqualTo("src"); + assertThat(request).doesNotContainKey("sessionFileScope"); + return result(); + }); + var first = files.query("tenant", "actor", locator, ".", "", null); + var direct = files.query("tenant", "actor", workspace, "src", ".", "", null); + assertThat(first.scopeRevision()).isNotEqualTo(direct.scopeRevision()); + jdbc.update("UPDATE ma_session SET context_revision = 8 WHERE session_id = ?", "session"); + assertThatThrownBy(() -> files.query("tenant", "actor", locator, ".", "", first.scopeRevision())) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("workspace_file_scope_changed")); + jdbc.update("DELETE FROM ma_workspace_access WHERE tenant_id = ? AND workspace_id = ? AND actor_id = ?", + "tenant", workspace, ManagedWorkspaceRegistry.actorKey("tenant", "actor")); + assertThatThrownBy(() -> files.query("tenant", "actor", locator, ".", "", null)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("session_not_found")); + } + } + + @Test + void anotherWorkspaceOperatorCanReadSessionFilesUntilTheSessionIsDeleted() { + var locator = session("session"); + jdbc.update("INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role) VALUES (?, ?, ?, 'OPERATOR')", + "tenant", workspace, ManagedWorkspaceRegistry.actorKey("tenant", "operator")); + try (var process = mockStatic(WorkspaceFileProcess.class)) { + process.when(() -> WorkspaceFileProcess.read(anyString(), anyString(), any())).thenReturn(result()); + assertThat(files.query("tenant", "operator", locator, ".", "", null).entries()).isNotEmpty(); + jdbc.update("UPDATE ma_session SET status = 'DELETED' WHERE tenant_id = ? AND session_id = ?", "tenant", "session"); + process.clearInvocations(); + assertThatThrownBy(() -> files.query("tenant", "operator", locator, ".", "", null)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("session_not_found")); + process.verifyNoInteractions(); + } + } + + @Test + void unreadySessionCannotReachTheFileProcess() { + var locator = session("session"); + try (var worktrees = mockStatic(SessionWorktreeStore.class); var process = mockStatic(WorkspaceFileProcess.class)) { + worktrees.when(() -> SessionWorktreeStore.requireReady(jdbc, "tenant", "session")) + .thenThrow(new ManagedAgentException(org.springframework.http.HttpStatus.CONFLICT, "worktree_not_ready", "Not ready")); + assertThatThrownBy(() -> files.query("tenant", "actor", locator, ".", "", null)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("worktree_not_ready")); + process.verifyNoInteractions(); + } + } + + @Test + void worktreeFileScopeIncludesActualContextDigestAndFrozenDirectoryIdentity() throws Exception { + var locator = session("session"); + String cwd = ".qwen-managed-worktrees/owned/src"; + var binding = files.scope("tenant", "actor", workspace, ".").binding(); + Path root = Path.of((String) guard.fileRoot(binding).get("root")); + Files.createDirectories(root.resolve(cwd)); + var identity = Files.readAttributes(root.resolve(".qwen-managed-worktrees/owned"), "unix:dev,ino"); + jdbc.update("UPDATE ma_session SET cwd_relative = ? WHERE session_id = ?", cwd, "session"); + try (var worktrees = mockStatic(SessionWorktreeStore.class); var process = mockStatic(WorkspaceFileProcess.class)) { + worktrees.when(() -> SessionWorktreeStore.descriptor(jdbc, "tenant", "session")) + .thenReturn(new SessionWorktreeStore.Descriptor("owned", "session", workspace, "ready", "branch", "a".repeat(40), cwd, "op", 2, null)); + process.when(() -> WorkspaceFileProcess.read(anyString(), anyString(), any())).thenAnswer(call -> { + Map request = call.getArgument(2); + Map proof = (Map) request.get("sessionFileScope"); + var actual = new com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding("tenant", workspace, + binding.getWorkspaceGeneration(), binding.getStorageId(), cwd, binding.getContextConfigRef(), 7); + assertThat(proof.get("sessionId")).isEqualTo("session"); + assertThat(proof.get("contextDigest")).isEqualTo(actual.getContextDigest()); + assertThat(proof.get("worktreeRevision")).isEqualTo(2L); + assertThat(proof.get("worktreeRootIdentity")).isEqualTo(Map.of("device", identity.get("dev").toString(), "inode", identity.get("ino").toString())); + return result(); + }); + String revision = files.query("tenant", "actor", locator, ".", "", null).scopeRevision(); + worktrees.when(() -> SessionWorktreeStore.descriptor(jdbc, "tenant", "session")) + .thenReturn(new SessionWorktreeStore.Descriptor("owned", "session", workspace, "ready", "branch", "a".repeat(40), cwd, "op", 3, null)); + assertThatThrownBy(() -> files.query("tenant", "actor", locator, ".", "", revision)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("workspace_file_scope_changed")); + } + } + @Test void disabledDeploymentRefusesQueryAndReadBeforeFileIo() { try (var process = mockStatic(WorkspaceFileProcess.class)) { diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStoreTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStoreTest.java index 3d1ad3660c1..256971f2756 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStoreTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceFileUploadStoreTest.java @@ -79,6 +79,7 @@ class WorkspaceFileUploadStoreTest { .create("tenant", "actor", "Uploads", "create").workspaceId(); ssh = mock(SshWorkspaceService.class); files = new WorkspaceFileStore(registry, guard, ssh, properties, jdbc, manager, mapper); + Files.createDirectory(Path.of(guard.fileRoot(files.scope("tenant", "actor", workspace, ".").binding()).get("root")).resolve("src")); uploads = store(); } @@ -95,6 +96,47 @@ class WorkspaceFileUploadStoreTest { new ByteArrayInputStream(bytes), bytes.length); } + @Test + void sessionUploadFreezesItsOriginalBindingForReconciliationAndRejectsDeletedSessions() { + var binding = files.scope("tenant", "actor", workspace, ".").binding(); + jdbc.update("INSERT INTO ma_session (tenant_id, session_id, agent_id, gmt_create, gmt_modified, workspace_id, workspace_generation," + + " workspace_storage_id, cwd_relative, context_config_ref, context_revision, workspace_config_ref, workspace_policy_ref, owner_actor_key)" + + " SELECT tenant_id, ?, ?, 0, 0, workspace_id, workspace_generation, storage_id, 'src', ?, 7, config_ref, policy_ref, ?" + + " FROM ma_workspace WHERE tenant_id = ? AND workspace_id = ?", "session", "agent", binding.getContextConfigRef(), + ManagedWorkspaceRegistry.actorKey("tenant", "other"), "tenant", workspace); + var locator = new WorkspaceFileStore.Locator("session", null, null); + String revision = files.scope("tenant", "actor", locator).revision(); + String id = UUID.randomUUID().toString(); + AtomicInteger calls = new AtomicInteger(); + try (var process = mockStatic(WorkspaceFileProcess.class)) { + process.when(() -> WorkspaceFileProcess.upload(anyString(), anyString(), any())).thenAnswer(call -> { + Map request = call.getArgument(2); + assertThat(request.get("cwdRelative")).isEqualTo("src"); + if (calls.getAndIncrement() == 0) { + throw new RuntimeBrokerException(503, "ssh_runtime_unavailable", "unknown", true); + } + Map file = (Map) request.get("files"); + assertThat(file.get("operation")).isEqualTo("upload-status"); + assertThat(file.containsKey("data")).isFalse(); + return response(request, "completed", null); + }); + assertThatThrownBy(() -> uploads.upload("tenant", "actor", locator, "file.bin", revision, id, InputStream.nullInputStream(), 0)) + .isInstanceOf(ManagedAgentException.class); + assertThat(jdbc.queryForObject("SELECT request_json FROM ma_operation WHERE operation_kind = 'WORKSPACE_UPLOAD'", String.class)) + .contains("\"session\":\"session\""); + assertThat(jdbc.queryForObject("SELECT session_id FROM ma_operation WHERE operation_kind = 'WORKSPACE_UPLOAD'", String.class)).isEqualTo("session"); + jdbc.update("UPDATE ma_session SET cwd_relative = 'replacement', context_revision = 8 WHERE session_id = ?", "session"); + jdbc.update("UPDATE ma_operation SET available_at = 0 WHERE operation_kind = 'WORKSPACE_UPLOAD'"); + uploads = store(); + uploads.reconcile(); + assertThat(uploads.query("tenant", "actor", id).state()).isEqualTo("completed"); + assertThat(calls).hasValue(2); + jdbc.update("UPDATE ma_session SET status = 'DELETED' WHERE tenant_id = ? AND session_id = ?", "tenant", "session"); + assertThatThrownBy(() -> uploads.query("tenant", "actor", id)) + .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("workspace_upload_not_found")); + } + } + @SuppressWarnings("unchecked") static Map response(Map request, String state, String code) { Map file = new LinkedHashMap<>((Map) request.get("files")); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java index 7b2c5b1c88d..109a7285ec2 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java @@ -7,6 +7,7 @@ import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaContract; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; @@ -72,11 +73,11 @@ class WorkspaceMigrationMySqlIT { } @Test - void freshBaselineContainsOneGcAuthorityAndExactly38Tables() { + void freshBaselineContainsOneGcAuthorityAndEveryContractTable() { ManagedSchema.initialize(data); assertThat(jdbc.queryForObject( "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema" - + " = ?", Integer.class, schema)).isEqualTo(38); + + " = ?", Integer.class, schema)).isEqualTo(ManagedSchemaContract.TABLES.size()); assertThat(jdbc.queryForObject( "SELECT COUNT(*) FROM ma_operation WHERE operation_kind =" + " 'SESSION_RESOURCE_GC'", Integer.class)).isZero(); diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinatorTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinatorTest.java index 41485ded929..76b53891f92 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinatorTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/HarnessCoordinatorTest.java @@ -24,6 +24,7 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.DispatchTarget; import com.alibaba.qwen.code.managedagent.model.StoreModels.HarnessEvent; import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; import com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore; import com.alibaba.qwen.code.managedagent.workspace.runtime.RuntimeWarmer; import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; @@ -152,7 +153,8 @@ class HarnessCoordinatorTest { } verify(executor).execute(any(Runnable.class)); verify(runtimeWarmer).warm("session"); - verifyNoInteractions(store); + verify(store, times(2)).findSessionWorktree("tenant", "session"); + verifyNoMoreInteractions(store); } @ParameterizedTest(name = "setting = {0}, warmer = {1}, files = {2}") @@ -210,7 +212,64 @@ class HarnessCoordinatorTest { .contains("create warm failed") .contains("session=session"); }); - verifyNoInteractions(store); + verify(store, times(2)).findSessionWorktree("tenant", "session"); + verifyNoMoreInteractions(store); + } + + @ParameterizedTest + @ValueSource(strings = {"preparing", "failed", "blocked", "removing", "removed"}) + void unreadyWorktreesNeverWarmOrSpendDispatchRetries(String state) { + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtime = mock(RuntimeWarmer.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(runtime.isEnabled()).thenReturn(true); + when(store.findSessionWorktree("tenant", "session")).thenReturn(worktree(state)); + TurnRecord claimed = turn("tenant", "session", "turn", + "11111111-1111-4111-8111-111111111111", null, 0, "ACCEPTED", false, 0); + when(store.claimTurn(eq("tenant"), eq("session"), eq("turn"), anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession("tenant", "session")).thenReturn(boundSessionRecord()); + HarnessCoordinator coordinator = sessionCreateWarmCoordinator(store, harness, runtime, directExecutor(), true); + try { + coordinator.warmSession("tenant", "session"); + coordinator.dispatch("tenant", "session", "turn"); + } finally { + coordinator.close(); + } + verify(runtime, never()).warm(anyString()); + verify(harness, never()).createOrLoad(anyString(), anyString(), anyBoolean()); + verify(store, never()).deferTurnRetry(anyString(), anyString(), anyString(), anyString(), anyLong()); + verify(store, never()).failTurn(anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); + verify(store).releaseTurnLease(eq("tenant"), eq("session"), eq("turn"), anyString()); + } + + @Test + void cancellingAnUnpreparedWorktreeNeedsNoRuntime() { + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtime = mock(RuntimeWarmer.class); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(store.findSessionWorktree("tenant", "session")).thenReturn(worktree("preparing")); + TurnRecord claimed = turn("tenant", "session", "turn", + "11111111-1111-4111-8111-111111111111", null, 0, "CANCELLING", false, 0); + when(store.claimTurn(eq("tenant"), eq("session"), eq("turn"), anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession("tenant", "session")).thenReturn(boundSessionRecord()); + HarnessCoordinator coordinator = sessionCreateWarmCoordinator(store, harness, runtime, directExecutor(), true); + try { + coordinator.dispatch("tenant", "session", "turn"); + } finally { + coordinator.close(); + } + verify(store).cancelBeforeAdmission(eq("tenant"), eq("session"), eq("turn"), anyString()); + verify(runtime, never()).warm(anyString()); + verify(harness, never()).createOrLoad(anyString(), anyString(), anyBoolean()); + } + + private static SessionWorktreeStore.Descriptor worktree(String state) { + return new SessionWorktreeStore.Descriptor("worktree", "session", "workspace", state, + "qwen/session", "a".repeat(40), ".qwen-managed-worktrees/worktree", "operation", 1, null); } private static HarnessCoordinator sessionCreateWarmCoordinator( diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentServiceWorktreeTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentServiceWorktreeTest.java new file mode 100644 index 00000000000..c9f426af1fe --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/session/ManagedAgentServiceWorktreeTest.java @@ -0,0 +1,86 @@ +package com.alibaba.qwen.code.managedagent.session; + +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.model.ApiModels.InputBlock; +import com.alibaba.qwen.code.managedagent.model.RequestDigests; +import com.alibaba.qwen.code.managedagent.model.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; +import com.alibaba.qwen.code.managedagent.workspace.SessionWorktreeService; +import com.alibaba.qwen.code.managedagent.workspace.WorkspaceSelection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ManagedAgentServiceWorktreeTest { + private final AgentStateStore store = mock(AgentStateStore.class); + private final HarnessConnector harness = mock(HarnessConnector.class); + private final HarnessCoordinator coordinator = mock(HarnessCoordinator.class); + private final SessionWorktreeService worktrees = mock(SessionWorktreeService.class); + private final WorkspaceSelection selection = new WorkspaceSelection("workspace", "."); + private final SessionWorktreeService.Request request = new SessionWorktreeService.Request("main"); + private final ManagedAgentService service = new ManagedAgentService(store, new RequestDigests(), + coordinator, harness, mock(ManagedWorkspaceRegistry.class)); + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void creationDoesNotWarmOrDispatchBeforeTheWorktreeIsReady(boolean prompted) { + service.setSessionWorktrees(worktrees); + var plan = mock(SessionWorktreeStore.Plan.class); + when(worktrees.preflight("tenant", "actor", selection, request)).thenReturn(plan); + when(harness.isAvailable()).thenReturn(true); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(store.findSessionWorktree("tenant", "session")).thenReturn(descriptor()); + when(store.insertWorkspaceSessionCommand(eq("tenant"), eq("actor"), eq("key"), anyString(), + eq("qwen-code"), isNull(), eq("title"), anyList(), any(), eq(selection), isNull(), anyBoolean(), + isNull(), anyBoolean(), anyList(), anyBoolean(), isNull(), isNull(), anySet(), eq(plan))) + .thenReturn(new Admission("session", prompted ? "turn" : null, false, true)); + + var result = service.createWorkspaceSession("tenant", "actor", "key", "qwen-code", null, + "title", null, prompted ? List.of(new InputBlock("text", "go")) : List.of(), selection, + null, null, List.of(), null, null, request); + + assertThat(result.sessionId()).isEqualTo("session"); + verify(worktrees).preflight("tenant", "actor", selection, request); + verifyNoInteractions(coordinator); + } + + @Test + void recordedCreationReplaysBeforeWorktreePreflightOrRuntimeAvailability() { + service.setSessionWorktrees(worktrees); + when(store.findWorkspaceCreateReplay(eq("tenant"), eq("actor"), eq("key"), anyString())) + .thenReturn(Optional.of(new Admission("session", "turn", true, false))); + when(harness.isAvailable()).thenReturn(true); + when(harness.isWorkspaceFilesAvailable()).thenReturn(true); + when(store.findSessionWorktree("tenant", "session")).thenReturn(descriptor()); + + var result = service.createWorkspaceSession("tenant", "actor", "key", "qwen-code", null, + "title", null, List.of(new InputBlock("text", "go")), selection, + null, null, List.of(), null, null, request); + + assertThat(result.replayed()).isTrue(); + assertThat(result.sessionId()).isEqualTo("session"); + verifyNoInteractions(worktrees, coordinator); + } + + private static SessionWorktreeStore.Descriptor descriptor() { + return new SessionWorktreeStore.Descriptor("worktree", "session", "workspace", "preparing", + "qwen/session", "a".repeat(40), ".qwen-managed-worktrees/worktree", "operation", 1, null); + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGitTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGitTest.java new file mode 100644 index 00000000000..69e71996174 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeGitTest.java @@ -0,0 +1,425 @@ +package com.alibaba.qwen.code.managedagent.workspace; + +import com.alibaba.qwen.code.managedagent.api.SessionWorktreeController; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.TenantContext; +import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry.ResolvedBinding; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore.Plan; +import com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore; +import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class SessionWorktreeGitTest { + @TempDir + Path root; + private Path repository; + private SessionWorktreeGit git; + + @BeforeEach + void setup() throws Exception { + root = root.toRealPath(); + repository = Files.createDirectory(root.resolve("project")); + run(repository, "init", "-b", "main"); + Files.writeString(repository.resolve("file.txt"), "committed\n"); + Files.createDirectory(repository.resolve("src")); + Files.writeString(repository.resolve("src/module.txt"), "module\n"); + run(repository, "add", "."); + run(repository, "commit", "-m", "initial"); + git = new SessionWorktreeGit(new ChildWorktreeGit("git", Duration.ofSeconds(10))); + } + + @AfterEach + void close() { + if (git != null) { + git.close(); + } + } + + @Test + void storageRootRepositoryKeepsCurrentModeButRefusesNewNestedCheckouts() throws Exception { + root = repository; + assertThat(git.currentBranch(root, ".")).isEqualTo("main"); + assertThatThrownBy(() -> git.source(root, ".", null)) + .isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo("worktree_layout_unsupported")); + Plan plan = storageRootPlan(); + assertThatThrownBy(() -> git.prepare(plan)) + .isInstanceOfSatisfying(ManagedAgentException.class, + error -> assertThat(error.getCode()).isEqualTo("worktree_layout_unsupported")); + assertThat(Files.exists(root.resolve(SessionWorktreeGit.CONTAINER))).isFalse(); + assertThat(run(root, "status", "--porcelain=v1")).isEmpty(); + assertThat(run(root, "branch", "--list", plan.branch())).isEmpty(); + assertThat(run(root, "branch", "--show-current").strip()).isEqualTo("main"); + } + + @Test + void persistedStorageRootCheckoutCanStillBeVerifiedAndSafelyCleaned() throws Exception { + root = repository; + Plan plan = storageRootPlan(); + Path container = Files.createDirectory(root.resolve(SessionWorktreeGit.CONTAINER)); + var mapper = new ObjectMapper(); + Files.writeString(container.resolve(plan.worktreeId() + ".json"), mapper.writeValueAsString(plan)); + run(root, "worktree", "add", "-b", plan.branch(), "--", target(plan).toString(), plan.baseCommit()); + Path gitfile = target(plan).resolve(".git"); + Path gitdir = Path.of(run(target(plan), "rev-parse", "--absolute-git-dir").strip()); + String identity = mapper.writeValueAsString(java.util.Map.of( + "target", SessionWorktreeGit.identity(target(plan)), "gitfile", SessionWorktreeGit.identity(gitfile), + "gitdir", gitdir.toString(), "gitdirIdentity", SessionWorktreeGit.identity(gitdir))); + Files.writeString(container.resolve(plan.worktreeId() + ".ready"), identity); + assertThat(SessionWorktreeGit.verify(plan, identity)).isEqualTo(identity); + Files.writeString(target(plan).resolve("file.txt"), "valuable\n"); + assertThat(git.cleanupBlocker(plan, identity)).isEqualTo("worktree_dirty"); + assertThatThrownBy(() -> git.cleanup(plan, identity)).isInstanceOf(ManagedAgentException.class); + assertThat(Files.readString(target(plan).resolve("file.txt"))).isEqualTo("valuable\n"); + Files.writeString(target(plan).resolve("file.txt"), "committed\n"); + git.cleanup(plan, identity); + assertThat(Files.exists(target(plan))).isFalse(); + assertThat(Files.readString(root.resolve("file.txt"))).isEqualTo("committed\n"); + assertThat(run(root, "branch", "--show-current").strip()).isEqualTo("main"); + } + + @Test + void optionsKeepCurrentBranchIndependentOfBaseAndExposeBothApiNames() throws Exception { + run(repository, "branch", "topic"); + run(repository, "tag", "start"); + String commit = run(repository, "rev-parse", "HEAD").strip(); + var store = mock(SessionWorktreeStore.class); + var selection = new WorkspaceSelection("workspace", "project"); + var binding = new ContextBinding("tenant", "workspace", 1, "storage", "project", "config", 1); + when(store.source("tenant", "actor", selection)).thenReturn(new SessionWorktreeStore.SourceLocation( + new ResolvedBinding(binding, "config", "policy"), root, null)); + var properties = new ManagedAgentProperties(); + var broker = properties.getRuntimeBroker(); + broker.setEnabled(true); + broker.setSessionWorktreesEnabled(true); + broker.setDurableLocalProcess(true); + broker.setProvisioner("local-process"); + broker.setBoundSessionIsolationClass("session"); + var service = new SessionWorktreeService(store, mock(WorkspaceExecutionStore.class), properties, + mock(ManagedAgentStore.class), mock(HarnessCoordinator.class)); + try { + for (String ref : List.of("topic", "start", commit, "missing", "../invalid")) { + var options = service.options("tenant", "actor", selection, ref); + assertThat(options.currentBranch()).isEqualTo("main"); + assertThat(options.supported()).isEqualTo(!List.of("missing", "../invalid").contains(ref)); + assertThat(new ObjectMapper().valueToTree(options).path("currentBranch").asText()).isEqualTo("main"); + } + var tenant = mock(TenantContext.class); + when(tenant.tenantId()).thenReturn("tenant"); + when(tenant.requireActorId()).thenReturn("actor"); + assertThat(new SessionWorktreeController(service).publicOptions(tenant, "workspace", "project", "topic") + .getBody()).containsEntry("current_branch", "main"); + run(repository, "checkout", "topic"); + assertThat(service.options("tenant", "actor", selection, null).currentBranch()).isEqualTo("topic"); + run(repository, "checkout", "--detach", commit); + assertThat(service.options("tenant", "actor", selection, null).currentBranch()).isEqualTo("HEAD"); + broker.setSessionWorktreesEnabled(false); + assertThat(service.options("tenant", "actor", selection, null).currentBranch()).isNull(); + } finally { + service.stop(); + } + } + + @Test + void currentBranchHandlesNestedAndUnbornReposAndRefusesPrivateOrNonGitPaths() throws Exception { + assertThat(git.currentBranch(root, "project/src")).isEqualTo("main"); + Path unborn = Files.createDirectory(root.resolve("unborn")); + run(unborn, "init", "-b", "new-branch"); + assertThat(git.currentBranch(root, "unborn")).isEqualTo("new-branch"); + Files.createDirectory(root.resolve("not-git")); + assertThatThrownBy(() -> git.currentBranch(root, "not-git")) + .hasFieldOrPropertyWithValue("code", "worktree_not_git"); + assertThatThrownBy(() -> git.currentBranch(root, SessionWorktreeGit.CONTAINER + "/private")) + .hasFieldOrPropertyWithValue("code", "worktree_source_private"); + } + + @Test + void closedSessionRecordsACompletedPreparationWithoutRestartingRuntime() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + Files.writeString(target(plan).resolve("file.txt"), "preserved after interruption\n"); + var store = mock(SessionWorktreeStore.class); + var execution = mock(WorkspaceExecutionStore.class); + var coordinator = mock(HarnessCoordinator.class); + var row = new SessionWorktreeStore.Row("tenant", "session", "workspace", + plan.worktreeId(), "PREPARING", plan, null, "operation", null, 1, null, "PREPARING", 0, 1, null, "actor"); + when(store.claim(eq(row), anyString(), + anyLong())).thenReturn(row); + var service = new SessionWorktreeService(store, execution, + new ManagedAgentProperties(), + mock(ManagedAgentStore.class), coordinator); + try { + service.drive(row); + verify(store).complete(eq(row), anyString(), + eq("READY"), eq(identity), isNull()); + verifyNoInteractions(execution, coordinator); + assertThat(Files.readString(target(plan).resolve("file.txt"))).isEqualTo("preserved after interruption\n"); + } finally { + service.stop(); + } + } + + @Test + void preparationFreezesCommittedContentsWithoutChangingDirtySourceOrIndex() throws Exception { + Plan plan = plan("project", null); + Files.writeString(repository.resolve("file.txt"), "staged\n"); + run(repository, "add", "file.txt"); + Files.writeString(repository.resolve("file.txt"), "unstaged\n"); + Files.writeString(repository.resolve("untracked.txt"), "untracked\n"); + String sourceStatus = run(repository, "status", "--porcelain=v1"); + String index = run(repository, "show", ":file.txt"); + String identity = git.prepare(plan); + assertThat(Files.readString(target(plan).resolve("file.txt"))).isEqualTo("committed\n"); + assertThat(Files.exists(target(plan).resolve("untracked.txt"))).isFalse(); + assertThat(run(repository, "status", "--porcelain=v1")).isEqualTo(sourceStatus); + assertThat(run(repository, "show", ":file.txt")).isEqualTo(index); + assertThat(SessionWorktreeGit.verify(plan, identity)).isEqualTo(identity); + } + + @Test + void retryNeverResetsUserEditsOrRenamedBranch() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + run(target(plan), "branch", "-m", "renamed-work"); + Files.writeString(target(plan).resolve("file.txt"), "keep me\n"); + assertThat(git.prepare(plan)).isEqualTo(identity); + assertThat(Files.readString(target(plan).resolve("file.txt"))).isEqualTo("keep me\n"); + assertThat(run(target(plan), "branch", "--show-current").strip()).isEqualTo("renamed-work"); + assertThat(SessionWorktreeGit.currentBranch(SessionWorktreeGit.verify(plan, identity))).isEqualTo("renamed-work"); + } + + @Test + void interruptedInitialCheckoutIsNeverAdoptedOnReentry() throws Exception { + Path bulk = Files.createDirectory(repository.resolve("bulk")); + for (int i = 0; i < 10000; i++) { + Files.writeString(bulk.resolve(String.format("%05d.txt", i)), "committed\n"); + } + run(repository, "add", "."); + run(repository, "commit", "--quiet", "-m", "many files"); + Plan plan = plan("project", null); + Path container = Files.createDirectory(root.resolve(SessionWorktreeGit.CONTAINER)); + Files.write(container.resolve(plan.worktreeId() + ".json"), new ObjectMapper().writeValueAsBytes(plan)); + Process checkout = new ProcessBuilder("git", "-c", "core.hooksPath=/dev/null", "-C", repository.toString(), + "worktree", "add", "-b", plan.branch(), "--", target(plan).toString(), plan.baseCommit()) + .redirectErrorStream(true).redirectOutput(root.resolve("checkout.log").toFile()).start(); + List descendants = List.of(); + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (checkout.isAlive() && !Files.exists(target(plan).resolve("bulk/00000.txt")) + && System.nanoTime() < deadline) { + Thread.sleep(1); + } + descendants = checkout.descendants().toList(); + } finally { + checkout.destroyForcibly(); + descendants.forEach(ProcessHandle::destroyForcibly); + assertThat(checkout.waitFor(5, TimeUnit.SECONDS)).isTrue(); + for (ProcessHandle child : descendants) { + child.onExit().get(5, TimeUnit.SECONDS); + } + } + long checkedOut; + try (var files = Files.list(target(plan).resolve("bulk"))) { + checkedOut = files.count(); + } + assertThat(checkedOut).isBetween(1L, 9999L); + Files.writeString(target(plan).resolve("user.txt"), "preserve me\n"); + assertThatThrownBy(() -> git.prepare(plan)).isInstanceOf(ManagedAgentException.class) + .hasMessageContaining("checkout completion"); + assertThatThrownBy(() -> SessionWorktreeGit.verify(plan, null)).isInstanceOf(ManagedAgentException.class); + assertThat(Files.readString(target(plan).resolve("user.txt"))).isEqualTo("preserve me\n"); + try (var files = Files.list(target(plan).resolve("bulk"))) { + assertThat(files.count()).isEqualTo(checkedOut); + } + } + + @Test + void missingOrPartialCompletionRecordBlocksEvenACompletedCheckout() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + Path completion = root.resolve(SessionWorktreeGit.CONTAINER).resolve(plan.worktreeId() + ".ready"); + Files.delete(completion); + assertThatThrownBy(() -> git.prepare(plan)).isInstanceOf(ManagedAgentException.class); + Files.writeString(completion, "{"); + assertThatThrownBy(() -> SessionWorktreeGit.verify(plan, identity)).isInstanceOf(ManagedAgentException.class); + assertThat(Files.readString(target(plan).resolve("file.txt"))).isEqualTo("committed\n"); + } + + @Test + void separateSessionsPreserveSubdirectoryAndFrozenBaseAfterHeadMoves() throws Exception { + Plan first = plan("project/src", null); + Files.writeString(repository.resolve("file.txt"), "new main\n"); + run(repository, "add", "."); + run(repository, "commit", "-m", "advance"); + Plan second = plan("project", null); + git.prepare(first); + git.prepare(second); + assertThat(Files.readString(target(first).resolve("file.txt"))).isEqualTo("committed\n"); + assertThat(Files.readString(target(second).resolve("file.txt"))).isEqualTo("new main\n"); + assertThat(Files.isDirectory(root.resolve(first.cwdRelative()))).isTrue(); + Files.writeString(target(first).resolve("file.txt"), "first\n"); + Files.writeString(target(second).resolve("file.txt"), "second\n"); + assertThat(Files.readString(repository.resolve("file.txt"))).isEqualTo("new main\n"); + assertThat(first.branch()).isNotEqualTo(second.branch()); + } + + @Test + void cleanupRetainsCommittedBranchAndIsIdempotent() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + Files.writeString(target(plan).resolve("file.txt"), "result\n"); + run(target(plan), "add", "."); + run(target(plan), "commit", "-m", "result"); + String result = run(target(plan), "rev-parse", "HEAD").strip(); + assertThat(git.cleanupBlocker(plan, identity)).isNull(); + git.cleanup(plan, identity); + git.cleanup(plan, identity); + assertThat(Files.exists(target(plan))).isFalse(); + assertThat(run(repository, "rev-parse", plan.branch()).strip()).isEqualTo(result); + assertThat(run(repository, "show", plan.branch() + ":file.txt")).isEqualTo("result\n"); + } + + @Test + void cleanupRefusesIgnoredAndUntrackedContents() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + Files.writeString(target(plan).resolve("untracked.txt"), "valuable\n"); + assertThat(git.cleanupBlocker(plan, identity)).isEqualTo("worktree_dirty"); + Files.delete(target(plan).resolve("untracked.txt")); + Files.writeString(Path.of(plan.commonDirectory()).resolve("info/exclude"), "private.env\n"); + Files.writeString(target(plan).resolve("private.env"), "valuable\n"); + assertThat(git.cleanupBlocker(plan, identity)).isEqualTo("worktree_dirty"); + assertThatThrownBy(() -> git.cleanup(plan, identity)).isInstanceOf(ManagedAgentException.class); + assertThat(Files.readString(target(plan).resolve("private.env"))).isEqualTo("valuable\n"); + } + + @Test + void cleanupRefusesHiddenTrackedChanges() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + run(target(plan), "update-index", "--assume-unchanged", "file.txt"); + Files.writeString(target(plan).resolve("file.txt"), "hidden work\n"); + assertThat(run(target(plan), "status", "--porcelain=v1")).isEmpty(); + assertThat(git.cleanupBlocker(plan, identity)).isEqualTo("worktree_index_flags"); + assertThatThrownBy(() -> git.cleanup(plan, identity)).isInstanceOf(ManagedAgentException.class); + assertThat(Files.readString(target(plan).resolve("file.txt"))).isEqualTo("hidden work\n"); + run(target(plan), "update-index", "--no-assume-unchanged", "file.txt"); + run(target(plan), "update-index", "--skip-worktree", "file.txt"); + assertThat(git.cleanupBlocker(plan, identity)).isEqualTo("worktree_index_flags"); + } + + @Test + void cleanupProtectsDetachedCommitWithoutRetainedRef() throws Exception { + Plan plan = plan("project", null); + String identity = git.prepare(plan); + run(target(plan), "checkout", "--detach"); + Files.writeString(target(plan).resolve("file.txt"), "detached\n"); + run(target(plan), "add", "."); + run(target(plan), "commit", "-m", "detached"); + assertThat(git.cleanupBlocker(plan, identity)).isEqualTo("worktree_unretained_commit"); + run(target(plan), "tag", "saved-result"); + assertThat(git.cleanupBlocker(plan, identity)).isNull(); + } + + @Test + void replacedDirectoryAndForeignBranchAreNeverAdopted() throws Exception { + Plan plan = plan("project", null); + run(repository, "branch", plan.branch()); + assertThatThrownBy(() -> git.prepare(plan)).isInstanceOf(ManagedAgentException.class) + .hasMessageContaining("already exists"); + assertThat(Files.exists(target(plan))).isFalse(); + Plan other = plan("project", null); + String identity = git.prepare(other); + Files.move(target(other), target(other).resolveSibling("saved-original")); + Files.createDirectory(target(other)); + assertThatThrownBy(() -> SessionWorktreeGit.verify(other, identity)).isInstanceOf(ManagedAgentException.class); + assertThatThrownBy(() -> git.prepare(other)).isInstanceOf(ManagedAgentException.class); + } + + @Test + void sourceLinkedCheckoutCanDisappearAfterPreparation() throws Exception { + Path source = root.resolve("source-linked"); + run(repository, "worktree", "add", "--detach", source.toString()); + Plan plan = plan("source-linked", null); + String identity = git.prepare(plan); + run(repository, "worktree", "remove", source.toString()); + assertThat(SessionWorktreeGit.verify(plan, identity)).isEqualTo(identity); + git.cleanup(plan, identity); + assertThat(Files.exists(target(plan))).isFalse(); + } + + @Test + void rejectsCommonDirectoryOutsideStorageAndMissingBaseDirectory() throws Exception { + Path smallStorage = Files.createDirectory(root.resolve("small-storage")); + Path linked = smallStorage.resolve("linked"); + run(repository, "worktree", "add", "--detach", linked.toString()); + assertThatThrownBy(() -> git.source(smallStorage, "linked", null)).isInstanceOf(ChildWorkspaceException.class); + run(repository, "checkout", "--orphan", "empty"); + run(repository, "rm", "-rf", "."); + Files.writeString(repository.resolve("only.txt"), "empty\n"); + run(repository, "add", "."); + run(repository, "commit", "-m", "empty"); + assertThatThrownBy(() -> git.source(root, "project/src", "empty")).isInstanceOf(RuntimeException.class); + } + + private Plan storageRootPlan() throws Exception { + String id = UUID.randomUUID().toString().replace("-", ""); + String directory = SessionWorktreeGit.CONTAINER + "/" + id; + Path common = root.resolve(".git"); + return new Plan("tenant", "workspace", 1, "storage", ".", "config", 1, root.toString(), + SessionWorktreeGit.identity(root), ".", ".", common.toString(), SessionWorktreeGit.identity(common), + null, run(root, "rev-parse", "HEAD").strip(), id, directory, directory, "worktree-managed-" + id); + } + + private Plan plan(String cwd, String ref) { + var source = git.source(root, cwd, ref); + String id = UUID.randomUUID().toString().replace("-", ""); + String directory = SessionWorktreeGit.CONTAINER + "/" + id; + return new Plan("tenant", "workspace", 1, "storage", cwd, "config", 1, root.toString(), + SessionWorktreeGit.identity(root), source.repositoryRelative(), source.offset(), source.commonDirectory(), + source.commonIdentity(), ref, source.commit(), id, directory, + ".".equals(source.offset()) ? directory : directory + "/" + source.offset(), "worktree-managed-" + id); + } + + private Path target(Plan plan) { + return root.resolve(plan.worktreeRoot()); + } + + private static String run(Path directory, String... args) throws Exception { + List command = new ArrayList<>(List.of("git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "-c", "core.hooksPath=/dev/null", "-C", directory.toString())); + command.addAll(List.of(args)); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + assertThat(process.waitFor(10, TimeUnit.SECONDS)).isTrue(); + String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(process.exitValue()).as(output).isZero(); + return output; + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeServiceTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeServiceTest.java new file mode 100644 index 00000000000..ead7e776e62 --- /dev/null +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/workspace/SessionWorktreeServiceTest.java @@ -0,0 +1,150 @@ +package com.alibaba.qwen.code.managedagent.workspace; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.config.ManagedArtifactConfiguration; +import com.alibaba.qwen.code.managedagent.event.MessageMaterializer; +import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.persistence.SessionWorktreeStore; +import com.alibaba.qwen.code.managedagent.persistence.WorkspaceExecutionStore; +import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SessionWorktreeServiceTest { + private final SessionWorktreeStore store = mock(SessionWorktreeStore.class); + private final WorkspaceExecutionStore execution = mock(WorkspaceExecutionStore.class); + private final ManagedAgentStore sessions = mock(ManagedAgentStore.class); + private final SessionWorktreeService service = new SessionWorktreeService(store, execution, new ManagedAgentProperties(), + sessions, mock(HarnessCoordinator.class)); + + @AfterEach + void stop() { + service.stop(); + } + + @Configuration + @EnableScheduling + static class SchedulingHarness { } + + @Test + void parkedWorktreeScanDoesNotBlockTheDefaultScheduler() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var scanThread = new AtomicReference(); + doAnswer(invocation -> { + scanThread.set(Thread.currentThread().getName()); + entered.countDown(); + release.await(10, TimeUnit.SECONDS); + return null; + }).when(store).resumeStopped(); + var context = new AnnotationConfigApplicationContext(); + context.register(SchedulingHarness.class, + TaskSchedulingAutoConfiguration.class, + ManagedArtifactConfiguration.class); + context.registerBean(ManagedAgentProperties.class, ManagedAgentProperties::new); + context.registerBean(MessageMaterializer.class, () -> mock(MessageMaterializer.class)); + context.registerBean("worktrees", SessionWorktreeService.class, () -> service); + try { + context.refresh(); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + var tick = new CountDownLatch(1); + context.getBean("taskScheduler", ThreadPoolTaskScheduler.class) + .schedule(tick::countDown, Instant.now()); + assertThat(tick.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(scanThread.get()).startsWith("session-worktree-"); + } finally { + release.countDown(); + context.close(); + } + } + + @Test + void storageBusyDefersWithoutFailingOrConsumingAnAttempt() { + var row = preparing(); + when(execution.holdForMaintenance(any(), anyString(), anyLong())).thenThrow( + new RuntimeBrokerException(409, "workspace_busy", "busy", true)); + service.drive(row); + verify(store).defer(eq(row), anyString(), eq("workspace_busy")); + verify(store, never()).retry(any(), anyString(), anyString()); + verify(store, never()).complete(any(), anyString(), anyString(), any(), any()); + verify(sessions, never()).failWorktreeInitialTurns(anyString(), anyString(), anyString()); + } + + @Test + void retryableMountFailureKeepsTheSamePreparation() { + var row = preparing(); + when(execution.holdForMaintenance(any(), anyString(), anyLong())).thenThrow( + new RuntimeBrokerException(409, "workspace_unavailable", "temporary", true)); + service.drive(row); + verify(store).retry(eq(row), anyString(), eq("workspace_unavailable")); + verify(store, never()).complete(any(), anyString(), anyString(), any(), any()); + } + + @Test + void unknownOriginalGitExecutionStaysBlocked() { + var row = preparing(); + when(execution.holdForMaintenance(any(), anyString(), anyLong())).thenThrow( + new RuntimeBrokerException(409, "maintenance_stop_unverified", "unknown", false)); + when(store.maintenanceHeld(row)).thenReturn(true); + service.drive(row); + verify(store).complete(eq(row), anyString(), eq("BLOCKED"), eq(null), eq("maintenance_stop_unverified")); + verify(execution, never()).releaseMaintenance(any(), anyString(), anyLong(), anyString()); + } + + @Test + void cleanupRechecksWritersAndPermissionAfterTakingMaintenance() { + var row = operation("REMOVING"); + when(store.cleanupBlocker(row)).thenReturn(null, "worktree_forbidden"); + when(execution.holdForMaintenance(any(), anyString(), anyLong())).thenReturn("step"); + service.drive(row); + verify(execution).releaseMaintenance(any(), eq(row.operationId()), eq(row.generation()), eq("step")); + verify(store).complete(eq(row), anyString(), eq("READY"), eq(null), eq("worktree_forbidden")); + } + + @Test + void scanSettlesTheInitialTurnAfterAFailureCommitWasInterrupted() { + when(store.failedInitialSessions()).thenReturn(java.util.Collections.singletonList(new String[] {"tenant", "session", "worktree_git_failed"})); + when(store.due()).thenReturn(List.of()); + service.scan(); + verify(sessions).failWorktreeInitialTurns("tenant", "session", "worktree_git_failed"); + } + + private SessionWorktreeStore.Row preparing() { + return operation("PREPARING"); + } + + private SessionWorktreeStore.Row operation(String state) { + String id = "a".repeat(32); + var plan = new SessionWorktreeStore.Plan("tenant", "workspace", 1, "storage", "project", "config", 1, + "/storage", "root", "project", ".", "/storage/project/.git", "git", null, "a".repeat(40), + id, ".qwen-managed-worktrees/" + id, ".qwen-managed-worktrees/" + id, "worktree-managed-" + id); + var row = new SessionWorktreeStore.Row("tenant", "session", "workspace", id, state, plan, null, id, + null, 1, null, state, 0, 1, null, "actor"); + when(store.claim(eq(row), anyString(), anyLong())).thenReturn(row); + when(store.active(row)).thenReturn(true); + return row; + } +} diff --git a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java index ff77d83ac4a..e1854e8e856 100644 --- a/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java +++ b/packages/managed-agent-server/src/test/java/com/alibaba/qwen/code/runtimebroker/managedschema/ManagedSchemaTest.java @@ -566,7 +566,7 @@ class ManagedSchemaTest { && ManagedSchemaContract.TABLES.containsAll( tables))); Set originalTargets = new java.util.HashSet<>(ManagedSchemaContract.TABLES); - originalTargets.removeAll(Set.of("ma_extension_package", "ma_extension_settings", "ma_extension_operation")); + originalTargets.removeAll(Set.of("ma_extension_package", "ma_extension_settings", "ma_extension_operation", "ma_session_worktree")); assertEquals( originalTargets, inventory.values().stream() diff --git a/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt b/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt index 6d8e74c6537..25381149bec 100644 --- a/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt +++ b/packages/managed-agent-server/src/test/resources/capability-parity/managed-surface.txt @@ -1,5 +1,5 @@ # Generated by ManagedSurfaceCaptureTest (capture). -# sha256 of the route lines below: e36c1bf362114f3f661ffeb583268a663598c5b749bc3c10dd695d893bda1978 +# sha256 of the route lines below: e162a77898654c5daa48396f6472ffc2b5e646f313bf57f080925b51cc1d5b32 AUTOMATION_RETIRE DELETE /v1/agent-automations/{automationId} public SESSION_DELETE DELETE /v1/agents/sessions/{sessionId} public STORE_SNAPSHOT_GET GET /internal/managed-session-store/v1/sessions/{sessionId}/journal-snapshots/{snapshotId} internal @@ -46,6 +46,9 @@ EXTENSION_OPERATION GET /v1/agents/workspaces/{workspaceId}/extensions/operation EXTENSION_GET GET /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision} public EXTENSION_ARCHIVE GET /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageRevision}/archive public EXTENSION_SETTINGS_GET GET /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings public +SESSION_WORKTREE GET /v1/agents/workspaces/{workspaceId}/worktrees public +SESSION_WORKTREE GET /v1/agents/workspaces/{workspaceId}/worktrees/options public +SESSION_WORKTREE GET /v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId} public AGENT_DEFINITION_GET GET /v1/agents/{agentId} public SESSION_RENAME PATCH /v1/agents/sessions/{sessionId} public ACTION_GET POST /api/agent/web-shell/v1/actions/get webshell @@ -110,6 +113,10 @@ WORKSPACE_GET POST /api/agent/web-shell/v1/workspaces/get webshell WORKSPACE_REGISTRATION_GET POST /api/agent/web-shell/v1/workspaces/operations/get webshell WORKSPACE_LIST POST /api/agent/web-shell/v1/workspaces/query webshell WORKSPACE_REGISTER POST /api/agent/web-shell/v1/workspaces/register webshell +SESSION_WORKTREE POST /api/agent/web-shell/v1/workspaces/worktrees/cleanup webshell +SESSION_WORKTREE POST /api/agent/web-shell/v1/workspaces/worktrees/inspect webshell +SESSION_WORKTREE POST /api/agent/web-shell/v1/workspaces/worktrees/options webshell +SESSION_WORKTREE POST /api/agent/web-shell/v1/workspaces/worktrees/query webshell STORE_APPROVAL_QUERY POST /internal/managed-session-store/v1/sessions/{sessionId}/approval:query internal STORE_EXECUTION_AUTHORIZE POST /internal/managed-session-store/v1/sessions/{sessionId}/execution:authorize internal STORE_SNAPSHOT_PUBLISH POST /internal/managed-session-store/v1/sessions/{sessionId}/journal-snapshots:publish internal @@ -156,5 +163,6 @@ SESSION_UNARCHIVE POST /v1/agents/sessions/{sessionId}/unarchive public USER_MESSAGE_COMMAND POST /v1/agents/sessions/{sessionId}/user-messages public EXTENSION_PUBLISH POST /v1/agents/workspaces/{workspaceId}/extensions public EXTENSION_SETTINGS_SET POST /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/settings public +SESSION_WORKTREE POST /v1/agents/workspaces/{workspaceId}/worktrees/{worktreeId}/cleanup public AGENT_DEFINITION_UPDATE POST /v1/agents/{agentId} public EXTENSION_PUBLISH PUT /v1/agents/workspaces/{workspaceId}/extensions/{extensionId}/packages/{packageDigest} public diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 142e5a01ff5..0911ec8dab9 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -408,6 +408,7 @@ interface ChatEditorRenderProps | 'btwEnabled' | 'isPreparing' | 'contextChipPlacement' + | 'gitContextControl' | 'standaloneTargetSupported' | 'onSelectStandaloneTarget' | 'workspaceSelectionDisabled' @@ -2195,6 +2196,30 @@ describe('ChatEditor context chip placement', () => { ).toBeNull(); }); + it('places a service-owned Git control beside the workspace without a Local Git picker', () => { + const container = renderChatEditor({ + ...workspaceProps, + contextChipPlacement: 'below', + gitContextControl: , + }); + const row = container.querySelector( + '[data-web-shell-composer-context-row]', + )!; + expect(row.querySelector('button[aria-label="Workspace"]')).not.toBeNull(); + expect(row.textContent).toContain('Managed worktree'); + expect( + container + .querySelector('[data-web-shell-composer-surface]')! + .contains(row), + ).toBe(false); + expect( + [...container.querySelectorAll('button')].filter( + (button) => button.textContent === 'Managed worktree', + ), + ).toHaveLength(1); + expect(container.querySelector('[data-testid="branch-picker"]')).toBeNull(); + }); + it('leaves the workspace to the header and keeps git in the toolbar', () => { const container = renderChatEditor({ ...bothChips, @@ -4955,6 +4980,25 @@ describe('ChatEditor mobile composer actions', () => { ).not.toBeNull(); }); + it('keeps the service-owned Git control once in the mobile context row', () => { + mobileComposer(); + const container = renderChatEditor({ + contextChipPlacement: 'below', + gitContextControl: , + visibleToolbarActions: [], + }); + const row = container.querySelector('[class*="mobileContextRow"]')!; + expect(row.textContent).toContain('Managed worktree'); + expect( + [...container.querySelectorAll('button')].filter( + (button) => button.textContent === 'Managed worktree', + ), + ).toHaveLength(1); + expect( + container.querySelector('[data-web-shell-composer-context-row]'), + ).toBeNull(); + }); + it.each([{ sessionId: 'b' }, { atWorkspaceCwd: '/b' }, { disabled: true }])( 'closes expanded editing only when its owner changes: %j', async (change) => { diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 8541d2f3383..07589238d76 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -245,6 +245,8 @@ interface ChatEditorProps { currentModel?: string; modelControlsDisabled?: boolean; gitBranch?: string; + /** Service-owned Git control in the workspace row below the composer. */ + gitContextControl?: ReactNode; /** Whether the session is in a worktree (styles the git chip purple). */ gitWorktree?: boolean; /** Git working directory for worktree sessions; targets git operations. */ @@ -1565,6 +1567,7 @@ export const ChatEditor = memo( currentModel = '', modelControlsDisabled = false, gitBranch, + gitContextControl, gitWorktree, gitCwd, gitModeIntent, @@ -2696,7 +2699,9 @@ export const ChatEditor = memo( gitBranchVisible && contextChipPlacement !== 'below'; const contextRowVisible = contextChipPlacement === 'below' && - (workspaceSelectVisible || gitBranchVisible); + (workspaceSelectVisible || + gitBranchVisible || + Boolean(gitContextControl)); useLayoutEffect(() => { if (currentModelLabel && currentModelLabel !== lastConfirmedModelLabel) { @@ -2779,7 +2784,10 @@ export const ChatEditor = memo( isMobile && !isSimple && !mobileVoiceActive && - (workspaceSelectVisible || workspaceIndicatorVisible || gitBranchVisible); + (workspaceSelectVisible || + workspaceIndicatorVisible || + gitBranchVisible || + Boolean(gitContextControl)); // The workspace/Git row lives inside the capped box and wraps on narrow // viewports, so its measured height is added back to the cap. const mobileContextRowRef = useRef(null); @@ -2995,6 +3003,7 @@ export const ChatEditor = memo( )} {(inToolbar ? gitChipInToolbar : gitBranchVisible) && renderGitBranchChip(inToolbar && !showGitBranchLabel)} + {!inToolbar && gitContextControl} ); @@ -4457,6 +4466,7 @@ export const ChatEditor = memo( > {workspaceSelectVisible && renderWorkspaceSelector(false)} {gitBranchVisible && renderGitBranchChip(false)} + {gitContextControl} )} {core.mobileComposer && ( diff --git a/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.test.tsx index e6210106e8b..5a16f4b8960 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.test.tsx @@ -101,7 +101,19 @@ describe('Managed session navigation row', () => { configurable: true, value: { writeText }, }); + props.session.worktree = { + id: 'worktree-a', + sessionId: 'session-one', + workspaceId: 'workspace-one', + state: 'ready', + branch: 'worktree-managed-a', + baseCommit: 'a'.repeat(40), + cwdRelative: '.qwen-managed-worktrees/a', + operationId: 'operation-a', + revision: 1, + }; await render(); + expect(container.textContent).not.toContain('worktree-managed-a'); await hover(); const details = portal.querySelector('[role="dialog"]')!; expect(details.textContent).toContain('Task one'); @@ -109,6 +121,9 @@ describe('Managed session navigation row', () => { expect(details.textContent).toContain('Project / src'); expect(details.textContent).toContain('Completed'); expect(details.textContent).not.toContain('clients'); + expect(details.textContent).toContain('Worktree ready'); + expect(details.textContent).toContain('worktree-managed-a'); + expect(details.textContent).toContain('Base commit: aaaaaaaaaaaa'); await act(async () => details .querySelector('[data-web-shell-session-id-copy]')! diff --git a/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.tsx b/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.tsx index dfdd6568e01..2a60c148663 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionNavigationRow.tsx @@ -1,9 +1,11 @@ import type { CSSProperties, ReactNode } from 'react'; +import { GitForkIcon } from 'lucide-react'; import { useI18n } from '../../i18n'; import { formatDateTime } from '../../utils/formatDateTime'; import { SessionDetailsTooltip } from '../sidebar/SessionDetailsTooltip'; import { measureSessionTitleScroll } from '../sidebar/sessionTitleScroll'; import styles from '../sidebar/WebShellSidebar.module.css'; +import { ManagedWorktreeStatus } from './ManagedSessionWorktrees'; import { isManagedSessionRunning, type ManagedAgentSessionSummary, @@ -41,6 +43,11 @@ export function ManagedSessionNavigationRow({ ? `managed.sessionState.${session.lifecycleStatus}` : `managed.phase.${session.phase}`, )} + details={ + session.worktree && ( + + ) + } >
- {running && ( + {running ? ( - )} + ) : session.worktree ? ( + + + + ) : null} {actions && (
{ + let root: Root; + let container: HTMLDivElement; + let api: NonNullable; + + async function flush() { + for (let index = 0; index < 8; index++) await Promise.resolve(); + } + + async function render() { + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + } + + async function click(label: string, dialog = '[role="dialog"]') { + const button = [ + ...document.querySelectorAll(`${dialog} button`), + ].find((item) => item.textContent === label); + expect(button).toBeDefined(); + await act(async () => { + button!.click(); + await flush(); + }); + } + + beforeEach(() => { + api = { + options: vi.fn(), + list: vi.fn().mockResolvedValue({ data: [worktree] }), + inspect: vi.fn().mockResolvedValue({ worktree, canCleanup: true }), + cleanup: vi + .fn() + .mockResolvedValue({ ...worktree, state: 'removing', revision: 3 }), + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + it('keeps deleted-session worktrees accessible at Workspace scope and confirms directory-only cleanup', async () => { + await render(); + expect(document.body.textContent).toContain('deleted-session'); + expect(document.body.textContent).toContain('keeps the branch'); + expect(document.body.textContent).toContain('worktree-managed-a'); + expect(document.body.textContent).toContain('Base commit: aaaaaaaaaaaa'); + expect(api.inspect).not.toHaveBeenCalled(); + await click('Inspect cleanup'); + expect(api.inspect).toHaveBeenCalledWith( + 'ws-a', + 'worktree-a', + expect.objectContaining({ clientId: 'client-a' }), + ); + await click('Remove worktree directory'); + expect(api.cleanup).not.toHaveBeenCalled(); + expect( + document.querySelector('[role="alertdialog"]')?.textContent, + ).toContain('Git branch will be retained'); + await click('Remove worktree directory', '[role="alertdialog"]'); + expect(api.cleanup).toHaveBeenCalledWith( + 'ws-a', + 'worktree-a', + expect.objectContaining({ + clientId: 'client-a', + idempotencyKey: expect.any(String), + }), + ); + }); + + it('shows cleanup blockers and never offers force deletion', async () => { + vi.mocked(api.inspect).mockResolvedValue({ + worktree, + canCleanup: false, + cleanupBlocker: 'worktree_dirty', + }); + await render(); + await click('Inspect cleanup'); + expect(document.body.textContent).toContain('worktree_dirty'); + expect(document.body.textContent).toContain( + 'uncommitted changes, untracked files or ignored files', + ); + expect( + [...document.querySelectorAll('button')].some( + (item) => item.textContent === 'Remove worktree directory', + ), + ).toBe(false); + expect(api.cleanup).not.toHaveBeenCalled(); + }); + + it('reuses the cleanup request key after an uncertain response', async () => { + vi.mocked(api.cleanup).mockRejectedValueOnce( + new TypeError('Lost response'), + ); + await render(); + await click('Inspect cleanup'); + await click('Remove worktree directory'); + await click('Remove worktree directory', '[role="alertdialog"]'); + expect( + document.querySelector('[role="alertdialog"]')?.textContent, + ).toContain('Lost response'); + await click('Remove worktree directory', '[role="alertdialog"]'); + expect(vi.mocked(api.cleanup).mock.calls[1]![2].idempotencyKey).toBe( + vi.mocked(api.cleanup).mock.calls[0]![2].idempotencyKey, + ); + }); +}); diff --git a/packages/web-shell/client/components/managed/ManagedSessionWorktrees.tsx b/packages/web-shell/client/components/managed/ManagedSessionWorktrees.tsx new file mode 100644 index 00000000000..02e48e0ef85 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedSessionWorktrees.tsx @@ -0,0 +1,318 @@ +import { useEffect, useRef, useState } from 'react'; +import { useI18n } from '../../i18n'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '../ui/alert-dialog'; +import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '../ui/dialog'; +import { Spinner } from '../ui/spinner'; +import type { + ManagedAgentProvider, + ManagedSessionWorktree, +} from './managed-agent-provider'; +import { managedRequestId } from './managed-session-storage'; +import { + useManagedWorktreeReason, + useManagedWorktreeText, +} from './managed-worktree-messages'; + +export function ManagedWorktreeStatus({ + worktree, + compact = false, +}: { + worktree: ManagedSessionWorktree; + compact?: boolean; +}) { + const text = useManagedWorktreeText(); + const reason = useManagedWorktreeReason(); + const progressing = ['preparing', 'removing'].includes(worktree.state); + return ( +
+
+ {progressing &&
+ {!compact && ( + <> +

+ {text('branch')}: {worktree.branch} +

+

+ {text('baseCommit')}: {worktree.baseCommit.slice(0, 12)} +

+ + )} + {worktree.errorCode && ( +

{reason(worktree.errorCode)}

+ )} +
+ ); +} + +type Inspection = Awaited< + ReturnType['inspect']> +>; + +export function ManagedSessionWorktrees({ + provider, + clientId, + workspaceId, + open, + onOpenChange, +}: { + provider: Pick; + clientId: string; + workspaceId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useI18n(); + const text = useManagedWorktreeText(); + const reason = useManagedWorktreeReason(); + const api = provider.sessionWorktrees; + const [items, setItems] = useState(); + const [selected, setSelected] = useState(); + const [inspection, setInspection] = useState(); + const [revision, setRevision] = useState(0); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const [confirm, setConfirm] = useState(false); + const command = useRef<{ id: string; key: string } | undefined>(undefined); + const lifetime = useRef(undefined); + const selectedRevision = items?.find( + (item) => item.id === selected, + )?.revision; + + useEffect(() => { + const abort = new AbortController(); + lifetime.current = abort; + setItems(undefined); + setSelected(undefined); + setInspection(undefined); + setError(undefined); + setBusy(false); + setConfirm(false); + command.current = undefined; + return () => abort.abort(); + }, [provider.storageKey, clientId, workspaceId, open]); + + useEffect(() => { + if (!open || !api) return; + const abort = new AbortController(); + setError(undefined); + let timer: ReturnType; + async function load() { + setLoading(true); + try { + const result = await api!.list(workspaceId, { + clientId, + signal: abort.signal, + }); + if (abort.signal.aborted) return; + setItems(result.data); + if ( + result.data.some((item) => + ['preparing', 'removing'].includes(item.state), + ) + ) + timer = setTimeout(() => void load(), 2000); + } catch (failure) { + if (!abort.signal.aborted) + setError( + failure instanceof Error ? failure.message : String(failure), + ); + } finally { + if (!abort.signal.aborted) setLoading(false); + } + } + void load(); + return () => { + abort.abort(); + clearTimeout(timer); + }; + }, [api, clientId, workspaceId, open, revision]); + + useEffect(() => { + setInspection(undefined); + if (!open || !api || !selected) return; + const abort = new AbortController(); + void api + .inspect(workspaceId, selected, { clientId, signal: abort.signal }) + .then( + (value) => { + if (!abort.signal.aborted) setInspection(value); + }, + (failure: unknown) => { + if (!abort.signal.aborted) + setError( + failure instanceof Error ? failure.message : String(failure), + ); + }, + ); + return () => abort.abort(); + }, [api, clientId, workspaceId, open, selected, selectedRevision, revision]); + + async function cleanup() { + const abort = lifetime.current; + if ( + !api || + !inspection?.canCleanup || + !abort || + abort.signal.aborted || + busy + ) + return; + const id = inspection.worktree.id; + if (command.current?.id !== id) + command.current = { id, key: managedRequestId() }; + setBusy(true); + setError(undefined); + try { + await api.cleanup(workspaceId, id, { + clientId, + idempotencyKey: command.current.key, + signal: abort.signal, + }); + if (abort.signal.aborted) return; + command.current = undefined; + setInspection(undefined); + setConfirm(false); + setRevision((value) => value + 1); + } catch (failure) { + if (!abort.signal.aborted) + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + if (!abort.signal.aborted) setBusy(false); + } + } + + return ( + <> + { + if (!busy) onOpenChange(value); + }} + > + + + {text('manage')} + {text('retained')} + +

{workspaceId}

+ {error && ( +

+ {error} +

+ )} + {loading && !items &&

{t('managed.loading')}

} + {items?.length === 0 &&

{text('empty')}

} +
+ {items?.map((item) => ( +
+

+ {text('session')}: {item.sessionId} +

+

{item.cwdRelative}

+ + {item.state !== 'removed' && ( + + )} + {inspection?.worktree.id === item.id && + (inspection.canCleanup ? ( + + ) : inspection.cleanupBlocker ? ( +

+ {text('blocked')}: {reason(inspection.cleanupBlocker)} +

+ ) : null)} +
+ ))} +
+ +
+
+ { + if (!busy) setConfirm(value); + }} + > + + + {text('confirm')} + + {text('confirmDescription')} + + +

+ {inspection?.worktree.cwdRelative} +

+ {error && ( +

+ {error} +

+ )} + + + {t('common.cancel')} + + { + event.preventDefault(); + void cleanup(); + }} + > + {text('cleanup')} + + +
+
+ + ); +} diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx index 90b050708d5..5efcd542b74 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -315,6 +315,113 @@ describe('ManagedSessionsPage', () => { }); } + it.each([false, true])( + 'refreshes a preparing empty worktree session until ready (home: %s) and keeps attachments unavailable during preparation', + async (home) => { + vi.useFakeTimers(); + const worktree = { + id: 'worktree-a', + sessionId: 's1', + workspaceId: 'ws-a', + state: 'preparing' as const, + branch: 'worktree-managed-a', + baseCommit: 'a'.repeat(40), + cwdRelative: '.qwen-managed-session-worktrees/a', + operationId: 'op-a', + revision: 1, + }; + let current = summary('s1', { + phase: 'created', + activeTurnId: undefined, + worktree, + workspace: { + workspaceId: 'ws-a', + cwdRelative: worktree.cwdRelative, + state: 'changing', + }, + capabilities: { + canSend: false, + canCancel: false, + inputAttachments: true, + workspaceTurns: true, + }, + }); + mocks.client.getSession.mockImplementation(async () => current); + mocks.client.getTranscript.mockResolvedValue({ + events: [], + lastEventId: 0, + }); + await render('s1', 'en', provider, home ? null : undefined); + expect(container.textContent).toContain('Preparing worktree'); + if (home) { + expect(container.textContent).not.toContain(worktree.branch); + expect(container.textContent).not.toContain('Base commit'); + } + expect(mocks.editorProps!.attachmentsEnabled).toBe(false); + expect(mocks.editorProps!.fileReferenceActions).toBeUndefined(); + current = { + ...current, + worktree: { ...worktree, state: 'ready', revision: 2 }, + workspace: { ...current.workspace!, state: 'ready' }, + capabilities: { + canSend: true, + canCancel: false, + inputAttachments: true, + workspaceTurns: true, + }, + }; + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + await flush(); + }); + if (home) { + expect(container.querySelector('[data-managed-worktree]')).toBeNull(); + expect(container.textContent).not.toContain(worktree.branch); + } else { + expect(container.textContent).toContain('Worktree ready'); + expect(container.textContent).toContain(worktree.branch); + } + expect(mocks.editorProps!.attachmentsEnabled).toBe(true); + expect(mocks.client.getTranscript).toHaveBeenCalledOnce(); + }, + ); + + it.each(['blocked', 'failed'] as const)( + 'keeps %s worktree attention visible in Home without branch details', + async (state) => { + mocks.client.getSession.mockResolvedValue( + summary('s1', { + capabilities: { canSend: false, canCancel: false }, + worktree: { + id: 'worktree-a', + sessionId: 's1', + workspaceId: 'ws-a', + state, + branch: 'worktree-managed-a', + baseCommit: 'a'.repeat(40), + cwdRelative: '.qwen-managed-worktrees/a', + operationId: 'operation-a', + revision: 1, + errorCode: 'worktree_identity_conflict', + }, + }), + ); + await render('s1', 'en', provider, null); + const status = container.querySelector( + `[data-managed-worktree="${state}"]`, + )!; + expect(status.textContent).toContain( + state === 'blocked' + ? 'Worktree needs attention' + : 'Worktree preparation failed', + ); + expect(status.textContent).toContain('worktree_identity_conflict'); + expect(status.textContent).not.toContain('worktree-managed-a'); + expect(status.textContent).not.toContain('Base commit'); + expect(mocks.editorProps!.disabled).toBe(true); + }, + ); + it('leaves MCP management to Plugins and refreshes the selected composer catalog on return', async () => { const query = vi.fn(); const list = vi.fn(async (id: string) => ({ @@ -1100,7 +1207,7 @@ describe('ManagedSessionsPage', () => { expect(mocks.editorProps?.skills).toEqual(skills); expect(mocks.editorProps?.fileReferenceActions).toBeDefined(); expect(query).toHaveBeenCalledWith( - { workspaceId: 'ws', cwdRelative: '.', directory: '.', query: '' }, + { sessionId: 's1', directory: '.', query: '' }, expect.objectContaining({ clientId: expect.any(String) }), ); }); diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx index f306884a9d6..bdab54df6ee 100644 --- a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx @@ -70,6 +70,7 @@ import { toManagedPermissionRequest } from './managed-approval'; import { isNonRetryableClientError } from './managed-request-error'; import { ManagedSessionProgress } from './ManagedSessionProgress'; import { WorkspaceBindingCreator } from './WorkspaceBindingCreator'; +import { ManagedWorktreeStatus } from './ManagedSessionWorktrees'; import type { ManagedCreationSkillsSource } from './managed-creation-skills'; import { ManagedWorkspaceNavigation } from './ManagedWorkspaceNavigation'; import { ManagedSessionNavigationRow } from './ManagedSessionNavigationRow'; @@ -354,6 +355,8 @@ function ManagedSessionsContent({ ); const detail = useManagedSession(provider, clientId, sessionId); const summary = detail.summary; + const worktreeReady = + !summary?.worktree || summary.worktree.state === 'ready'; const active = summary && isManagedSessionRunning(summary.phase); const recovery = useManagedRecovery( provider, @@ -385,6 +388,7 @@ function ManagedSessionsContent({ clientId, detail.summary && detail.summary.sessionId === sessionId && + worktreeReady && detail.summary.capabilities.inputAttachments ? detail.summary.workspace : undefined, @@ -1168,7 +1172,7 @@ function ManagedSessionsContent({ attachmentCapability.current = { sessionId, enabled: false }; if (summary) attachmentCapability.current.enabled = - summary.capabilities.inputAttachments === true; + summary.capabilities.inputAttachments === true && worktreeReady; const { request: requestTurnCancel, busy: turnCancelBusy } = turnCancel; const failureMessage = summary?.failure ? (FAILURE_MESSAGES[summary.failure.code]?.[language === 'zh-CN' ? 1 : 0] ?? @@ -1787,6 +1791,13 @@ function ManagedSessionsContent({ )}
)} + {summary?.worktree && + (!home || summary.worktree.state !== 'ready') && ( + + )} {summary?.failure && (

{failureMessage}

)} diff --git a/packages/web-shell/client/components/managed/ManagedWorktreePopover.tsx b/packages/web-shell/client/components/managed/ManagedWorktreePopover.tsx new file mode 100644 index 00000000000..b0bb8a2e9a7 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedWorktreePopover.tsx @@ -0,0 +1,159 @@ +import { useRef } from 'react'; +import { CircleDotIcon, GitBranchIcon, GitForkIcon } from 'lucide-react'; +import { useI18n } from '../../i18n'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import styles from '../GitModePopover.module.css'; +import { useManagedWorktreeText } from './managed-worktree-messages'; + +export function ManagedWorktreePopover({ + worktree, + currentBranch, + open, + onOpenChange, + disabled, + onWorktreeChange, + onManage, +}: { + worktree: boolean; + currentBranch?: string | null; + open: boolean; + onOpenChange: (value: boolean) => void; + disabled: boolean; + onWorktreeChange: (value: boolean) => void; + onManage: () => void; +}) { + const { t } = useI18n(); + const text = useManagedWorktreeText(); + const contentRef = useRef(null); + const label = worktree + ? t('gitMode.worktree') + : currentBranch || text('mode'); + + return ( + + + + + + event.stopPropagation()} + onInteractOutside={(event) => { + if (contentRef.current?.contains(event.target as Node)) + event.preventDefault(); + }} + > +
{t('gitMode.title')}
+ {([false, true] as const).map((isolated) => ( + + ))} +
+ +
+
+
+ {worktree && ( + + )} +
+ ); +} diff --git a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx index 2b2c02202a3..0cf852809a7 100644 --- a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx +++ b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.test.tsx @@ -11,6 +11,8 @@ import { JavaManagedAgentHttpError } from './java-managed-agent-client'; import type { ManagedAgentProvider, ManagedSessionOptions, + ManagedSessionWorktree, + ManagedSessionWorktreeOptions, } from './managed-agent-provider'; import { WorkspaceBindingCreator } from './WorkspaceBindingCreator'; import * as pendingStorage from './managed-pending-storage'; @@ -61,6 +63,7 @@ vi.mock('../ChatEditor', async () => { onOpenExistingFolder={props.onOpenExistingWorkspace ?? (() => {})} menuItems={props.workspaceMenuItems} /> + {props.gitContextControl}
); }), @@ -712,8 +715,469 @@ describe('WorkspaceBindingCreator', () => { return createSession; } + const createdWorktree: ManagedSessionWorktree = { + id: 'worktree-a', + sessionId: 'session-a', + workspaceId: 'ws-a', + state: 'preparing', + branch: 'worktree-managed-a', + baseCommit: 'a'.repeat(40), + cwdRelative: '.qwen-managed-session-worktrees/a', + operationId: 'operation-a', + revision: 1, + }; + + function worktreeCreation() { + const createSession = directCreation(); + provider.workspaceBinding!.list = vi.fn().mockResolvedValue({ + data: [workspace], + defaultWorkspace: workspace, + supported: true, + supportsApprovalModeSelection: true, + }); + provider.workspaceBinding!.getSessionOptions = vi.fn().mockResolvedValue({ + approvalMode: 'default', + availableApprovalModes: ['default'], + inputAttachments: true, + }); + const options = vi.fn().mockResolvedValue({ + supported: true, + baseCommit: 'a'.repeat(40), + branches: ['main', 'topic'], + currentBranch: 'main', + }); + provider.sessionWorktrees = { + options, + list: vi.fn().mockResolvedValue({ data: [] }), + inspect: vi.fn(), + cleanup: vi.fn(), + }; + getSession.mockResolvedValue({ + sessionId: 'session-a', + agentId: 'agent-a', + initialApprovalMode: 'default', + workspace: { + workspaceId: 'ws-a', + cwdRelative: createdWorktree.cwdRelative, + }, + worktree: createdWorktree, + }); + return { createSession, options }; + } + + async function selectWorktree() { + await act(async () => { + container + .querySelector( + '[data-testid="managed-worktree-chip"]', + )! + .click(); + }); + const option = [ + ...document.querySelectorAll('[role="radio"]'), + ].find((button) => button.textContent?.startsWith('Worktree')); + expect(option).toBeDefined(); + await act(async () => option!.click()); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + } + + it('puts Managed worktree selection in the shared composer instead of a top-level fieldset', async () => { + vi.useFakeTimers(); + const { options } = worktreeCreation(); + options.mockResolvedValue({ + supported: true, + baseCommit: 'a'.repeat(40), + branches: ['topic', 'main'], + currentBranch: 'main', + }); + await render(); + await act(async () => vi.advanceTimersByTimeAsync(200)); + const editor = container.querySelector('[data-testid="shared-editor"]'); + expect( + editor?.querySelector('[data-testid="managed-worktree-chip"]') + ?.textContent, + ).toContain('main'); + expect( + container.querySelector('input[name="managed-worktree-mode"]'), + ).toBeNull(); + expect(container.querySelector('legend')).toBeNull(); + expect(options).toHaveBeenLastCalledWith( + { workspaceId: 'ws-a', cwdRelative: '.' }, + expect.objectContaining({ clientId: 'client-a' }), + ); + }); + + it('refreshes the actual branch when opening the picker without selecting worktree', async () => { + vi.useFakeTimers(); + const { options } = worktreeCreation(); + await render(); + await act(async () => vi.advanceTimersByTimeAsync(200)); + options.mockResolvedValue({ + supported: true, + branches: ['main'], + currentBranch: 'renamed', + }); + await act(async () => + container + .querySelector( + '[data-testid="managed-worktree-chip"]', + )! + .click(), + ); + await act(async () => vi.advanceTimersByTimeAsync(200)); + expect( + container.querySelector('[data-testid="managed-worktree-chip"]') + ?.textContent, + ).toContain('renamed'); + expect(document.querySelector('[role="radio"]')?.textContent).toContain( + 'renamed', + ); + expect(options.mock.calls.at(-1)?.[0]).not.toHaveProperty('baseRef'); + }); + + it('does not block current-directory use when only worktree preparation is unsupported', async () => { + vi.useFakeTimers(); + const { options } = worktreeCreation(); + options.mockResolvedValue({ + supported: false, + reason: 'worktree_submodule_unsupported', + branches: [], + currentBranch: 'main', + }); + await render(); + await act(async () => vi.advanceTimersByTimeAsync(200)); + expect( + container.querySelector('[data-testid="managed-worktree-chip"]') + ?.textContent, + ).toContain('main'); + expect(editorMocks.props!.disabled).toBe(false); + expect(container.textContent).not.toContain( + 'worktree_submodule_unsupported', + ); + }); + + it('ignores a previous workspace branch response after selecting another workspace', async () => { + vi.useFakeTimers(); + const { options } = worktreeCreation(); + const other = { ...workspace, workspaceId: 'ws-b', displayName: 'B' }; + vi.mocked(provider.workspaceBinding!.list).mockResolvedValue({ + data: [workspace, other], + defaultWorkspace: workspace, + supported: true, + supportsApprovalModeSelection: true, + }); + provider.workspaceBinding!.get = vi + .fn() + .mockImplementation(async (id) => (id === 'ws-b' ? other : workspace)); + let resolveFirst!: (value: ManagedSessionWorktreeOptions) => void; + options.mockImplementation(({ workspaceId }) => + workspaceId === 'ws-a' + ? new Promise((resolve) => { + resolveFirst = resolve; + }) + : Promise.resolve({ + supported: true, + branches: [], + currentBranch: 'feature/b', + }), + ); + await render(); + await act(async () => vi.advanceTimersByTimeAsync(200)); + await act(async () => { + editorMocks.props!.onSelectWorkspace!('ws-b'); + await flush(); + }); + await act(async () => vi.advanceTimersByTimeAsync(200)); + await act(async () => { + resolveFirst({ + supported: true, + branches: ['main'], + currentBranch: 'main', + }); + await flush(); + }); + const label = container.querySelector( + '[data-testid="managed-worktree-chip"]', + )?.textContent; + expect(label).toContain('feature/b'); + expect(label).not.toContain('main'); + }); + + it('creates a worktree from HEAD and reads back the allocated target without accessing source files', async () => { + vi.useFakeTimers(); + const { createSession, options } = worktreeCreation(); + provider.workspaceFiles = { query: vi.fn(), read: vi.fn() }; + await render(); + vi.mocked(provider.workspaceFiles.query).mockClear(); + await selectWorktree(); + expect(options).toHaveBeenLastCalledWith( + { workspaceId: 'ws-a', cwdRelative: '.' }, + expect.objectContaining({ clientId: 'client-a' }), + ); + expect(editorMocks.props!.dialogOpen).toBe(false); + expect(editorMocks.props!.disabled).toBe(false); + expect(document.querySelector('[data-slot="popover-content"]')).toBeNull(); + expect(document.getElementById('managed-worktree-base')).toBeNull(); + expect(createSession).not.toHaveBeenCalled(); + expect(createEmpty).not.toHaveBeenCalled(); + expect(editorMocks.props!.fileReferenceActions).toBeUndefined(); + expect(editorMocks.props!.fileUploadTarget).toBeUndefined(); + expect(editorMocks.props!.attachmentsEnabled).toBe(true); + await sendInitial('Work independently'); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'ws-a', + cwdRelative: '.', + worktree: {}, + }), + expect.anything(), + ); + expect(provider.workspaceFiles.query).not.toHaveBeenCalled(); + expect(onCreated).toHaveBeenCalledWith('session-a', 'ws-a'); + }); + + it('keeps ordinary message attachments available before worktree preparation', async () => { + vi.useFakeTimers(); + const { createSession } = worktreeCreation(); + await render(); + await selectWorktree(); + await act(async () => { + editorMocks.props!.onSubmit( + 'Read the attachment', + undefined, + [ + { + name: 'notes.txt', + media_type: 'text/plain', + text: 'Message context', + }, + ], + vi.fn(), + ); + await flush(); + }); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktree: {}, + input: expect.arrayContaining([ + { + type: 'input_file', + name: 'notes.txt', + mime_type: 'text/plain', + text: 'Message context', + }, + ]), + }), + expect.anything(), + ); + }); + + it('clears worktree isolation back to the current branch without creating a session', async () => { + vi.useFakeTimers(); + const { createSession } = worktreeCreation(); + await render(); + await selectWorktree(); + expect( + container.querySelector('[data-testid="managed-worktree-chip"]') + ?.textContent, + ).toContain('Worktree'); + await act(async () => + container + .querySelector( + '[data-testid="managed-worktree-clear"]', + )! + .click(), + ); + await act(async () => vi.advanceTimersByTimeAsync(200)); + expect( + container.querySelector('[data-testid="managed-worktree-chip"]') + ?.textContent, + ).toContain('main'); + expect( + container.querySelector('[data-testid="managed-worktree-clear"]'), + ).toBeNull(); + expect(createSession).not.toHaveBeenCalled(); + getSession.mockResolvedValue({ + sessionId: 'session-a', + agentId: 'agent-a', + initialApprovalMode: 'default', + workspace: { workspaceId: 'ws-a', cwdRelative: '.' }, + }); + await sendInitial('Use the current branch'); + expect(createSession.mock.calls[0]![0]).not.toHaveProperty('worktree'); + expect(onCreated).toHaveBeenCalledWith('session-a', 'ws-a'); + }); + + it('keeps the simple picker and retained-worktree manager without creation fields', async () => { + vi.useFakeTimers(); + const { createSession } = worktreeCreation(); + await render(); + await selectWorktree(); + await act(async () => + container + .querySelector( + '[data-testid="managed-worktree-chip"]', + )! + .click(), + ); + const popup = document.querySelector('[data-slot="popover-content"]')!; + expect(popup.querySelectorAll('[role="radio"]')).toHaveLength(2); + expect(popup.textContent).toContain( + 'Creates an isolated copy when you send your first message', + ); + expect(popup.textContent).toContain('Develop directly on main'); + expect(popup.querySelector('input, fieldset, datalist')).toBeNull(); + expect(popup.textContent).not.toContain('Base commit'); + expect(editorMocks.props!.dialogOpen).toBe(true); + expect(editorMocks.props!.disabled).toBe(true); + await click('Session worktrees'); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'No session worktrees', + ); + expect(provider.sessionWorktrees!.list).toHaveBeenCalledWith( + 'ws-a', + expect.anything(), + ); + expect(createSession).not.toHaveBeenCalled(); + }); + + it('freezes the HEAD worktree request and idempotency key across a restored creation retry', async () => { + vi.useFakeTimers(); + const { createSession } = worktreeCreation(); + createSession.mockRejectedValueOnce(new TypeError('Lost response')); + await render(); + await selectWorktree(); + await sendInitial('Frozen worktree input'); + const first = createSession.mock.calls[0]!; + expect(first[0].worktree).toEqual({}); + await act(async () => root.unmount()); + root = createRoot(container); + await render(); + const chip = container.querySelector( + '[data-testid="managed-worktree-chip"]', + )!; + expect(chip.textContent).toContain('Worktree'); + expect(chip.disabled).toBe(true); + expect( + container.querySelector( + '[data-testid="managed-worktree-clear"]', + )!.disabled, + ).toBe(true); + await click('Retry the same request'); + expect(createSession.mock.calls[1]![0]).toEqual(first[0]); + expect(createSession.mock.calls[1]![1].idempotencyKey).toBe( + first[1].idempotencyKey, + ); + expect(onCreated).toHaveBeenCalledWith('session-a', 'ws-a'); + }); + + it('replays a legacy pending explicit base ref unchanged after simplifying the picker', async () => { + const { createSession } = worktreeCreation(); + sessionStorage.setItem( + `qwen-managed-workspace-create:${provider.storageKey}`, + JSON.stringify({ + agentId: 'agent-a', + workspaceId: 'ws-a', + cwdRelative: '.', + worktree: { baseRef: 'topic' }, + input: [{ type: 'input_text', text: 'Original input' }], + clientId: 'client-a', + idempotencyKey: 'legacy-worktree-key', + }), + ); + await render(); + expect( + container.querySelector( + '[data-testid="managed-worktree-clear"]', + )!.disabled, + ).toBe(true); + expect(createSession).not.toHaveBeenCalled(); + await click('Retry the same request'); + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ + worktree: { baseRef: 'topic' }, + text: 'Original input', + }), + expect.objectContaining({ idempotencyKey: 'legacy-worktree-key' }), + ); + expect(onCreated).toHaveBeenCalledWith('session-a', 'ws-a'); + }); + + it('keeps unsupported worktree creation blocked and does not silently use the current directory', async () => { + vi.useFakeTimers(); + const { createSession, options } = worktreeCreation(); + options.mockResolvedValue({ + supported: false, + reason: 'worktree_layout_unsupported', + branches: [], + }); + await render(); + await selectWorktree(); + expect(container.textContent).toContain('worktree_layout_unsupported'); + expect(editorMocks.props!.disabled).toBe(true); + await sendInitial('Do not fall back'); + expect(createSession).not.toHaveBeenCalled(); + expect(createEmpty).not.toHaveBeenCalled(); + const chip = container.querySelector( + '[data-testid="managed-worktree-chip"]', + )!; + expect(chip.disabled).toBe(false); + expect(document.querySelector('[data-slot="popover-content"]')).toBeNull(); + expect(editorMocks.props!.dialogOpen).toBe(false); + expect(container.textContent).toContain('worktree_layout_unsupported'); + await act(async () => chip.click()); + const current = [ + ...document.querySelectorAll('[role="radio"]'), + ].find((button) => button.textContent?.startsWith('Current directory')); + await act(async () => current!.click()); + expect(editorMocks.props!.disabled).toBe(false); + expect(container.textContent).not.toContain('worktree_layout_unsupported'); + }); + + it('rejects source workspace file annotations after switching to an independent worktree', async () => { + vi.useFakeTimers(); + const { createSession } = worktreeCreation(); + await render(); + await selectWorktree(); + await act(async () => { + editorMocks.props!.onSubmit( + 'Read @source.ts', + undefined, + undefined, + vi.fn(), + { + inputAnnotations: [ + { + type: 'reference', + start: 5, + end: 15, + text: '@source.ts', + reference: { + id: 'source', + kind: 'file', + label: 'source.ts', + metadata: { + managedFile: { version: 1, source: 'previous-workspace' }, + }, + }, + }, + ], + }, + ); + await flush(); + }); + expect(createSession).not.toHaveBeenCalled(); + expect(container.textContent).toContain('select the file again'); + }); + async function sendInitial(text: string) { const commit = vi.fn(); + const worktreeChip = container.querySelector( + '[data-testid="managed-worktree-chip"][aria-expanded="true"]', + ); + if (worktreeChip) await act(async () => worktreeChip.click()); await act(async () => { expect( editorMocks.props!.onSubmit(text, undefined, undefined, commit), @@ -2345,7 +2809,7 @@ describe('WorkspaceBindingCreator', () => { await openWorkspaceCreation(); await nameWorkspace('Created'); const write = vi - .spyOn(Storage.prototype, 'setItem') + .spyOn(Object.getPrototypeOf(sessionStorage) as Storage, 'setItem') .mockImplementation(() => { throw new Error('unavailable'); }); diff --git a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx index ed073ddf5ce..edf69fd5f6b 100644 --- a/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx +++ b/packages/web-shell/client/components/managed/WorkspaceBindingCreator.tsx @@ -9,6 +9,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import shellStyles from '../../App.module.css'; import { useI18n } from '../../i18n'; import { useManagedRecoveryText } from './managed-recovery-messages'; +import { + useManagedWorktreeReason, + useManagedWorktreeText, +} from './managed-worktree-messages'; +import { ManagedSessionWorktrees } from './ManagedSessionWorktrees'; +import { ManagedWorktreePopover } from './ManagedWorktreePopover'; import { ChatEditor } from '../ChatEditor'; import type { WorkspaceSelectorOption } from '../WorkspaceSelector'; import { HostLabel } from '../workspaces/HostLabel'; @@ -56,6 +62,7 @@ import type { ManagedModelPreset, ManagedInputBlock, ManagedSessionSkill, + ManagedSessionWorktreeOptions, } from './managed-agent-provider'; import type { PromptImage, PromptFile } from '../../adapters/promptTypes'; import { @@ -90,6 +97,7 @@ interface PendingCreation { planMode?: boolean; skills?: ManagedSessionSkill[]; extensionPins?: ManagedExtensionPin[]; + worktree?: { baseRef?: string }; } interface PendingWorkspace { @@ -153,6 +161,7 @@ function readPending( if (typeof value !== 'object' || !value) return undefined; const item = value as Record; const input = item['input']; + const worktree = item['worktree']; if ( typeof item['agentId'] !== 'string' || (item['modelId'] !== undefined && @@ -169,6 +178,14 @@ function readPending( !isManagedSessionSkills(item['skills'])) || (item['extensionPins'] !== undefined && !isManagedExtensionPins(item['extensionPins'])) || + (worktree !== undefined && + (typeof worktree !== 'object' || + worktree === null || + Array.isArray(worktree) || + Object.keys(worktree).some((key) => key !== 'baseRef') || + ('baseRef' in worktree && + (typeof worktree.baseRef !== 'string' || + !worktree.baseRef.trim())))) || !isManagedInput(input) || (item['sessionId'] !== undefined && typeof item['sessionId'] !== 'string') ) @@ -229,6 +246,8 @@ export function WorkspaceBindingCreator({ }) { const { t } = useI18n(); const recoveryText = useManagedRecoveryText(); + const worktreeText = useManagedWorktreeText(); + const worktreeReason = useManagedWorktreeReason(); const workspaceId = explicitWorkspaceId ?? handoff?.workspaceId; const binding = provider.workspaceBinding!; const pendingKey = `qwen-managed-workspace-create:${provider.storageKey}`; @@ -314,6 +333,30 @@ export function WorkspaceBindingCreator({ selectingWorkspace || Boolean(workspaceId && handledWorkspaceRequest.current !== workspaceId); const [cwdRelative, setCwdRelative] = useState(handoff?.cwdRelative ?? '.'); + const [worktreeMode, setWorktreeMode] = useState(Boolean(pending?.worktree)); + const [worktreesOpen, setWorktreesOpen] = useState(false); + const [worktreePopoverOpen, setWorktreePopoverOpen] = useState(false); + const [worktreeOptions, setWorktreeOptions] = useState<{ + source: string; + value?: ManagedSessionWorktreeOptions; + error?: string; + }>(); + const [worktreeOptionsRevision, setWorktreeOptionsRevision] = useState(0); + const branchScope = JSON.stringify([selected, cwdRelative]); + const [branchObservation, setBranchObservation] = useState<{ + scope: string; + branch: string | null; + }>(); + const currentBranch = + branchObservation?.scope === branchScope ? branchObservation.branch : null; + const worktreeSource = JSON.stringify([selected, cwdRelative, worktreeMode]); + const worktreeRequested = pending ? Boolean(pending.worktree) : worktreeMode; + const worktreeReady = + !worktreeRequested || + Boolean( + worktreeOptions?.source === worktreeSource && + worktreeOptions.value?.supported, + ); const [cwdDraft, setCwdDraft] = useState(); const cwdEdited = useRef(Boolean(handoff?.cwdRelative)); const modelPresets = binding.modelPresets ?? []; @@ -382,6 +425,9 @@ export function WorkspaceBindingCreator({ const saved = readPending(pendingKey, value); pendingRef.current = saved; setPending(saved); + if (saved?.worktree) { + setWorktreeMode(true); + } setBusy(false); }, () => { @@ -391,6 +437,56 @@ export function WorkspaceBindingCreator({ return () => abort.abort(); }, [pendingKey]); + useEffect(() => { + if (!selected || pending || !provider.sessionWorktrees) return; + const abort = new AbortController(); + setWorktreeOptions(undefined); + const timer = setTimeout(() => { + void provider + .sessionWorktrees!.options( + { + workspaceId: selected, + cwdRelative, + }, + { clientId, signal: abort.signal }, + ) + .then( + (value) => { + if (!abort.signal.aborted) { + setWorktreeOptions({ source: worktreeSource, value }); + setBranchObservation({ + scope: branchScope, + branch: value.currentBranch ?? null, + }); + } + }, + (failure: unknown) => { + if (!abort.signal.aborted) { + setWorktreeOptions({ + source: worktreeSource, + error: message(failure), + }); + setBranchObservation({ scope: branchScope, branch: null }); + } + }, + ); + }, 200); + return () => { + abort.abort(); + clearTimeout(timer); + }; + }, [ + provider.sessionWorktrees, + clientId, + selected, + cwdRelative, + branchScope, + worktreeSource, + worktreeMode, + pending, + worktreeOptionsRevision, + ]); + useEffect(() => { try { sessionStorage.setItem(selectionKey, selected); @@ -592,7 +688,7 @@ export function WorkspaceBindingCreator({ const fileReferences = useManagedFileReferences( provider, clientId, - currentOptions?.inputAttachments + currentOptions?.inputAttachments && !worktreeRequested ? { workspaceId: selected, cwdRelative } : undefined, ); @@ -666,6 +762,8 @@ export function WorkspaceBindingCreator({ currentOptions?.modelSelectionSupported !== true ) return; + if (attempt?.worktree && !attempt.sessionId && !provider.sessionWorktrees) + return; const firstSubmission = !attempt; if (!attempt) { if ( @@ -683,7 +781,7 @@ export function WorkspaceBindingCreator({ !files?.length ) return; - if (!approvalReady) return; + if (!approvalReady || !worktreeReady) return; if ( !supported || !selectedPreset || @@ -737,6 +835,7 @@ export function WorkspaceBindingCreator({ ...(modelId === undefined ? {} : { modelId }), workspaceId: selected, cwdRelative, + ...(worktreeRequested ? { worktree: {} } : {}), input, clientId, idempotencyKey: managedRequestId(), @@ -777,6 +876,7 @@ export function WorkspaceBindingCreator({ : { modelId: attempt.modelId }), workspaceId: attempt.workspaceId, cwdRelative: attempt.cwdRelative, + ...(attempt.worktree ? { worktree: attempt.worktree } : {}), ...(attempt.skills?.length ? { skills: attempt.skills } : {}), ...(attempt.extensionPins === undefined ? {} @@ -837,7 +937,12 @@ export function WorkspaceBindingCreator({ !session.workspace || session.workspace.workspaceId !== attempt.workspaceId || typeof session.workspace.cwdRelative !== 'string' || - !session.workspace.cwdRelative + !session.workspace.cwdRelative || + (attempt.worktree && + (!session.worktree || + session.worktree.sessionId !== createdSessionId || + session.worktree.workspaceId !== attempt.workspaceId || + session.worktree.cwdRelative !== session.workspace.cwdRelative)) ) { throw new Error( t('managed.workspaceProtocolError', { @@ -1154,6 +1259,45 @@ export function WorkspaceBindingCreator({ [binding, clientId, directoryPicker, t], ); + const worktreeControl = provider.sessionWorktrees && + selected && + interactive && ( + { + setWorktreePopoverOpen(open); + if (open) setWorktreeOptionsRevision((value) => value + 1); + }} + disabled={busy || workspaceCreating || Boolean(pending) || loading} + onWorktreeChange={setWorktreeMode} + onManage={() => setWorktreesOpen(true)} + /> + ); + const worktreeStatus = + worktreeRequested && + !pending && + (worktreeOptions?.source !== worktreeSource ? ( +

+ {worktreeText('checking')} +

+ ) : worktreeOptions.error || !worktreeOptions.value?.supported ? ( +
+

+ {worktreeOptions.error ?? + worktreeReason(worktreeOptions.value?.reason)} +

+ +
+ ) : null); const sessionCreator = (
)} + {!directInput && worktreeControl} + {!directInput && worktreeStatus} {extensionCatalog?.supported && extensionCatalog.extensions.length > 0 && (
+ {worktreeStatus}
)} @@ -1840,6 +1994,15 @@ export function WorkspaceBindingCreator({ return ( <> {!creationOnly && sessionCreator} + {provider.sessionWorktrees && selected && worktreesOpen && ( + + )} {interactive && !creationOnly && cwdDraft !== undefined && ( ; export interface components { @@ -1190,6 +1258,10 @@ export interface components { skills?: components["schemas"]["SessionCreationSkill"][]; /** @description Optional immutable initial extension selection. Omission keeps the legacy configuration path; an explicit empty array starts the unified configuration format. Included in creation idempotency and applied before the first model request. Requires session-extension-packages/1 and an extension-capable original Runtime. */ extensionPins?: components["schemas"]["ManagedExtensionPackagePin"][]; + /** @description Opt into a main Session worktree on supported LOCAL storage. Omission uses the current directory; an empty object freezes HEAD at first admission. Requires an explicit Workspace. Preparation is durable and gates Runtime execution; replay preserves the original commit, directory and branch. */ + worktree?: { + baseRef?: string; + }; }; WebShellSubmitRequest: { /** @description Trace correlation only. */ @@ -1292,6 +1364,7 @@ export interface components { /** @description Current unsettled lifecycle operation, queried in batch under Session read authorization. A failed close/delete may leave the Session closing/deleting; only explicit retry starts a new operation. Settled Sessions omit this projection. */ lifecycleOperation?: components["schemas"]["WebShellLifecycleOperation"]; recovery?: components["schemas"]["SessionRecovery"]; + worktree?: components["schemas"]["WebShellSessionWorktree"] | null; }; WebShellSessionCapabilities: { /** @@ -2098,18 +2171,22 @@ export interface components { skills: components["schemas"]["WebShellSkill"][]; }; WebShellWorkspaceFileQueryRequest: { - workspaceId: string; - cwdRelative: string; + workspaceId?: string; + cwdRelative?: string; directory: string; query?: string; expectedScopeRevision?: string; - }; + /** Format: uuid */ + sessionId?: string; + } & (unknown | unknown); WebShellWorkspaceFileReadRequest: { - workspaceId: string; - cwdRelative: string; + workspaceId?: string; + cwdRelative?: string; path: string; expectedScopeRevision: string; - }; + /** Format: uuid */ + sessionId?: string; + } & (unknown | unknown); WebShellWorkspaceFileEntry: { path: string; name: string; @@ -2524,6 +2601,52 @@ export interface components { /** Format: uuid */ uploadId: string; }; + WebShellSessionWorktree: { + id: string; + sessionId: string; + workspaceId: string; + branch: string; + baseCommit: string; + cwdRelative: string; + operationId: string; + /** @enum {string} */ + state: "preparing" | "ready" | "failed" | "blocked" | "removing" | "removed"; + revision: number; + errorCode?: string | null; + }; + WebShellSessionWorktreeOptions: { + supported: boolean; + reason?: string | null; + baseCommit?: string | null; + /** @description Source checkout branch independent of base ref; HEAD when detached, null when unavailable. */ + currentBranch?: string | null; + branches: string[]; + }; + WebShellSessionWorktreePage: { + data: components["schemas"]["WebShellSessionWorktree"][]; + }; + WebShellSessionWorktreeInspection: { + worktree: components["schemas"]["WebShellSessionWorktree"]; + canCleanup: boolean; + cleanupBlocker?: string | null; + }; + WebShellSessionWorktreeOptionsRequest: { + workspaceId: string; + cwdRelative: string; + baseRef?: string; + }; + WebShellSessionWorktreeQueryRequest: { + workspaceId: string; + }; + WebShellSessionWorktreeInspectRequest: { + workspaceId: string; + worktreeId: string; + }; + WebShellSessionWorktreeCleanupRequest: { + workspaceId: string; + worktreeId: string; + idempotencyKey: string; + }; }; responses: { /** @description The authenticated actor belongs to another tenant than X-Qwen-Tenant-Id or has an invalid ID (actor_scope_mismatch, which the tenant filter answers on every /v1/agents/ and WebShell route), or the resource is readable but the actor lacks this operation or the original Action responder permission. Artifact content reads may also return artifact_content_forbidden when the actor cannot read the original bytes. */ @@ -4645,11 +4768,13 @@ export interface operations { webShellWorkspaceFilesUpload: { parameters: { query: { - workspaceId: string; - cwdRelative: string; + workspaceId?: string; + cwdRelative?: string; path: string; expectedScopeRevision: string; uploadId: string; + /** @description Exactly one locator: Session ID, or Workspace ID plus cwdRelative. */ + sessionId?: string; }; header?: never; path?: never; @@ -4726,4 +4851,120 @@ export interface operations { 503: components["responses"]["Unavailable"]; }; }; + webShellSessionWorktreeOptions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellSessionWorktreeOptionsRequest"]; + }; + }; + responses: { + /** @description Session worktree result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebShellSessionWorktreeOptions"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + }; + }; + webShellSessionWorktreeQuery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellSessionWorktreeQueryRequest"]; + }; + }; + responses: { + /** @description Session worktree result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebShellSessionWorktreePage"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + }; + }; + webShellSessionWorktreeInspect: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellSessionWorktreeInspectRequest"]; + }; + }; + responses: { + /** @description Session worktree result. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebShellSessionWorktreeInspection"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + }; + }; + webShellSessionWorktreeCleanup: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WebShellSessionWorktreeCleanupRequest"]; + }; + }; + responses: { + /** @description Session worktree result. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebShellSessionWorktree"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + }; + }; } diff --git a/packages/web-shell/client/components/managed/java-managed-agent-client.test.ts b/packages/web-shell/client/components/managed/java-managed-agent-client.test.ts index 17020956a97..e2e23543c44 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-client.test.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-client.test.ts @@ -23,6 +23,53 @@ afterEach(() => { }); describe('JavaManagedAgentClient', () => { + it('sends Session file locators without a caller-selected Workspace or cwd', async () => { + const fetchImpl = vi + .fn() + .mockImplementation(async () => jsonResponse({})); + const client = new JavaManagedAgentClient({ + baseUrl: 'https://product.example', + fetch: fetchImpl, + }); + await client.queryWorkspaceFiles({ + sessionId: 'session-a', + directory: '.', + }); + await client.readWorkspaceFile({ + sessionId: 'session-a', + path: 'file.txt', + expectedScopeRevision: 'scope', + }); + await client.uploadWorkspaceFile( + { + sessionId: 'session-a', + path: 'file.txt', + expectedScopeRevision: 'scope', + uploadId: 'upload-id', + }, + new Blob(['text']), + ); + expect(JSON.parse(String(fetchImpl.mock.calls[0][1]?.body))).toEqual({ + sessionId: 'session-a', + directory: '.', + }); + expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toEqual({ + sessionId: 'session-a', + path: 'file.txt', + expectedScopeRevision: 'scope', + }); + expect( + Object.fromEntries( + new URL(String(fetchImpl.mock.calls[2][0])).searchParams, + ), + ).toEqual({ + sessionId: 'session-a', + path: 'file.txt', + expectedScopeRevision: 'scope', + uploadId: 'upload-id', + }); + }); + it('uploads raw bytes to the authenticated Workspace route and queries the same upload ID', async () => { const result = { scopeRevision: 'scope', diff --git a/packages/web-shell/client/components/managed/java-managed-agent-client.ts b/packages/web-shell/client/components/managed/java-managed-agent-client.ts index 1551577c59d..4153491784c 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-client.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-client.ts @@ -9,6 +9,11 @@ import type { } from './managed-extension-types'; import type { components } from './generated/managed-agent-api'; import type { ManagedMcpManagementSnapshot } from './managed-mcp-management-types'; +import type { + ManagedWorkspaceFileQueryRequest, + ManagedWorkspaceFileReadRequest, + ManagedWorkspaceFileUploadRequest, +} from './managed-agent-provider'; import type { ManagedArtifact, ManagedArtifactPage, @@ -233,31 +238,28 @@ export class JavaManagedAgentClient { } queryWorkspaceFiles( - request: Schemas['WebShellWorkspaceFileQueryRequest'], + request: ManagedWorkspaceFileQueryRequest, signal?: AbortSignal, ): Promise { return this.post('/workspaces/files/query', request, signal); } readWorkspaceFile( - request: Schemas['WebShellWorkspaceFileReadRequest'], + request: ManagedWorkspaceFileReadRequest, signal?: AbortSignal, ): Promise { return this.post('/workspaces/files/read', request, signal); } async uploadWorkspaceFile( - request: { - workspaceId: string; - cwdRelative: string; - path: string; - expectedScopeRevision: string; - uploadId: string; - }, + request: ManagedWorkspaceFileUploadRequest, data: Blob, signal?: AbortSignal, ): Promise { - const query = new URLSearchParams(request); + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(request)) { + if (value !== undefined) query.set(key, value); + } const response = await this.fetchRequest( `/workspaces/files/upload?${query}`, data, @@ -347,6 +349,39 @@ export class JavaManagedAgentClient { return this.post('/sessions/create', request, signal); } + worktreeOptions( + request: Schemas['WebShellSessionWorktreeOptionsRequest'], + signal?: AbortSignal, + ): Promise { + return this.post('/workspaces/worktrees/options', request, signal); + } + + listSessionWorktrees( + workspaceId: string, + signal?: AbortSignal, + ): Promise { + return this.post('/workspaces/worktrees/query', { workspaceId }, signal); + } + + inspectSessionWorktree( + workspaceId: string, + worktreeId: string, + signal?: AbortSignal, + ): Promise { + return this.post( + '/workspaces/worktrees/inspect', + { workspaceId, worktreeId }, + signal, + ); + } + + cleanupSessionWorktree( + request: Schemas['WebShellSessionWorktreeCleanupRequest'], + signal?: AbortSignal, + ): Promise { + return this.post('/workspaces/worktrees/cleanup', request, signal); + } + submitTurn( request: Schemas['WebShellSubmitRequest'], signal?: AbortSignal, diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts index e4ba386af9b..a76b1ed988d 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.test.ts @@ -1043,7 +1043,7 @@ describe('createJavaManagedAgentProvider', () => { ).toThrow('productScope'); }); - it('sends a catalog model with the built-in Agent on both creation paths', async () => { + it('preserves model, worktree and extension pins on both creation paths', async () => { const fetchImpl = vi .fn() .mockImplementation(async () => @@ -1061,6 +1061,14 @@ describe('createJavaManagedAgentProvider', () => { workspaceId: 'ws', cwdRelative: '.', approvalMode: 'yolo' as const, + worktree: { baseRef: 'main' }, + extensionPins: [ + { + extensionId: 'ext_00000000-0000-0000-0000-000000000001', + packageRevision: 1, + packageDigest: 'a'.repeat(64), + }, + ], }; const command = { clientId: 'client', idempotencyKey: 'frozen-key' }; await provider.workspaceBinding!.createEmpty(request, command); @@ -1074,6 +1082,8 @@ describe('createJavaManagedAgentProvider', () => { agentId: 'qwen-code', modelId: 'model-b', approvalMode: 'yolo', + worktree: request.worktree, + extensionPins: request.extensionPins, idempotencyKey: 'frozen-key', }), ); diff --git a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts index 2853d9dd173..76f8c99b7a5 100644 --- a/packages/web-shell/client/components/managed/java-managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/java-managed-agent-provider.ts @@ -115,6 +115,7 @@ export function createJavaManagedAgentProvider( planMode?: boolean; skills?: import('./managed-agent-provider').ManagedSessionSkill[]; extensionPins?: import('./managed-extension-types').ManagedExtensionPin[]; + worktree?: { baseRef?: string }; }, command: ManagedAgentCommandOptions, ) { @@ -126,6 +127,9 @@ export function createJavaManagedAgentProvider( requestId: managedRequestId(), idempotencyKey: command.idempotencyKey, agentId: request.agentId, + ...(request.worktree === undefined + ? {} + : { worktree: request.worktree }), ...(request.modelId === undefined ? {} : { modelId: request.modelId }), ...(request.skills?.length ? { skills: request.skills } : {}), ...(request.extensionPins === undefined @@ -802,6 +806,8 @@ export function createJavaManagedAgentProvider( approvalMode, planMode, skills, + extensionPins, + worktree, }, command, ) => @@ -814,11 +820,34 @@ export function createJavaManagedAgentProvider( approvalMode, planMode, skills, + extensionPins, + worktree, }, command, ), createSession: createWorkspaceSession, }, + sessionWorktrees: { + options: (request, options) => + client.worktreeOptions(request, options.signal), + list: (workspaceId, options) => + client.listSessionWorktrees(workspaceId, options.signal), + inspect: (workspaceId, worktreeId, options) => + client.inspectSessionWorktree( + workspaceId, + worktreeId, + options.signal, + ), + cleanup: (workspaceId, worktreeId, options) => + client.cleanupSessionWorktree( + { + workspaceId, + worktreeId, + idempotencyKey: options.idempotencyKey, + }, + options.signal, + ), + }, } : {}), async listSessions(request) { @@ -1004,6 +1033,7 @@ function toSessionSummary( ...(session.recovery ? { recovery: session.recovery } : {}), title: session.title || session.sessionId, workspace: session.workspace, + ...(session.worktree ? { worktree: session.worktree } : {}), createdAt: session.createdAt, admittedAt: session.activeTurn?.submittedAt ?? session.createdAt, updatedAt: session.updatedAt, diff --git a/packages/web-shell/client/components/managed/managed-agent-provider.ts b/packages/web-shell/client/components/managed/managed-agent-provider.ts index f7c735d8920..5ea1b9445a6 100644 --- a/packages/web-shell/client/components/managed/managed-agent-provider.ts +++ b/packages/web-shell/client/components/managed/managed-agent-provider.ts @@ -49,6 +49,27 @@ export type ManagedInputBlock = components['schemas']['InputBlock']; export type ManagedSessionSkills = components['schemas']['WebShellSessionSkills']; export type ManagedSessionMcp = components['schemas']['PublicMcpCatalog']; +export type ManagedWorkspaceFileLocator = + | { sessionId: string; workspaceId?: never; cwdRelative?: never } + | { sessionId?: never; workspaceId: string; cwdRelative: string }; + +export type ManagedWorkspaceFileQueryRequest = ManagedWorkspaceFileLocator & + Omit< + components['schemas']['WebShellWorkspaceFileQueryRequest'], + 'sessionId' | 'workspaceId' | 'cwdRelative' + >; + +export type ManagedWorkspaceFileReadRequest = ManagedWorkspaceFileLocator & + Omit< + components['schemas']['WebShellWorkspaceFileReadRequest'], + 'sessionId' | 'workspaceId' | 'cwdRelative' + >; + +export type ManagedWorkspaceFileUploadRequest = ManagedWorkspaceFileLocator & { + path: string; + expectedScopeRevision: string; + uploadId: string; +}; export function isManagedApprovalMode( value: unknown, @@ -150,9 +171,15 @@ export type ManagedLifecycleOperation = export type ManagedExportPage = components['schemas']['WebShellExportPage']; export type ManagedExportItem = components['schemas']['WebShellItem']; +export type ManagedSessionWorktree = + components['schemas']['WebShellSessionWorktree']; +export type ManagedSessionWorktreeOptions = + components['schemas']['WebShellSessionWorktreeOptions']; + export interface ManagedAgentSessionSummary { sessionId: string; agentId?: string; + worktree?: ManagedSessionWorktree | null; approvalMode?: ManagedApprovalMode; initialApprovalMode?: ManagedApprovalMode; approvalRevision?: number; @@ -541,21 +568,15 @@ export interface ManagedAgentProvider { }; readonly workspaceFiles?: { query( - request: components['schemas']['WebShellWorkspaceFileQueryRequest'], + request: ManagedWorkspaceFileQueryRequest, options: ManagedAgentRequestOptions, ): Promise; read( - request: components['schemas']['WebShellWorkspaceFileReadRequest'], + request: ManagedWorkspaceFileReadRequest, options: ManagedAgentRequestOptions, ): Promise; upload?( - request: { - workspaceId: string; - cwdRelative: string; - path: string; - expectedScopeRevision: string; - uploadId: string; - }, + request: ManagedWorkspaceFileUploadRequest, data: Blob, options: ManagedAgentRequestOptions, ): Promise; @@ -614,6 +635,7 @@ export interface ManagedAgentProvider { planMode?: boolean; skills?: ManagedSessionSkill[]; extensionPins?: ManagedExtensionPin[]; + worktree?: { baseRef?: string }; }, options: ManagedAgentCommandOptions, ): Promise<{ sessionId: string }>; @@ -629,10 +651,31 @@ export interface ManagedAgentProvider { planMode?: boolean; skills?: ManagedSessionSkill[]; extensionPins?: ManagedExtensionPin[]; + worktree?: { baseRef?: string }; }, options: ManagedAgentCommandOptions, ): Promise<{ sessionId: string }>; }; + readonly sessionWorktrees?: { + options( + request: { workspaceId: string; cwdRelative: string; baseRef?: string }, + options: ManagedAgentRequestOptions, + ): Promise; + list( + workspaceId: string, + options: ManagedAgentRequestOptions, + ): Promise<{ data: ManagedSessionWorktree[] }>; + inspect( + workspaceId: string, + worktreeId: string, + options: ManagedAgentRequestOptions, + ): Promise; + cleanup( + workspaceId: string, + worktreeId: string, + options: ManagedAgentCommandOptions, + ): Promise; + }; listSessions( options: ManagedAgentRequestOptions & { archiveState?: 'unarchived' | 'archived' | 'all'; diff --git a/packages/web-shell/client/components/managed/managed-worktree-messages.ts b/packages/web-shell/client/components/managed/managed-worktree-messages.ts new file mode 100644 index 00000000000..657e1f15b62 --- /dev/null +++ b/packages/web-shell/client/components/managed/managed-worktree-messages.ts @@ -0,0 +1,122 @@ +import { useI18n } from '../../i18n'; + +const EN = { + mode: 'Session files', + current: 'Current directory', + isolated: 'Independent worktree', + base: 'Base branch or commit', + head: 'Current HEAD', + committed: + 'Starts from the committed version. Uncommitted changes stay in the current directory.', + checking: 'Checking worktree support…', + files: + 'Select workspace files after the worktree is ready. Existing file references must be selected again.', + manage: 'Session worktrees', + retained: + 'Closing, archiving or deleting a session keeps its worktree. Cleanup removes the directory and keeps the branch.', + empty: 'No session worktrees in this workspace.', + inspect: 'Inspect cleanup', + cleanup: 'Remove worktree directory', + confirm: 'Remove this directory?', + confirmDescription: + 'The session must be closed and the worktree clean. The directory will be removed; its Git branch will be retained.', + blocked: 'Cleanup is unavailable', + session: 'Session', + branch: 'Branch', + baseCommit: 'Base commit', + preparing: 'Preparing worktree', + ready: 'Worktree ready', + failed: 'Worktree preparation failed', + blockedState: 'Worktree needs attention', + removing: 'Removing worktree directory', + removed: 'Directory removed; branch retained', + reasonUnavailable: 'This worktree operation is currently unavailable.', + workspaceBusy: 'Waiting for the workspace to become idle.', + unsupported: + 'This deployment or runtime environment does not support session worktrees.', + notGit: 'The selected directory is not in a Git repository.', + layoutUnsupported: + 'The repository and its Git metadata must be in the same authorized storage. Select the original checkout.', + invalidRef: 'The selected base branch or commit is invalid.', + dirty: + 'The worktree contains uncommitted changes, untracked files or ignored files. Preserve or remove them before cleanup.', + identityConflict: + 'The original worktree directory is missing or has changed.', + stopUnverified: 'The previous Git operation has not been confirmed stopped.', + sessionActive: 'Close the session before removing its worktree.', + backgroundBusy: 'Wait for background tasks to finish before cleanup.', +} as const; + +const ZH: Record = { + mode: '会话文件', + current: '当前目录', + isolated: '独立 worktree', + base: '起始分支或提交', + head: '当前 HEAD', + committed: '从已提交版本开始,未提交修改保留在当前目录。', + checking: '正在检查 worktree 支持情况…', + files: 'worktree 就绪后再选择工作区文件,已有文件引用需要重新选择。', + manage: '会话 worktree', + retained: '关闭、归档或删除会话后会保留 worktree。清理只删除目录,保留分支。', + empty: '此工作区没有会话 worktree。', + inspect: '检查清理条件', + cleanup: '删除 worktree 目录', + confirm: '删除此目录?', + confirmDescription: + '会话需要已关闭,且 worktree 没有未提交修改。目录将被删除,Git 分支仍会保留。', + blocked: '暂时无法清理', + session: '会话', + branch: '分支', + baseCommit: '起始提交', + preparing: '正在准备 worktree', + ready: 'worktree 已就绪', + failed: 'worktree 准备失败', + blockedState: 'worktree 需要处理', + removing: '正在删除 worktree 目录', + removed: '目录已删除,分支已保留', + reasonUnavailable: '此 worktree 操作暂不可用。', + workspaceBusy: '正在等待工作区空闲。', + unsupported: '当前部署或运行环境不支持会话 worktree。', + notGit: '所选目录不在 Git 仓库中。', + layoutUnsupported: + '仓库和 Git 元数据需要位于同一已授权存储中,请选择原 checkout。', + invalidRef: '所选起始分支或提交无效。', + dirty: + 'worktree 中存在未提交修改、未跟踪文件或忽略文件,请先保存或移除这些内容再清理。', + identityConflict: '原 worktree 目录缺失或已发生变化。', + stopUnverified: '尚未确认上一次 Git 执行已经停止。', + sessionActive: '请先关闭会话,再删除它的 worktree。', + backgroundBusy: '请等待后台任务结束后再清理。', +}; + +const REASON_KEYS: Record = { + workspace_busy: 'workspaceBusy', + worktree_unsupported: 'unsupported', + worktree_not_git: 'notGit', + worktree_layout_unsupported: 'layoutUnsupported', + worktree_invalid_ref: 'invalidRef', + worktree_dirty: 'dirty', + worktree_index_flags: 'dirty', + worktree_identity_conflict: 'identityConflict', + maintenance_stop_unverified: 'stopUnverified', + worktree_session_active: 'sessionActive', + worktree_runtime_busy: 'backgroundBusy', + worktree_children_busy: 'backgroundBusy', + worktree_operations_busy: 'backgroundBusy', +}; + +export function useManagedWorktreeText() { + const { language } = useI18n(); + return (key: keyof typeof EN): string => + (language === 'zh-CN' ? ZH : EN)[key]; +} + +export function useManagedWorktreeReason() { + const text = useManagedWorktreeText(); + return (code?: string | null): string => { + const key = + code && Object.hasOwn(REASON_KEYS, code) ? REASON_KEYS[code] : undefined; + const message = text(key ?? 'reasonUnavailable'); + return code ? `${message} (${code})` : message; + }; +} diff --git a/packages/web-shell/client/components/managed/use-managed-file-references.test.tsx b/packages/web-shell/client/components/managed/use-managed-file-references.test.tsx index f47e83ea1e2..4547770f0ce 100644 --- a/packages/web-shell/client/components/managed/use-managed-file-references.test.tsx +++ b/packages/web-shell/client/components/managed/use-managed-file-references.test.tsx @@ -275,6 +275,49 @@ describe('Managed file reference snapshots', () => { ).rejects.toThrow('select the file again'); }); + it('resolves an existing Session from its ID for query, read and upload', async () => { + const api = enableUploads(); + await render('.qwen-managed-worktrees/owned/src', 'session-a'); + expect(api.query).toHaveBeenLastCalledWith( + { sessionId: 'session-a', directory: '.', query: '' }, + { clientId: 'client', signal: expect.any(AbortSignal) }, + ); + await select(); + expect(api.read).toHaveBeenLastCalledWith( + { + sessionId: 'session-a', + path: 'src/a.ts', + expectedScopeRevision: revision, + }, + { clientId: 'client' }, + ); + const data = new Blob(['session upload']); + await current.uploadTarget!.client.uploadWorkspaceFile({ + path: 'file.txt', + data, + }); + expect(api.upload).toHaveBeenLastCalledWith( + { + sessionId: 'session-a', + path: 'file.txt', + expectedScopeRevision: revision, + uploadId: expect.any(String), + }, + data, + { clientId: 'client', signal: undefined }, + ); + const prepared = await current.prepareInput( + '@src/a.ts explain', + [], + await metadata(), + ); + expect(prepared.files[0]?.text).toBe(text); + expect(api.query).toHaveBeenLastCalledWith( + { sessionId: 'session-a', directory: '.', query: '' }, + { clientId: 'client' }, + ); + }); + it('blocks submission until every selected file has finished loading', async () => { const read = provider.workspaceFiles!.read; const original = vi.mocked(read).getMockImplementation()!; diff --git a/packages/web-shell/client/components/managed/use-managed-file-references.ts b/packages/web-shell/client/components/managed/use-managed-file-references.ts index 0f289c1ca6f..9264f73caf7 100644 --- a/packages/web-shell/client/components/managed/use-managed-file-references.ts +++ b/packages/web-shell/client/components/managed/use-managed-file-references.ts @@ -96,8 +96,9 @@ export function useManagedFileReferences( try { const result = await api!.query( { - workspaceId: workspaceId!, - cwdRelative: cwdRelative!, + ...(sessionId === 'new' + ? { workspaceId: workspaceId!, cwdRelative: cwdRelative! } + : { sessionId }), directory, query, }, @@ -161,8 +162,9 @@ export function useManagedFileReferences( if (!revision) await query('.', ''); const snapshot = await api.read( { - workspaceId, - cwdRelative, + ...(sessionId === 'new' + ? { workspaceId, cwdRelative } + : { sessionId }), path, expectedScopeRevision: revision!, }, @@ -200,7 +202,15 @@ export function useManagedFileReferences( } }, }; - }, [api, workspaceId, cwdRelative, source, clientId, workspace?.state]); + }, [ + api, + workspaceId, + cwdRelative, + source, + clientId, + workspace?.state, + sessionId, + ]); useEffect(() => { const controller = new AbortController(); @@ -233,7 +243,12 @@ export function useManagedFileReferences( uploadScope?.source === source && workspaceId && cwdRelative - ? { ...uploadScope, workspaceId, cwdRelative } + ? { + ...uploadScope, + ...(sessionId === 'new' + ? { workspaceId, cwdRelative } + : { sessionId }), + } : undefined, ); @@ -298,7 +313,11 @@ export function useManagedFileReferences( if (!api || !workspaceId || !cwdRelative) throw new Error('Workspace file access is unavailable.'); const scope = await api.query( - { workspaceId, cwdRelative, directory: '.', query: '' }, + { + ...(sessionId === 'new' ? { workspaceId, cwdRelative } : { sessionId }), + directory: '.', + query: '', + }, { clientId }, ); if ( diff --git a/packages/web-shell/client/components/managed/use-managed-file-upload.ts b/packages/web-shell/client/components/managed/use-managed-file-upload.ts index 90febbf27b5..469d0616e41 100644 --- a/packages/web-shell/client/components/managed/use-managed-file-upload.ts +++ b/packages/web-shell/client/components/managed/use-managed-file-upload.ts @@ -2,7 +2,10 @@ import { useEffect, useMemo, useRef } from 'react'; import { fileReferenceInsertText } from '../../hooks/useAtMentionSources'; import type { FileUploadClient } from '../../hooks/useFileUpload'; import type { WebShellComposerTag } from '../../customization'; -import type { ManagedAgentProvider } from './managed-agent-provider'; +import type { + ManagedAgentProvider, + ManagedWorkspaceFileLocator, +} from './managed-agent-provider'; export interface ManagedUploadReference { version: 1; @@ -11,13 +14,11 @@ export interface ManagedUploadReference { path: string; } -interface UploadScope { - workspaceId: string; - cwdRelative: string; +type UploadScope = ManagedWorkspaceFileLocator & { source: string; scopeRevision: string; maxUploadBytes: number; -} +}; export function useManagedFileUpload( provider: ManagedAgentProvider, @@ -27,6 +28,7 @@ export function useManagedFileUpload( const api = provider.workspaceFiles; const workspaceId = scope?.workspaceId; const cwdRelative = scope?.cwdRelative; + const sessionId = scope?.sessionId; const source = scope?.source; const scopeRevision = scope?.scopeRevision; const targetKey = @@ -46,8 +48,7 @@ export function useManagedFileUpload( return useMemo(() => { if ( !api?.upload || - !workspaceId || - !cwdRelative || + (!sessionId && (!workspaceId || !cwdRelative)) || !source || !scopeRevision || !targetKey || @@ -79,8 +80,9 @@ export function useManagedFileUpload( try { result = await api.upload!( { - workspaceId, - cwdRelative, + ...(sessionId + ? { sessionId } + : { workspaceId: workspaceId!, cwdRelative: cwdRelative! }), path: request.path, expectedScopeRevision: scopeRevision, uploadId, @@ -153,6 +155,7 @@ export function useManagedFileUpload( clientId, workspaceId, cwdRelative, + sessionId, source, scopeRevision, maxBytes, diff --git a/packages/web-shell/client/components/managed/use-managed-session.test.tsx b/packages/web-shell/client/components/managed/use-managed-session.test.tsx index 2918c69348a..dbd09349e43 100644 --- a/packages/web-shell/client/components/managed/use-managed-session.test.tsx +++ b/packages/web-shell/client/components/managed/use-managed-session.test.tsx @@ -15,6 +15,7 @@ import type { ManagedAgentProvider, ManagedAgentSessionEvent, ManagedAgentSessionTranscript, + ManagedAgentSessionSummary, } from './managed-agent-provider'; import { failureRetryDelayMs, useManagedSession } from './use-managed-session'; @@ -270,6 +271,111 @@ describe('useManagedSession', () => { return () => random.mockRestore(); } + it('keeps the newest worktree identity without regranting execution capabilities from an older read', async () => { + const bound = ( + revision: number, + state: 'preparing' | 'ready' | 'blocked', + ): ManagedAgentSessionSummary => ({ + sessionId: 'session-1', + title: 'Worktree session', + createdAt: 1, + updatedAt: 1, + admittedAt: 1, + phase: 'created', + runtimeReady: state === 'ready', + runtimeState: 'unknown', + worktree: { + id: 'worktree-a', + sessionId: 'session-1', + workspaceId: 'ws-a', + state, + branch: 'worktree-managed-a', + baseCommit: 'a'.repeat(40), + cwdRelative: '.qwen-managed-session-worktrees/a', + operationId: 'op-a', + revision, + }, + workspace: { + workspaceId: 'ws-a', + cwdRelative: '.qwen-managed-session-worktrees/a', + contextRevision: 1, + state: + state === 'ready' + ? 'ready' + : state === 'preparing' + ? 'changing' + : 'recovery_blocked', + }, + planModeSupported: state === 'ready', + capabilities: { + canSend: state === 'ready', + canCancel: false, + canRename: state === 'ready', + workspaceTurns: state === 'ready', + inputAttachments: state === 'ready', + childAgents: state === 'ready', + modelChange: state === 'ready', + contextCompression: state === 'ready', + approvalModeChange: state === 'ready', + }, + }); + let stale!: (value: ManagedAgentSessionSummary) => void; + const getSession = vi + .fn() + .mockResolvedValueOnce(bound(1, 'preparing')) + .mockImplementationOnce( + () => + new Promise((resolve) => { + stale = resolve; + }), + ) + .mockResolvedValueOnce(bound(2, 'ready')) + .mockResolvedValueOnce(bound(3, 'blocked')) + .mockResolvedValue(bound(2, 'ready')); + const provider = { + getSession, + getTranscript: vi.fn().mockResolvedValue(transcript(0)), + async *subscribeEvents(_id: string, opts: { signal: AbortSignal }) { + await new Promise((resolve) => + opts.signal.addEventListener('abort', resolve), + ); + yield* []; + }, + } as unknown as ManagedAgentProvider; + root = createRoot(document.createElement('div')); + await act(async () => { + root!.render(); + }); + let earlier!: Promise; + act(() => { + earlier = latest!.refreshSummary(); + }); + await act(async () => { + await latest!.refreshSummary(); + }); + await act(async () => { + stale({ ...bound(1, 'preparing'), lifecycleStatus: 'closed' }); + await earlier; + }); + expect(latest!.summary).toMatchObject({ + worktree: { revision: 2, state: 'ready' }, + workspace: { state: 'ready' }, + lifecycleStatus: 'closed', + capabilities: bound(1, 'preparing').capabilities, + planModeSupported: false, + }); + await act(async () => { + await latest!.refreshSummary(); + await latest!.refreshSummary(); + }); + expect(latest!.summary).toMatchObject({ + worktree: { revision: 3, state: 'blocked' }, + workspace: { state: 'recovery_blocked' }, + capabilities: bound(3, 'blocked').capabilities, + planModeSupported: false, + }); + }); + it('refreshes only the summary on context events and preserves a higher revision', async () => { const bound = (revision: number) => ({ sessionId: 'session-1', diff --git a/packages/web-shell/client/components/managed/use-managed-session.ts b/packages/web-shell/client/components/managed/use-managed-session.ts index 6bc5dd9818e..336d94a7ea8 100644 --- a/packages/web-shell/client/components/managed/use-managed-session.ts +++ b/packages/web-shell/client/components/managed/use-managed-session.ts @@ -226,6 +226,34 @@ export function useManagedSession( planMode: previous.planMode, }; } + if ( + previous?.sessionId === incoming.sessionId && + (previous.worktree?.revision ?? 0) > (incoming.worktree?.revision ?? 0) + ) { + next = { + ...next, + worktree: previous.worktree, + workspace: next.workspace + ? { ...next.workspace, state: previous.workspace?.state } + : previous.workspace, + ...(previous.worktree?.state === 'ready' + ? {} + : { + planModeSupported: false, + capabilities: { + ...next.capabilities, + canSend: false, + canRename: false, + workspaceTurns: false, + childAgents: false, + modelChange: false, + contextCompression: false, + approvalModeChange: false, + inputAttachments: false, + }, + }), + }; + } summaryRef.current = next; return next; }, diff --git a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx index 8b338f83d57..5e1696cb890 100644 --- a/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx +++ b/packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type ReactElement, + type ReactNode, } from 'react'; import type { DaemonSessionSummary } from '@qwen-code/sdk/daemon'; import { @@ -41,6 +42,7 @@ interface SessionDetailsTooltipProps { completedUnread: boolean; statusLabel?: string; workspaceLabel?: string; + details?: ReactNode; openOnClick?: boolean; ownerToken?: string; open?: boolean; @@ -56,6 +58,7 @@ export function SessionDetailsTooltip({ completedUnread, statusLabel, workspaceLabel, + details, openOnClick = false, ownerToken, open: controlledOpen, @@ -347,6 +350,7 @@ export function SessionDetailsTooltip({