From 2996fa3d193d4948b4c6012c483f0269182f4565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 00:25:23 +0800 Subject: [PATCH 01/18] refactor(run): centralize recovery status writes --- .../run/recovery/run-recovery.service.spec.ts | 51 ++++++------ .../src/run/recovery/run-recovery.service.ts | 41 ++++------ .../src/run/status/run-status.service.spec.ts | 46 +++++++++++ .../src/run/status/run-status.service.ts | 77 +++++++++++++++---- 4 files changed, 152 insertions(+), 63 deletions(-) diff --git a/apps/server/src/run/recovery/run-recovery.service.spec.ts b/apps/server/src/run/recovery/run-recovery.service.spec.ts index 7c7dcf0b..b3f2ee94 100644 --- a/apps/server/src/run/recovery/run-recovery.service.spec.ts +++ b/apps/server/src/run/recovery/run-recovery.service.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import type { RuntimeHostContract } from "@agework/shared/protocol"; import { RunRecoveryService } from "./run-recovery.service"; import type { RunRepository } from "../run.repository"; -import type { ConversationService } from "../../conversation/conversation.service"; +import type { RunStatusService } from "../status/run-status.service"; import type { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import type { ConfigService } from "../../config/config.service"; import type { RuntimeHostRunReconciliation } from "../../runtime-host/runtime-host.types"; @@ -30,11 +30,10 @@ function makeActiveRun(input: { id?: string; runtimeHostId?: string }) { function makeDeps(activeRuns: unknown[]) { const runRepository: Partial = { listActive: vi.fn().mockResolvedValue(activeRuns), - markError: vi.fn().mockResolvedValue(undefined), findRuntimeReconciliationRows: vi.fn().mockResolvedValue([]), }; - const conversationService: Partial = { - setConversationRunState: vi.fn().mockResolvedValue(undefined), + const runStatusService: Partial = { + failRun: vi.fn().mockResolvedValue(undefined), }; const runtimeHostService: Partial = { getRuntimeHostRow: vi.fn().mockResolvedValue(null), @@ -47,7 +46,7 @@ function makeDeps(activeRuns: unknown[]) { }; return { runRepository, - conversationService, + runStatusService, runtimeHostService, configService, }; @@ -62,7 +61,7 @@ function makeService( ) { return new RunRecoveryService( deps.runRepository as RunRepository, - deps.conversationService as unknown as ConversationService, + deps.runStatusService as RunStatusService, deps.runtimeHostService as RuntimeHostService, deps.configService as ConfigService, runtimeHost as RuntimeHostContract, @@ -87,13 +86,11 @@ describe("RunRecoveryService.failInterruptedRuns", () => { await service.failInterruptedRuns(); - expect(deps.runRepository.markError).toHaveBeenCalledWith( - "run-1", - "服务重启导致运行中断" - ); - expect( - deps.conversationService.setConversationRunState - ).toHaveBeenCalledWith("conversation-1", { runStatus: "error" }); + expect(deps.runStatusService.failRun).toHaveBeenCalledWith({ + runId: "run-1", + conversationId: "conversation-1", + error: "服务重启导致运行中断", + }); }); it("fails registered-host runs and cancels their stale remote sessions", async () => { @@ -107,10 +104,11 @@ describe("RunRecoveryService.failInterruptedRuns", () => { await service.failInterruptedRuns(); - expect(deps.runRepository.markError).toHaveBeenCalledWith( - "run-1", - "服务重启导致运行中断" - ); + expect(deps.runStatusService.failRun).toHaveBeenCalledWith({ + runId: "run-1", + conversationId: "conversation-1", + error: "服务重启导致运行中断", + }); expect(runtimeHost.command).toHaveBeenCalledWith({ runtimeHostId: "rt-registered-1", payload: expect.objectContaining({ @@ -215,7 +213,7 @@ describe("RunRecoveryService.failInterruptedRuns", () => { it("rejects bootstrap recovery when the core run status write fails", async () => { const deps = makeDeps([makeActiveRun({ runtimeHostId: "builtin" })]); - deps.runRepository.markError = vi + deps.runStatusService.failRun = vi .fn() .mockRejectedValue(new Error("database unavailable")); service = makeService(deps, makeRuntimeHost()); @@ -242,7 +240,7 @@ describe("RunRecoveryService abandoned-host sweep", () => { vi.useFakeTimers(); service = makeService(deps, runtimeHost); await service.failInterruptedRuns(); // 启动 sweep 定时器 - (deps.runRepository.markError as ReturnType).mockClear(); + (deps.runStatusService.failRun as ReturnType).mockClear(); await vi.advanceTimersByTimeAsync(60_000); } @@ -258,10 +256,11 @@ describe("RunRecoveryService abandoned-host sweep", () => { await runOneSweep(deps, runtimeHost); - expect(deps.runRepository.markError).toHaveBeenCalledWith( - "run-1", - "Runtime Host 离线超时,运行中断" - ); + expect(deps.runStatusService.failRun).toHaveBeenCalledWith({ + runId: "run-1", + conversationId: "conversation-1", + error: "Runtime Host 离线超时,运行中断", + }); expect(runtimeHost.releaseRun).toHaveBeenCalledWith({ runtimeHostId: "rt-registered-1", runId: "run-1", @@ -279,7 +278,7 @@ describe("RunRecoveryService abandoned-host sweep", () => { await runOneSweep(deps, makeRuntimeHost()); - expect(deps.runRepository.markError).not.toHaveBeenCalled(); + expect(deps.runStatusService.failRun).not.toHaveBeenCalled(); }); it("leaves runs alone during the grace window right after a disconnect", async () => { @@ -293,7 +292,7 @@ describe("RunRecoveryService abandoned-host sweep", () => { await runOneSweep(deps, makeRuntimeHost()); - expect(deps.runRepository.markError).not.toHaveBeenCalled(); + expect(deps.runStatusService.failRun).not.toHaveBeenCalled(); }); it("never sweeps builtin Host runs (they are handled at boot)", async () => { @@ -302,6 +301,6 @@ describe("RunRecoveryService abandoned-host sweep", () => { await runOneSweep(deps, makeRuntimeHost()); expect(deps.runtimeHostService.getRuntimeHostRow).not.toHaveBeenCalled(); - expect(deps.runRepository.markError).not.toHaveBeenCalled(); + expect(deps.runStatusService.failRun).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/run/recovery/run-recovery.service.ts b/apps/server/src/run/recovery/run-recovery.service.ts index d3ed1bf8..beb946db 100644 --- a/apps/server/src/run/recovery/run-recovery.service.ts +++ b/apps/server/src/run/recovery/run-recovery.service.ts @@ -17,7 +17,7 @@ import { type HostRunReapPort, type RuntimeHostRunReconciliation, } from "../../runtime-host/runtime-host.types"; -import { ConversationService } from "../../conversation/conversation.service"; +import { RunStatusService } from "../status/run-status.service"; import { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import { isBuiltinHostId } from "../../runtime-host/runtime-host.types"; import { ConfigService } from "../../config/config.service"; @@ -59,7 +59,7 @@ export class RunRecoveryService constructor( private readonly runRepository: RunRepository, - private readonly conversationService: ConversationService, + private readonly runStatusService: RunStatusService, private readonly runtimeHostService: RuntimeHostService, private readonly configService: ConfigService, @Inject(RUNTIME_HOST_EXECUTION) @@ -90,7 +90,11 @@ export class RunRecoveryService } else { for (const run of activeRuns) { const runtimeHostId = run.conversation.workspace.runtimeHostId; - await this.failRun(run.id, run.conversationId, "服务重启导致运行中断"); + await this.runStatusService.failRun({ + runId: run.id, + conversationId: run.conversationId, + error: "服务重启导致运行中断", + }); this.logger.log(`Marked interrupted run ${run.id} as error`); if (isBuiltinHostId(runtimeHostId)) continue; @@ -214,11 +218,11 @@ export class RunRecoveryService this.logger.warn( `Run ${run.id} abandoned: registered host ${runtimeHostId} offline beyond grace window` ); - await this.failRun( - run.id, - run.conversationId, - "Runtime Host 离线超时,运行中断" - ); + await this.runStatusService.failRun({ + runId: run.id, + conversationId: run.conversationId, + error: "Runtime Host 离线超时,运行中断", + }); this.runtimeHost.releaseRun({ runtimeHostId, runId: run.id }); } } @@ -240,7 +244,11 @@ export class RunRecoveryService this.logger.warn( `Run ${run.id} reaped: host ${runtimeHostId} ${logDetail}` ); - await this.failRun(run.id, run.conversationId, runReason); + await this.runStatusService.failRun({ + runId: run.id, + conversationId: run.conversationId, + error: runReason, + }); this.runtimeHost.releaseRun({ runtimeHostId, runId: run.id }); } } @@ -256,19 +264,4 @@ export class RunRecoveryService return heartbeatAt < cutoffMs; } - private async failRun( - runId: string, - conversationId: string, - reason: string - ): Promise { - await this.runRepository.markError(runId, reason); - await this.conversationService - .setConversationRunState(conversationId, { runStatus: "error" }) - .catch( - swallow( - this.logger, - `set conversation active run status to error for run ${runId}` - ) - ); - } } diff --git a/apps/server/src/run/status/run-status.service.spec.ts b/apps/server/src/run/status/run-status.service.spec.ts index 741c24bb..d7d9d57a 100644 --- a/apps/server/src/run/status/run-status.service.spec.ts +++ b/apps/server/src/run/status/run-status.service.spec.ts @@ -268,6 +268,52 @@ describe("RunStatusService", () => { expect(handler.isTerminalOrFinalizing("run-1")).toBe(true); }); + it("fails a run without a live handle through the single status owner", async () => { + const { handler, runRepository, runConversation, runEvents } = + makeSubject(); + + await handler.failRun({ + runId: "run-1", + conversationId: "conversation-1", + error: "host offline", + }); + + expect(runRepository.markError).toHaveBeenCalledWith( + "run-1", + "host offline" + ); + expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + "conversation-1", + { runStatus: "error" } + ); + expect(runEvents.runStatusChanged).toHaveBeenCalledWith({ + runId: "run-1", + origin: "platform", + status: "error", + reason: "host offline", + }); + }); + + it("fails a live run with the normal terminal side effects", async () => { + const registry = new LiveRunRegistry(makeConfig()); + const unregister = vi.spyOn(registry, "unregister"); + const { handler } = makeSubject({ + activeRun: { id: "run-1" }, + registry, + }); + const handle = makeHandle(); + registry.register("run-1", handle); + + await handler.failRun({ + runId: "run-1", + conversationId: "conversation-1", + error: "host offline", + }); + + expect(handle.saveRun).toHaveBeenCalledWith(false, "error"); + expect(unregister).toHaveBeenCalledWith("run-1"); + }); + it("marks cancelling and records a platform status event on cancel request", async () => { const { handler, runRepository, runEvents } = makeSubject(); diff --git a/apps/server/src/run/status/run-status.service.ts b/apps/server/src/run/status/run-status.service.ts index 1dcfa5a4..40db5d6c 100644 --- a/apps/server/src/run/status/run-status.service.ts +++ b/apps/server/src/run/status/run-status.service.ts @@ -54,8 +54,18 @@ export class RunStatusService { runId: string; payload: RunStatusPayload; effect: RunStatusEffect; + /** 无 LiveRunHandle 的平台恢复路径用它回写会话终态。 */ + conversationId?: string; + /** worker 上行默认 runtime;恢复/平台主动终结显式标 platform。 */ + origin?: "runtime" | "platform"; }): Promise { - const { runId, payload, effect } = input; + const { + runId, + payload, + effect, + conversationId, + origin = "runtime", + } = input; const terminal = effect.terminal; this.logger[terminal ? "log" : "debug"]("run status", { runId, @@ -66,7 +76,16 @@ export class RunStatusService { if (terminal) { this.finalization.beginFinalizing(runId); } - this.recordStatusEvent(runId, payload); + if (origin === "platform") { + this.recordPlatformStatusChanged( + runId, + payload.status, + payload.error, + `record platform run status for run ${runId}` + ); + } else { + this.recordStatusEvent(runId, payload); + } if (effect.persistenceAction === "markRequiresAction") { this.runEvents .append(this.runEvents.permissionRequested({ runId })) @@ -92,8 +111,18 @@ export class RunStatusService { ); } - if (terminal && handle) { - await this.applyTerminalEffects(runId, payload, effect, handle); + if (terminal) { + const terminalConversationId = handle?.conversationId ?? conversationId; + if (terminalConversationId) { + await this.updateConversationTerminalStatus( + runId, + effect, + terminalConversationId + ); + } + if (handle) { + await this.applyTerminalEffects(runId, payload, effect, handle); + } } // 只有全部终态副作用成功后才记 completed。失败时保留 live handle 与 @@ -114,6 +143,31 @@ export class RunStatusService { } } + /** + * 平台/恢复侧把 run 收敛为 error。无论 LiveRunHandle 是否存在都复用 apply: + * 有 handle 时完整保存消息、结束 SSE 并清理内存;无 handle 时仍统一完成 + * Run/Conversation 持久化、状态事件和终态守卫。 + */ + async failRun(input: { + runId: string; + conversationId: string; + error: string; + }): Promise { + const payload: RunStatusPayload = { + status: "error", + error: input.error, + }; + const decision = this.decide(input.runId, payload); + if (decision.action === "ignore") return; + await this.apply({ + runId: input.runId, + payload, + effect: decision.effect, + conversationId: input.conversationId, + origin: "platform", + }); + } + /** 平台侧取消请求:活跃 run 标记 cancelling 并记账;终态等 worker 上报 cancelled 收敛。 */ async markCancelRequested(runId: string, reason?: string): Promise { await this.runRepository.markCancelling(runId); @@ -195,7 +249,6 @@ export class RunStatusService { effect: RunStatusEffect, handle: LiveRunHandle ): Promise { - await this.updateConversationTerminalStatus(runId, effect, handle); if (effect.terminalMessageComplete !== true) { this.recordMessageFailed(runId, effect, handle); } @@ -231,21 +284,19 @@ export class RunStatusService { private async updateConversationTerminalStatus( runId: string, effect: RunStatusEffect, - handle: LiveRunHandle + conversationId: string ): Promise { if (!effect.terminalConversationStatus) return; - const newerActiveRun = await this.runRepository.findActiveByConversationId( - handle.conversationId - ); + const newerActiveRun = + await this.runRepository.findActiveByConversationId(conversationId); if (newerActiveRun && newerActiveRun.id !== runId) { return; } - await this.conversationService.setConversationRunState( - handle.conversationId, - { runStatus: effect.terminalConversationStatus } - ); + await this.conversationService.setConversationRunState(conversationId, { + runStatus: effect.terminalConversationStatus, + }); } private writeTerminalSse( From c0a98757f0965d1a8d19dd2d04e2ae24f4bd34e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 00:38:18 +0800 Subject: [PATCH 02/18] refactor(runtime): aggregate worker lifecycle state --- apps/runtime/src/host/runtime-host.ts | 40 +++----- apps/runtime/src/host/worker-registry.spec.ts | 63 ++++++++++++ apps/runtime/src/host/worker-registry.ts | 97 +++++++++++++++++++ 3 files changed, 175 insertions(+), 25 deletions(-) create mode 100644 apps/runtime/src/host/worker-registry.spec.ts create mode 100644 apps/runtime/src/host/worker-registry.ts diff --git a/apps/runtime/src/host/runtime-host.ts b/apps/runtime/src/host/runtime-host.ts index ea3b285a..2bda0b81 100644 --- a/apps/runtime/src/host/runtime-host.ts +++ b/apps/runtime/src/host/runtime-host.ts @@ -56,20 +56,18 @@ import { export { WorkerHttpServer } from "./worker-http-server.js"; export { loadRuntimePlugins } from "../plugins/runtime-plugin-loader.js"; import { - WorkerPool, type WorkerEntry, deriveReuseIdentity, type AcquisitionTask, type ReuseIdentity, } from "./worker-pool"; +import { WorkerRegistry } from "./worker-registry"; import { CleanupLedger, type CleanupMetadata, type ReleaseCleanupContext, } from "./cleanup-ledger"; import { buildWorkerEnv, makeRunConfig, resolveSpec } from "./run-config"; -import { CommandMailbox } from "./command-mailbox"; -import { HandshakeStore } from "./handshake-store"; import { RunSessionRegistry } from "./run-session-registry"; import { HostEnvironmentOperations, @@ -142,8 +140,8 @@ type WorkerLaunchAttempt = { /** * RuntimeHost:部署在一台执行机器上的常驻执行节点。 * - * 实现 `RuntimeHostContract`,作为 Host 门面协调 WorkerPool、RunSessionRegistry、 - * CommandMailbox、HandshakeStore 和本机操作组件。一台机器 = 一个 Host = + * 实现 `RuntimeHostContract`,作为 Host 门面协调 WorkerRegistry、 + * RunSessionRegistry 和本机操作组件。一台机器 = 一个 Host = * 一行注册 = 一条隧道。 * * 两种宿主: @@ -153,9 +151,8 @@ type WorkerLaunchAttempt = { * 同一实现、两种宿主。不依赖 NestJS。 */ export class RuntimeHost implements RuntimeHostContract { - private readonly pool = new WorkerPool(); - private readonly mailbox = new CommandMailbox(); - private readonly handshakes = new HandshakeStore(); + /** Worker 条目、握手与命令队列的单一生命周期聚合。 */ + private readonly pool = new WorkerRegistry(); private readonly sessions = new RunSessionRegistry(); private readonly workspaceOperations = new HostWorkspaceOperations(); private readonly environmentOperations: HostEnvironmentOperations; @@ -674,12 +671,9 @@ export class RuntimeHost implements RuntimeHostContract { mode: WorkerRemovalMode, release?: ReleaseCleanupContext ): Promise { - const entry = this.pool.remove(workerId); + const entry = this.pool.evict(workerId, reason); if (!entry) return; - this.handshakes.cancel(entry.workerId, reason); - this.mailbox.cleanup(entry.workerId); - // 先收口 Host 内的 run 状态,终态不依赖 provider 收资源成功。 for (const runId of entry.activeRuns) { this.clearRunState(runId); @@ -763,7 +757,7 @@ export class RuntimeHost implements RuntimeHostContract { }; this.pool.put(entry); - const handshake = this.handshakes.waitForRegister(workerId, startToken); + const handshake = this.pool.waitForRegister(workerId, startToken); // provider.start 可能长期 pending;先安装 rejection handler,避免 timeout // cancel handshake 后直到 start settle 期间出现 unhandled rejection。 void handshake.catch(() => {}); @@ -815,9 +809,7 @@ export class RuntimeHost implements RuntimeHostContract { attempt.cancelReason = reason; attempt.release = release; abortController.abort(reason); - this.handshakes.cancel(workerId, reason.message); - this.pool.remove(workerId); - this.mailbox.cleanup(workerId); + this.pool.evict(workerId, reason.message); result.reject(reason); if (attempt.runtimeInstanceId) { // 显式 catch 避免 onProvisioned 的 fire-and-forget 产生 unhandled rejection; @@ -881,10 +873,8 @@ export class RuntimeHost implements RuntimeHostContract { attempt.cancelled = true; attempt.cancelReason = failure; abortController.abort(failure); - this.handshakes.cancel(workerId, failure.message); } - this.pool.remove(workerId); - this.mailbox.cleanup(workerId); + this.pool.evict(workerId, failure.message); try { await this.cleanupFailedLaunch(provider, entry, attempt); } catch (cleanupError) { @@ -942,7 +932,7 @@ export class RuntimeHost implements RuntimeHostContract { runId: string, payload: CommandPayload ): void { - this.mailbox.enqueue(workerId, runId, payload); + this.pool.enqueueCommand(workerId, runId, payload); this.config.onCommandDispatched?.({ runId, commandId: payload.commandId, @@ -957,7 +947,7 @@ export class RuntimeHost implements RuntimeHostContract { workerId: string, query: { afterSeq?: number; waitMs?: number } ): Promise<{ - commands: ReturnType; + commands: RunChannelMessage[]; queueEpoch: number; }> { const afterSeq = query.afterSeq ?? 0; @@ -966,9 +956,9 @@ export class RuntimeHost implements RuntimeHostContract { const entry = this.pool.getById(workerId); if (entry) this.pool.touch(workerId); - return this.mailbox.poll(workerId, afterSeq, waitMs).then((commands) => ({ + return this.pool.pollCommands(workerId, afterSeq, waitMs).then((commands) => ({ commands, - queueEpoch: this.mailbox.epochFor(workerId), + queueEpoch: this.pool.commandEpoch(workerId), })); } @@ -984,7 +974,7 @@ export class RuntimeHost implements RuntimeHostContract { token: string, info: { pid?: number } ): boolean { - return this.handshakes.registerWorker(workerId, token, info); + return this.pool.registerWorker(workerId, token, info); } /** @@ -1131,7 +1121,7 @@ export class RuntimeHost implements RuntimeHostContract { /** 进程退出时清理定时器(不停止 worker,shutdown 才停)。 */ drain(): void { - this.mailbox.drain(); + this.pool.drainControlPlane(); if (this.fenceTimer) clearInterval(this.fenceTimer); if (this.capabilityTimer) clearInterval(this.capabilityTimer); } diff --git a/apps/runtime/src/host/worker-registry.spec.ts b/apps/runtime/src/host/worker-registry.spec.ts new file mode 100644 index 00000000..f3ed9d4f --- /dev/null +++ b/apps/runtime/src/host/worker-registry.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { WorkerRegistry } from "./worker-registry"; +import type { WorkerEntry } from "./worker-pool"; + +function makeEntry(workerId = "worker-1"): WorkerEntry { + return { + workerId, + isolation: { scope: "workspace", subjectId: "workspace-1" }, + runtimeType: "native", + userId: "user-1", + userLifecycleVersion: 1, + workspaceIds: new Set(["workspace-1"]), + startToken: "token-1", + status: "starting", + runtimeInstanceId: "", + lastSeen: Date.now(), + cancelledRuns: new Set(), + activeRuns: new Set(["run-1"]), + }; +} + +describe("WorkerRegistry", () => { + it("evicts the entry, pending handshake and command queue together", async () => { + const registry = new WorkerRegistry(); + const entry = makeEntry(); + registry.put(entry); + const handshake = registry.waitForRegister( + entry.workerId, + entry.startToken + ); + registry.enqueueCommand(entry.workerId, "run-1", { + type: "user_message", + commandId: "command-1", + runId: "run-1", + }); + + const rejected = expect(handshake).rejects.toThrow("worker stopped"); + expect(registry.evict(entry.workerId, "worker stopped")).toBe(entry); + await rejected; + + expect(registry.getById(entry.workerId)).toBeUndefined(); + await expect( + registry.pollCommands(entry.workerId, 0, 0) + ).resolves.toEqual([]); + }); + + it("cleans control state when a worker generation is superseded", async () => { + const registry = new WorkerRegistry(); + const first = makeEntry("worker-1"); + registry.put(first); + const handshake = registry.waitForRegister( + first.workerId, + first.startToken + ); + const rejected = expect(handshake).rejects.toThrow("worker superseded"); + + registry.put(makeEntry("worker-2")); + + await rejected; + expect(registry.getById("worker-1")).toBeUndefined(); + expect(registry.getById("worker-2")).toBeDefined(); + }); +}); diff --git a/apps/runtime/src/host/worker-registry.ts b/apps/runtime/src/host/worker-registry.ts new file mode 100644 index 00000000..454357fa --- /dev/null +++ b/apps/runtime/src/host/worker-registry.ts @@ -0,0 +1,97 @@ +import type { + CommandPayload, + RunChannelMessage, +} from "@agework/shared/protocol"; +import { CommandMailbox } from "./command-mailbox"; +import { HandshakeStore } from "./handshake-store"; +import { + WorkerPool, + type WorkerEntry, + type ReuseIdentity, +} from "./worker-pool"; + +/** + * Worker 生命周期聚合:WorkerEntry、启动握手和命令信箱以 workerId 同生共死。 + * + * WorkerPool / CommandMailbox / HandshakeStore 各自维护专门索引,本类是唯一组合 + * 入口,确保 RuntimeHost 不再跨三个状态容器手工清理。后续内部存储如何演进 + * 不影响 RuntimeHost 契约。 + */ +export class WorkerRegistry extends WorkerPool { + private readonly mailbox = new CommandMailbox(); + private readonly handshakes = new HandshakeStore(); + + override put(entry: WorkerEntry): void { + const identity: ReuseIdentity = { + scope: entry.isolation.scope, + subjectId: entry.isolation.subjectId, + runtimeType: entry.runtimeType, + }; + const previous = this.getByIdentity( + identity, + entry.userLifecycleVersion + ); + super.put(entry); + if (previous && previous.workerId !== entry.workerId) { + this.cleanupControlState(previous.workerId, "worker superseded"); + } + } + + /** + * 原子移除 Worker 的全部 Host 内状态。未知 workerId 也清理残留的握手/信箱, + * 让重复 stop/release 保持幂等。 + */ + evict(workerId: string, reason: string): WorkerEntry | undefined { + const entry = super.remove(workerId); + this.cleanupControlState(workerId, reason); + return entry; + } + + waitForRegister( + workerId: string, + token: string + ): Promise<{ pid?: number; registeredAt: string }> { + return this.handshakes.waitForRegister(workerId, token); + } + + cancelHandshake(workerId: string, reason: string): void { + this.handshakes.cancel(workerId, reason); + } + + registerWorker( + workerId: string, + token: string, + info: { pid?: number } + ): boolean { + return this.handshakes.registerWorker(workerId, token, info); + } + + enqueueCommand( + workerId: string, + runId: string, + payload: CommandPayload + ): RunChannelMessage { + return this.mailbox.enqueue(workerId, runId, payload); + } + + pollCommands( + workerId: string, + afterSeq: number, + timeoutMs: number + ): Promise[]> { + return this.mailbox.poll(workerId, afterSeq, timeoutMs); + } + + commandEpoch(workerId: string): number { + return this.mailbox.epochFor(workerId); + } + + drainControlPlane(): void { + this.mailbox.drain(); + } + + private cleanupControlState(workerId: string, reason: string): void { + this.handshakes.cancel(workerId, reason); + this.mailbox.cleanup(workerId); + } +} From b70eeab9c68b3fa4c68a9491dd72e9bdc85f5cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 00:39:54 +0800 Subject: [PATCH 03/18] refactor(protocol): centralize runtime contract types --- apps/runtime/src/host/host-operations.ts | 6 +-- apps/runtime/src/host/run-config.ts | 7 ++- apps/runtime/src/host/runtime-host.ts | 10 ++-- apps/runtime/src/registered/tunnel-client.ts | 15 +++--- .../contract/runtime-host.adapter.ts | 6 +-- .../contract/tunnel-runtime-host.ts | 6 +-- packages/shared/src/protocol/channel.ts | 46 ------------------- packages/shared/src/protocol/index.ts | 4 -- 8 files changed, 21 insertions(+), 79 deletions(-) diff --git a/apps/runtime/src/host/host-operations.ts b/apps/runtime/src/host/host-operations.ts index b7f7d6e5..1ec43f7e 100644 --- a/apps/runtime/src/host/host-operations.ts +++ b/apps/runtime/src/host/host-operations.ts @@ -9,15 +9,13 @@ import type { ReadFileDiffInput, ReadFileInput, SearchFilesInput, - WorkspaceFileQuery, -} from "@agework/shared/protocol"; -import type { WorkspaceChangedFilesResponse, WorkspaceFileDiffResponse, WorkspaceFileListResponse, + WorkspaceFileQuery, WorkspaceFileReadResponse, WorkspaceFileSearchResponse, -} from "@agework/shared/api"; +} from "@agework/shared/protocol"; import { detectEnvConfig, installCli as installCliOnDisk, diff --git a/apps/runtime/src/host/run-config.ts b/apps/runtime/src/host/run-config.ts index 1f311059..4ebd221d 100644 --- a/apps/runtime/src/host/run-config.ts +++ b/apps/runtime/src/host/run-config.ts @@ -3,10 +3,13 @@ import type { AgentEventTraceConfig, RunConfig, RunPlacement, - RuntimeSpec, SubmitRunInput, } from "@agework/shared/protocol"; -import { isRuntimeType, resolveRuntimeSpec } from "@agework/runtime-sdk"; +import { + isRuntimeType, + resolveRuntimeSpec, + type RuntimeSpec, +} from "@agework/runtime-sdk"; import type { RuntimeHostConfig } from "./runtime-host.js"; /** diff --git a/apps/runtime/src/host/runtime-host.ts b/apps/runtime/src/host/runtime-host.ts index 2bda0b81..a457333f 100644 --- a/apps/runtime/src/host/runtime-host.ts +++ b/apps/runtime/src/host/runtime-host.ts @@ -19,27 +19,25 @@ import type { RuntimeHostCommandInput, RuntimeHostRunRef, RuntimeHostUpstream, - RuntimeSpec, SearchFilesInput, StopWorkerInput, SubmitRunInput, WorkerSnapshot, - WorkspaceFileQuery, -} from "@agework/shared/protocol"; -import { generateId } from "@agework/shared"; -import type { WorkspaceChangedFilesResponse, WorkspaceFileDiffResponse, WorkspaceFileListResponse, + WorkspaceFileQuery, WorkspaceFileReadResponse, WorkspaceFileSearchResponse, -} from "@agework/shared/api"; +} from "@agework/shared/protocol"; +import { generateId } from "@agework/shared"; import { isRuntimeType, type RuntimeInstanceRef, type RuntimeLaunchContext, type RuntimeProvider, type RuntimeProviderPlugin, + type RuntimeSpec, type RuntimeType, } from "@agework/runtime-sdk"; import { diff --git a/apps/runtime/src/registered/tunnel-client.ts b/apps/runtime/src/registered/tunnel-client.ts index e20cb4e7..886a2d3b 100644 --- a/apps/runtime/src/registered/tunnel-client.ts +++ b/apps/runtime/src/registered/tunnel-client.ts @@ -13,6 +13,11 @@ import { type InstallCliResult, type RuntimeCapabilities, type WorkerScope, + type WorkspaceChangedFilesResponse, + type WorkspaceFileDiffResponse, + type WorkspaceFileListResponse, + type WorkspaceFileReadResponse, + type WorkspaceFileSearchResponse, } from "@agework/shared/protocol"; import { rpcError, rpcSuccess } from "@agework/shared/protocol/rpc"; import { @@ -21,21 +26,13 @@ import { isHostTunnelServerNotification, isWireMessageType, } from "@agework/shared/protocol/wire"; -import { AGEWORK_VERSION } from "@agework/shared"; -import type { RuntimeEnvConfig } from "@agework/shared/api"; +import { AGEWORK_VERSION, type RuntimeEnvConfig } from "@agework/shared"; import type { RegisteredRuntimeHostConfig, RuntimeType } from "./config.js"; import { detectEnvConfig } from "@agework/shared/cli"; import type { DirectoryListing, HostCapabilityStatus, } from "@agework/shared/protocol"; -import type { - WorkspaceFileListResponse, - WorkspaceFileReadResponse, - WorkspaceFileSearchResponse, - WorkspaceChangedFilesResponse, - WorkspaceFileDiffResponse, -} from "@agework/shared/api"; import { TunnelUpstream } from "./tunnel-upstream.js"; /** 按 runtimeTypes 构建能力矩阵条目(scope 表 + 各类型的可用性)。 */ diff --git a/apps/server/src/runtime-host/contract/runtime-host.adapter.ts b/apps/server/src/runtime-host/contract/runtime-host.adapter.ts index f259d954..619e9075 100644 --- a/apps/server/src/runtime-host/contract/runtime-host.adapter.ts +++ b/apps/server/src/runtime-host/contract/runtime-host.adapter.ts @@ -22,15 +22,13 @@ import type { StopWorkerInput, SubmitRunInput, WorkerSnapshot, - WorkspaceFileQuery, -} from "@agework/shared/protocol"; -import type { WorkspaceChangedFilesResponse, WorkspaceFileDiffResponse, WorkspaceFileListResponse, + WorkspaceFileQuery, WorkspaceFileReadResponse, WorkspaceFileSearchResponse, -} from "@agework/shared/api"; +} from "@agework/shared/protocol"; import type { RuntimeHost } from "@agework/runtime/host"; import { BUILTIN_HOST_ID, diff --git a/apps/server/src/runtime-host/contract/tunnel-runtime-host.ts b/apps/server/src/runtime-host/contract/tunnel-runtime-host.ts index 47256b64..894e31fd 100644 --- a/apps/server/src/runtime-host/contract/tunnel-runtime-host.ts +++ b/apps/server/src/runtime-host/contract/tunnel-runtime-host.ts @@ -26,15 +26,13 @@ import type { StopWorkerInput, SubmitRunInput, WorkerSnapshot, - WorkspaceFileQuery, -} from "@agework/shared/protocol"; -import type { WorkspaceChangedFilesResponse, WorkspaceFileDiffResponse, WorkspaceFileListResponse, + WorkspaceFileQuery, WorkspaceFileReadResponse, WorkspaceFileSearchResponse, -} from "@agework/shared/api"; +} from "@agework/shared/protocol"; import { ConfigService } from "../../config/config.service"; import { HostTunnelHandler } from "../gateway/host-tunnel.handler"; diff --git a/packages/shared/src/protocol/channel.ts b/packages/shared/src/protocol/channel.ts index 9a3e0ca9..a4c45366 100644 --- a/packages/shared/src/protocol/channel.ts +++ b/packages/shared/src/protocol/channel.ts @@ -205,55 +205,9 @@ export interface RunChannel { close(): Promise; } -// ── RuntimeSpec ────────────────────────────────────────────────────── - /** Worker 复用范围:user(用户范围)或 workspace(工作空间范围)。 */ export type WorkerScope = "user" | "workspace"; -/** - * 沙箱专属放置信息:复用范围、容器内挂载目标、沙箱引擎类型。 - * 仅 runtimeType 为 container(docker|opensandbox)时存在;native 模式无 sandbox scope,不带此对象。 - */ -export type SandboxPlacementInfo = { - scope: WorkerScope; - /** 容器/沙箱内 hostPath 的挂载目标路径(如 `/workspace` 或 `/workspaces`)。 */ - mountTarget: string; -}; - -/** - * 一次 run 已解析的 runtime 规格:workspace 怎么挂进运行环境(host/容器侧路径 + 挂载点)。 - * 启动前纯计算,provider 照此挂卷/起容器。 - * - * `sandbox` 是否存在决定 placement 形态;runtimeType 是插件开放标识,不再承担封闭联合判别。 - * `runtimePath` 跨 native/container 都有意义(worker 在执行环境内看到的 - * workspace 路径),留顶层。container-only 逻辑可直接以 `SandboxRuntimeSpec` 为入参。 - * - * 隔离/复用身份(isolation)由 Runtime Host 从 placement 派生后注入 - * RuntimeLaunchContext,不再进入 RuntimeSpec。 - */ -type RuntimeSpecBase = { - runtimeType: string; - userId: string; - workspaceId: string; - hostPath: string; - runtimePath: string; - /** 日志目录在执行环境内的路径(native 下即宿主机日志目录)。 */ - runtimeLogDir: string; -}; - -export type NativeRuntimeSpec = RuntimeSpecBase & { - runtimeType: "native"; - sandbox?: never; -}; - -export type SandboxRuntimeSpec = RuntimeSpecBase & { - sandbox: SandboxPlacementInfo; -}; - -export type RuntimeSpec = RuntimeSpecBase & { - sandbox?: SandboxPlacementInfo; -}; - // ── RunExecutionHandle ──────────────────────────────────────────── /** Server 侧一次 run 的最小执行路由句柄,不暴露 worker/容器实例细节。 */ diff --git a/packages/shared/src/protocol/index.ts b/packages/shared/src/protocol/index.ts index b3ca6153..5562f1b0 100644 --- a/packages/shared/src/protocol/index.ts +++ b/packages/shared/src/protocol/index.ts @@ -73,10 +73,6 @@ export type { WorkerRegisterRequest, WorkerRegisterResponse, WorkerScope, - RuntimeSpec, - NativeRuntimeSpec, - SandboxRuntimeSpec, - SandboxPlacementInfo, } from "./channel"; export type { RuntimeType, From f37cf101d17e4c7dcb2644e97d5dc259b059abc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 00:50:22 +0800 Subject: [PATCH 04/18] refactor(runtime-host): reduce dependency injection roles --- .../run/recovery/run-recovery.service.spec.ts | 8 +- .../src/run/recovery/run-recovery.service.ts | 7 +- ...me-host-reconciliation.coordinator.spec.ts | 82 ++++++++---- ...runtime-host-reconciliation.coordinator.ts | 22 +--- apps/server/src/run/run.service.spec.ts | 14 ++- apps/server/src/run/run.service.ts | 16 +-- .../src/run/user/run-user.listener.spec.ts | 20 +-- apps/server/src/run/user/run-user.listener.ts | 20 +-- .../workspace/run-workspace.listener.spec.ts | 4 +- .../run/workspace/run-workspace.listener.ts | 12 +- .../contract/runtime-host.adapter.ts | 16 +-- .../runtime-host/runtime-host.module.spec.ts | 15 +-- .../src/runtime-host/runtime-host.module.ts | 27 +--- .../runtime-host/runtime-host.service.spec.ts | 18 +-- .../src/runtime-host/runtime-host.service.ts | 119 ++++++++++-------- .../src/runtime-host/runtime-host.types.ts | 44 +------ apps/server/src/user/user.service.ts | 16 +-- .../workspace-reconciliation.spec.ts | 1 - .../src/workspace/workspace.service.spec.ts | 55 +++++--- .../server/src/workspace/workspace.service.ts | 20 +-- 20 files changed, 232 insertions(+), 304 deletions(-) diff --git a/apps/server/src/run/recovery/run-recovery.service.spec.ts b/apps/server/src/run/recovery/run-recovery.service.spec.ts index b3f2ee94..757eea88 100644 --- a/apps/server/src/run/recovery/run-recovery.service.spec.ts +++ b/apps/server/src/run/recovery/run-recovery.service.spec.ts @@ -5,7 +5,6 @@ import type { RunRepository } from "../run.repository"; import type { RunStatusService } from "../status/run-status.service"; import type { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import type { ConfigService } from "../../config/config.service"; -import type { RuntimeHostRunReconciliation } from "../../runtime-host/runtime-host.types"; function makeRuntimeHost( overrides: Record = {} @@ -37,6 +36,7 @@ function makeDeps(activeRuns: unknown[]) { }; const runtimeHostService: Partial = { getRuntimeHostRow: vi.fn().mockResolvedValue(null), + listRunIds: vi.fn().mockResolvedValue([]), }; // check 间隔 60s;判死窗口 300s → 兜底 grace 600s(2×), // 保证「sweep 触发时刚断线的 run」还在 grace 内不被误杀。 @@ -55,17 +55,15 @@ function makeDeps(activeRuns: unknown[]) { function makeService( deps: ReturnType, runtimeHost: Partial, - runReconciliation: Partial = { - listRunIds: vi.fn().mockResolvedValue([]), - } + runtimeHostServiceOverrides: Partial = {} ) { + Object.assign(deps.runtimeHostService, runtimeHostServiceOverrides); return new RunRecoveryService( deps.runRepository as RunRepository, deps.runStatusService as RunStatusService, deps.runtimeHostService as RuntimeHostService, deps.configService as ConfigService, runtimeHost as RuntimeHostContract, - runReconciliation as RuntimeHostRunReconciliation, { setRunReapPort: vi.fn() } ); } diff --git a/apps/server/src/run/recovery/run-recovery.service.ts b/apps/server/src/run/recovery/run-recovery.service.ts index beb946db..ecce4062 100644 --- a/apps/server/src/run/recovery/run-recovery.service.ts +++ b/apps/server/src/run/recovery/run-recovery.service.ts @@ -10,12 +10,10 @@ import type { RuntimeHostExecution } from "@agework/shared/protocol"; import { RunRepository } from "../run.repository"; import { RUNTIME_HOST_EXECUTION, - RUNTIME_HOST_RUN_RECONCILIATION, RUNTIME_HOST_RUN_REAP_BINDING, type HostReapReason, type HostRunReapBinding, type HostRunReapPort, - type RuntimeHostRunReconciliation, } from "../../runtime-host/runtime-host.types"; import { RunStatusService } from "../status/run-status.service"; import { RuntimeHostService } from "../../runtime-host/runtime-host.service"; @@ -64,8 +62,6 @@ export class RunRecoveryService private readonly configService: ConfigService, @Inject(RUNTIME_HOST_EXECUTION) private readonly runtimeHost: RuntimeHostExecution, - @Inject(RUNTIME_HOST_RUN_RECONCILIATION) - private readonly runReconciliation: RuntimeHostRunReconciliation, @Inject(RUNTIME_HOST_RUN_REAP_BINDING) private readonly runReapBinding: HostRunReapBinding ) {} @@ -128,7 +124,7 @@ export class RunRecoveryService * 由协调器保持 fail-closed 并重跑完整 attempt。 */ async reconcileRuntimeHost(runtimeHostId: string): Promise { - const runIds = await this.runReconciliation.listRunIds(runtimeHostId); + const runIds = await this.runtimeHostService.listRunIds(runtimeHostId); const rows = await this.runRepository.findRuntimeReconciliationRows(runIds); const rowById = new Map(rows.map((row) => [row.id, row])); let firstFailure: Error | undefined; @@ -263,5 +259,4 @@ export class RunRecoveryService const heartbeatAt = row.lastHeartbeatAt?.getTime() ?? 0; return heartbeatAt < cutoffMs; } - } diff --git a/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.spec.ts b/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.spec.ts index 54008500..e61de5d8 100644 --- a/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.spec.ts +++ b/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.spec.ts @@ -10,13 +10,15 @@ function deferred() { return { promise, resolve }; } -function makeCoordinator(overrides: { - runs?: Record; - workspaces?: Record; - users?: Record; - runtimeHosts?: Record; - hostResources?: Record; -} = {}) { +function makeCoordinator( + overrides: { + runs?: Record; + workspaces?: Record; + users?: Record; + runtimeHosts?: Record; + hostResources?: Record; + } = {} +) { const runs = { reconcileRuntimeHostRuns: vi.fn().mockResolvedValue(undefined), ...overrides.runs, @@ -33,19 +35,17 @@ function makeCoordinator(overrides: { isCurrentReconciliationEpoch: vi.fn().mockReturnValue(true), markReconciled: vi.fn().mockReturnValue(true), markReconcileFailed: vi.fn().mockReturnValue(true), - ...overrides.runtimeHosts, - }; - const hostResources = { listLifecycleClaims: vi.fn().mockResolvedValue([]), ...overrides.hostResources, + ...overrides.runtimeHosts, }; const coordinator = new RuntimeHostReconciliationCoordinator( runs as never, workspaces as never, users as never, - runtimeHosts as never, - hostResources as never + runtimeHosts as never ); + const hostResources = runtimeHosts; return { coordinator, runs, workspaces, users, runtimeHosts, hostResources }; } @@ -103,22 +103,50 @@ describe("RuntimeHostReconciliationCoordinator", () => { }); it.each([ - ["run", { runs: { reconcileRuntimeHostRuns: vi.fn().mockRejectedValue(new Error("run")) } }], - ["workspace", { workspaces: { reconcileRuntimeHostResources: vi.fn().mockRejectedValue(new Error("workspace")) } }], - ["user", { users: { reconcileRuntimeHostResources: vi.fn().mockRejectedValue(new Error("user")) } }], - ])("keeps the host fail-closed when %s reconciliation fails", async (_name, overrides) => { - const deps = makeCoordinator(overrides); - await deps.coordinator.onRuntimeHostConnected( - new RuntimeHostConnectedEvent("host-1", 3) - ); + [ + "run", + { + runs: { + reconcileRuntimeHostRuns: vi.fn().mockRejectedValue(new Error("run")), + }, + }, + ], + [ + "workspace", + { + workspaces: { + reconcileRuntimeHostResources: vi + .fn() + .mockRejectedValue(new Error("workspace")), + }, + }, + ], + [ + "user", + { + users: { + reconcileRuntimeHostResources: vi + .fn() + .mockRejectedValue(new Error("user")), + }, + }, + ], + ])( + "keeps the host fail-closed when %s reconciliation fails", + async (_name, overrides) => { + const deps = makeCoordinator(overrides); + await deps.coordinator.onRuntimeHostConnected( + new RuntimeHostConnectedEvent("host-1", 3) + ); - expect(deps.runtimeHosts.markReconcileFailed).toHaveBeenCalledWith( - "host-1", - 3 - ); - expect(deps.runtimeHosts.markReconciled).not.toHaveBeenCalled(); - deps.coordinator.onApplicationShutdown(); - }); + expect(deps.runtimeHosts.markReconcileFailed).toHaveBeenCalledWith( + "host-1", + 3 + ); + expect(deps.runtimeHosts.markReconciled).not.toHaveBeenCalled(); + deps.coordinator.onApplicationShutdown(); + } + ); it("reruns the complete attempt on the same connection after failure", async () => { vi.useFakeTimers(); diff --git a/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.ts b/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.ts index 653992eb..771ff747 100644 --- a/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.ts +++ b/apps/server/src/run/recovery/runtime-host-reconciliation.coordinator.ts @@ -1,18 +1,9 @@ -import { - Inject, - Injectable, - Logger, - type OnApplicationShutdown, -} from "@nestjs/common"; +import { Injectable, Logger, type OnApplicationShutdown } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; import { RUNTIME_HOST_CONNECTED_EVENT, type RuntimeHostConnectedEvent, } from "../../runtime-host/runtime-host.events"; -import { - RUNTIME_HOST_RESOURCE_RECONCILIATION, - type RuntimeHostResourceReconciliationPort, -} from "../../runtime-host/runtime-host.types"; import { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import { WorkspaceService } from "../../workspace/workspace.service"; import { UserService } from "../../user/user.service"; @@ -26,9 +17,7 @@ const RETRY_MAX_MS = 30_000; * 读取、workspace、user 对账,全部成功后才以 epoch CAS 放行 submitRun。 */ @Injectable() -export class RuntimeHostReconciliationCoordinator - implements OnApplicationShutdown -{ +export class RuntimeHostReconciliationCoordinator implements OnApplicationShutdown { private readonly logger = new Logger( RuntimeHostReconciliationCoordinator.name ); @@ -41,9 +30,7 @@ export class RuntimeHostReconciliationCoordinator private readonly runs: RunService, private readonly workspaces: WorkspaceService, private readonly users: UserService, - private readonly runtimeHosts: RuntimeHostService, - @Inject(RUNTIME_HOST_RESOURCE_RECONCILIATION) - private readonly hostResources: RuntimeHostResourceReconciliationPort + private readonly runtimeHosts: RuntimeHostService ) {} onApplicationShutdown(): void { @@ -71,8 +58,7 @@ export class RuntimeHostReconciliationCoordinator await this.runs.reconcileRuntimeHostRuns(runtimeHostId); if (!this.isCurrent(runtimeHostId, epoch)) return; - const claims = - await this.hostResources.listLifecycleClaims(runtimeHostId); + const claims = await this.runtimeHosts.listLifecycleClaims(runtimeHostId); if (!this.isCurrent(runtimeHostId, epoch)) return; await this.workspaces.reconcileRuntimeHostResources( diff --git a/apps/server/src/run/run.service.spec.ts b/apps/server/src/run/run.service.spec.ts index 0cb74ed5..3b8bc00a 100644 --- a/apps/server/src/run/run.service.spec.ts +++ b/apps/server/src/run/run.service.spec.ts @@ -7,12 +7,14 @@ import { RunEventService } from "../run-event/run-event.service"; import { RunStatusService } from "./status/run-status.service"; import { RunLauncher } from "./launch/run.launcher"; import { RunRecoveryService } from "./recovery/run-recovery.service"; +import type { RuntimeHostService } from "../runtime-host/runtime-host.service"; describe("RunService", () => { let service: RunService; let mockRunRepository: Partial; let mockLiveRunRegistry: Partial; let mockRuntimeHost: Partial; + let mockRuntimeHostService: Partial; let mockRunEvents: RunEventService; let mockRunStatusService: Partial; let mockRunLauncher: Partial; @@ -30,7 +32,9 @@ describe("RunService", () => { }; mockRuntimeHost = { command: vi.fn().mockResolvedValue(undefined), - listWorkers: vi.fn().mockResolvedValue([]), + }; + mockRuntimeHostService = { + listWorkersForAdmin: vi.fn().mockResolvedValue({ list: [] }), }; mockRunEvents = new RunEventService({} as never, {} as never, {} as never); vi.spyOn(mockRunEvents, "append").mockResolvedValue({} as never); @@ -49,7 +53,7 @@ describe("RunService", () => { mockRunRepository as RunRepository, mockLiveRunRegistry as LiveRunRegistry, mockRuntimeHost as RuntimeHostContract, - mockRuntimeHost as RuntimeHostContract, + mockRuntimeHostService as RuntimeHostService, mockRunEvents, mockRunStatusService as RunStatusService, mockRunLauncher as RunLauncher, @@ -373,9 +377,9 @@ describe("RunService", () => { it("reshapes the raw row and attaches the live worker snapshot", async () => { mockRunRepository.findAdminDetail = vi.fn().mockResolvedValue(rawRow); - mockRuntimeHost.listWorkers = vi - .fn() - .mockResolvedValue([{ workerId: "w-1", runIds: ["run-1"] }]); + mockRuntimeHostService.listWorkersForAdmin = vi.fn().mockResolvedValue({ + list: [{ workerId: "w-1", runIds: ["run-1"] }], + }); const detail = await service.getDetailForAdmin("run-1"); diff --git a/apps/server/src/run/run.service.ts b/apps/server/src/run/run.service.ts index 0d086514..5e8dc7c5 100644 --- a/apps/server/src/run/run.service.ts +++ b/apps/server/src/run/run.service.ts @@ -10,16 +10,11 @@ import { import { generateId } from "@agework/shared"; import type { Response } from "express"; import { swallow } from "../common/swallow"; -import type { - RuntimeHostDiagnostics, - RuntimeHostExecution, -} from "@agework/shared/protocol"; +import type { RuntimeHostExecution } from "@agework/shared/protocol"; import { RunRepository } from "./run.repository"; import { LiveRunRegistry } from "./live-run/live-run.registry"; -import { - RUNTIME_HOST_DIAGNOSTICS, - RUNTIME_HOST_EXECUTION, -} from "../runtime-host/runtime-host.types"; +import { RUNTIME_HOST_EXECUTION } from "../runtime-host/runtime-host.types"; +import { RuntimeHostService } from "../runtime-host/runtime-host.service"; import { type IncompleteMessageReason } from "./upstream/assistant-message.aggregator"; import { RunEventService } from "../run-event/run-event.service"; import { RunStatusService } from "./status/run-status.service"; @@ -38,8 +33,7 @@ export class RunService implements OnApplicationBootstrap { private readonly liveRuns: LiveRunRegistry, @Inject(RUNTIME_HOST_EXECUTION) private readonly runtimeHost: RuntimeHostExecution, - @Inject(RUNTIME_HOST_DIAGNOSTICS) - private readonly runtimeHostDiagnostics: RuntimeHostDiagnostics, + private readonly runtimeHosts: RuntimeHostService, private readonly runEvents: RunEventService, private readonly runStatusService: RunStatusService, private readonly runLauncher: RunLauncher, @@ -78,7 +72,7 @@ export class RunService implements OnApplicationBootstrap { if (!row) { throw new NotFoundException(`Run ${id} 不存在`); } - const workers = await this.runtimeHostDiagnostics.listWorkers(); + const { list: workers } = await this.runtimeHosts.listWorkersForAdmin(); const worker = workers.find((w) => w.runIds.includes(id)) ?? null; return { ...toAdminRunDetail(row), worker }; } diff --git a/apps/server/src/run/user/run-user.listener.spec.ts b/apps/server/src/run/user/run-user.listener.spec.ts index d1c162b4..3cf9d341 100644 --- a/apps/server/src/run/user/run-user.listener.spec.ts +++ b/apps/server/src/run/user/run-user.listener.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { RuntimeHostResourceReconciliationPort } from "../../runtime-host/runtime-host.types"; +import type { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import type { RunService } from "../run.service"; import { UserDisabledEvent, UserDeletedEvent } from "../../user/user.events"; import { RunUserListener } from "./run-user.listener"; @@ -10,7 +10,7 @@ function makeHostResources(overrides: Record = {}) { listConnectedHostIds: vi.fn().mockReturnValue(["builtin"]), releaseResources: vi.fn().mockResolvedValue(undefined), ...overrides, - } as unknown as RuntimeHostResourceReconciliationPort; + } as unknown as RuntimeHostService; } function makeDeps( @@ -23,8 +23,12 @@ function makeDeps( const stopForUser = overrides.stopForUser ?? vi.fn().mockResolvedValue(undefined); const hostResources = makeHostResources({ - ...(overrides.releaseResources && { releaseResources: overrides.releaseResources }), - ...(overrides.listConnectedHostIds && { listConnectedHostIds: overrides.listConnectedHostIds }), + ...(overrides.releaseResources && { + releaseResources: overrides.releaseResources, + }), + ...(overrides.listConnectedHostIds && { + listConnectedHostIds: overrides.listConnectedHostIds, + }), }); const listener = new RunUserListener( { stopForUser } as unknown as RunService, @@ -43,9 +47,7 @@ describe("RunUserListener", () => { releaseResources: vi.fn(async () => { order.push("release"); }), - listConnectedHostIds: vi - .fn() - .mockReturnValue(["rt-1", "rt-2"]), + listConnectedHostIds: vi.fn().mockReturnValue(["rt-1", "rt-2"]), }); await listener.onUserDeactivated(new UserDisabledEvent("user-1", 5)); @@ -103,9 +105,7 @@ describe("RunUserListener", () => { // P0 fix: in-flight submit may not have formed a claim yet. // Must send release to all connected hosts to install fence. const { listener, hostResources } = makeDeps({ - listConnectedHostIds: vi - .fn() - .mockReturnValue(["rt-1", "rt-2", "rt-3"]), + listConnectedHostIds: vi.fn().mockReturnValue(["rt-1", "rt-2", "rt-3"]), }); await listener.onUserDeactivated(new UserDisabledEvent("user-1", 2)); diff --git a/apps/server/src/run/user/run-user.listener.ts b/apps/server/src/run/user/run-user.listener.ts index d4280a63..adb76da4 100644 --- a/apps/server/src/run/user/run-user.listener.ts +++ b/apps/server/src/run/user/run-user.listener.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; import { USER_DELETED_EVENT, @@ -6,10 +6,7 @@ import { UserDeletedEvent, UserDisabledEvent, } from "../../user/user.events"; -import { - RUNTIME_HOST_RESOURCE_RECONCILIATION, - type RuntimeHostResourceReconciliationPort, -} from "../../runtime-host/runtime-host.types"; +import { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import { RunService } from "../run.service"; /** @@ -36,8 +33,7 @@ export class RunUserListener { constructor( private readonly runService: RunService, - @Inject(RUNTIME_HOST_RESOURCE_RECONCILIATION) - private readonly hostResources: RuntimeHostResourceReconciliationPort + private readonly runtimeHosts: RuntimeHostService ) {} @OnEvent([USER_DISABLED_EVENT, USER_DELETED_EVENT]) @@ -59,13 +55,9 @@ export class RunUserListener { // Step 2: 对所有在线 Host 下发 releaseResources(user target) // 必须覆盖所有在线 Host,因为 in-flight submit 可能尚未在 Runtime 形成 claim try { - const hostIds = this.hostResources.listConnectedHostIds(); + const hostIds = this.runtimeHosts.listConnectedHostIds(); for (const runtimeHostId of hostIds) { - await this.releaseUserResources( - runtimeHostId, - userId, - sessionVersion - ); + await this.releaseUserResources(runtimeHostId, userId, sessionVersion); } } catch (err) { this.logger.warn( @@ -82,7 +74,7 @@ export class RunUserListener { userLifecycleVersion: number ): Promise { try { - await this.hostResources.releaseResources({ + await this.runtimeHosts.releaseResources({ runtimeHostId, target: { type: "user", userId, userLifecycleVersion }, }); diff --git a/apps/server/src/run/workspace/run-workspace.listener.spec.ts b/apps/server/src/run/workspace/run-workspace.listener.spec.ts index fccff77f..fa0b31bb 100644 --- a/apps/server/src/run/workspace/run-workspace.listener.spec.ts +++ b/apps/server/src/run/workspace/run-workspace.listener.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { RunService } from "../run.service"; -import type { RuntimeHostResourceReconciliationPort } from "../../runtime-host/runtime-host.types"; +import type { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import { WorkspaceDeletedEvent } from "../../workspace/workspace.events"; import { RunWorkspaceListener } from "./run-workspace.listener"; @@ -19,7 +19,7 @@ function makeDeps( { releaseResources, listLifecycleClaims: vi.fn().mockResolvedValue([]), - } as unknown as RuntimeHostResourceReconciliationPort + } as unknown as RuntimeHostService ); return { listener, stopForWorkspace, releaseResources }; } diff --git a/apps/server/src/run/workspace/run-workspace.listener.ts b/apps/server/src/run/workspace/run-workspace.listener.ts index 1e665642..3f241e37 100644 --- a/apps/server/src/run/workspace/run-workspace.listener.ts +++ b/apps/server/src/run/workspace/run-workspace.listener.ts @@ -1,13 +1,10 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { OnEvent } from "@nestjs/event-emitter"; import { WORKSPACE_DELETED_EVENT, WorkspaceDeletedEvent, } from "../../workspace/workspace.events"; -import { - RUNTIME_HOST_RESOURCE_RECONCILIATION, - type RuntimeHostResourceReconciliationPort, -} from "../../runtime-host/runtime-host.types"; +import { RuntimeHostService } from "../../runtime-host/runtime-host.service"; import { RunService } from "../run.service"; /** @@ -24,8 +21,7 @@ export class RunWorkspaceListener { constructor( private readonly runService: RunService, - @Inject(RUNTIME_HOST_RESOURCE_RECONCILIATION) - private readonly hostResources: RuntimeHostResourceReconciliationPort + private readonly runtimeHosts: RuntimeHostService ) {} @OnEvent(WORKSPACE_DELETED_EVENT) @@ -43,7 +39,7 @@ export class RunWorkspaceListener { ); } try { - await this.hostResources.releaseResources({ + await this.runtimeHosts.releaseResources({ runtimeHostId, target: { type: "workspace", workspaceId }, }); diff --git a/apps/server/src/runtime-host/contract/runtime-host.adapter.ts b/apps/server/src/runtime-host/contract/runtime-host.adapter.ts index 619e9075..819d6cba 100644 --- a/apps/server/src/runtime-host/contract/runtime-host.adapter.ts +++ b/apps/server/src/runtime-host/contract/runtime-host.adapter.ts @@ -35,9 +35,6 @@ import { isBuiltinHostId, type HostRunReapBinding, type HostRunReapPort, - type RuntimeHostConnectivity, - type RuntimeHostResourceReconciliationPort, - type RuntimeHostRunReconciliation, } from "../runtime-host.types"; import { RunEventService } from "../../run-event/run-event.service"; import { BUILTIN_RUNTIME_HOST } from "./builtin-runtime-host"; @@ -62,12 +59,7 @@ import { */ @Injectable() export class RuntimeHostAdapter - implements - RuntimeHostContract, - RuntimeHostResourceReconciliationPort, - RuntimeHostRunReconciliation, - RuntimeHostConnectivity, - HostRunReapBinding + implements RuntimeHostContract, HostRunReapBinding { private readonly logger = new Logger(RuntimeHostAdapter.name); private upstream!: RuntimeHostUpstream; @@ -263,9 +255,9 @@ export class RuntimeHostAdapter // SPEC §5.3: 查询失败必须显式失败,不能折叠为空列表。 // 不再 catch 单 Host 错误——让其传播给调用方。 const tunnelClaims = await Promise.all( - this.tunnelHandler.listConnected().map((hostId) => - this.tunnelHost.listLifecycleClaimsOn(hostId) - ) + this.tunnelHandler + .listConnected() + .map((hostId) => this.tunnelHost.listLifecycleClaimsOn(hostId)) ); return builtin.concat(tunnelClaims.flat()); } diff --git a/apps/server/src/runtime-host/runtime-host.module.spec.ts b/apps/server/src/runtime-host/runtime-host.module.spec.ts index 3dfe43c6..a1af2896 100644 --- a/apps/server/src/runtime-host/runtime-host.module.spec.ts +++ b/apps/server/src/runtime-host/runtime-host.module.spec.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Module } from "@nestjs/common"; +import { Injectable, Module } from "@nestjs/common"; import { EventEmitterModule } from "@nestjs/event-emitter"; import { Test, type TestingModule } from "@nestjs/testing"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,18 +12,10 @@ import { BUILTIN_RUNTIME_HOST, BUILTIN_RUNTIME_HOST_LIFECYCLE, } from "./contract/builtin-runtime-host"; -import { - RUNTIME_HOST_RESOURCE_RECONCILIATION, - type RuntimeHostResourceReconciliationPort, -} from "./runtime-host.types"; @Injectable() class DownstreamRuntimeConsumer { - constructor( - readonly runtimeHostService: RuntimeHostService, - @Inject(RUNTIME_HOST_RESOURCE_RECONCILIATION) - readonly resourceReconciliation: RuntimeHostResourceReconciliationPort - ) {} + constructor(readonly runtimeHostService: RuntimeHostService) {} } @Module({ @@ -49,7 +41,7 @@ describe("RuntimeHostModule wiring", () => { ); }); - it("exports the root Service and narrow role ports to downstream modules", async () => { + it("exports the root Service to downstream modules", async () => { testingModule = await createRuntimeTestingModule([ DownstreamRuntimeConsumerModule, ]); @@ -58,7 +50,6 @@ describe("RuntimeHostModule wiring", () => { expect(consumer.runtimeHostService).toBe( testingModule.get(RuntimeHostService) ); - expect(consumer.resourceReconciliation).toBeDefined(); }); }); diff --git a/apps/server/src/runtime-host/runtime-host.module.ts b/apps/server/src/runtime-host/runtime-host.module.ts index ca8a03da..0b731fdc 100644 --- a/apps/server/src/runtime-host/runtime-host.module.ts +++ b/apps/server/src/runtime-host/runtime-host.module.ts @@ -15,15 +15,9 @@ import { } from "./contract/builtin-runtime-host"; import { RunEventModule } from "../run-event/run-event.module"; import { - RUNTIME_HOST_DIAGNOSTICS, - RUNTIME_HOST_CONNECTIVITY, - RUNTIME_HOST_ENVIRONMENT, RUNTIME_HOST_EXECUTION, - RUNTIME_HOST_RESOURCE_RECONCILIATION, - RUNTIME_HOST_RUN_RECONCILIATION, RUNTIME_HOST_RUN_REAP_BINDING, RUNTIME_HOST_UPSTREAM_BINDING, - RUNTIME_HOST_WORKSPACE_DATA, } from "./runtime-host.types"; /** @@ -32,9 +26,8 @@ import { * * - `HostTunnelHandler`(隧道 WS 端点)、`HostLivenessWatchdog`(Host 级判死)、 * builtin Host 实例都是 internal provider,不 export。 - * - `RuntimeHostAdapter` 类本身不直接 export,而是按 execution / upstream-binding / - * connectivity / environment / workspace-data / diagnostics 角色 token 暴露契约; - * 消费者不感知 builtin/registered 的路由细节。 + * - `RuntimeHostAdapter` 类本身不 export;业务用例经根 `RuntimeHostService`,run + * 执行与两个启动期反向接线保留窄 token。 * - worker 数据面由每个 Host 自己的 WorkerHttpServer 承接;worker 池由 Host * 进程内自治,本模块只经契约下发与观测。 * - 资源生命周期判断归 workspace / user owner;重连同步用例由上层 run @@ -54,18 +47,6 @@ import { RuntimeHostAdapter, { provide: RUNTIME_HOST_EXECUTION, useExisting: RuntimeHostAdapter }, { provide: RUNTIME_HOST_UPSTREAM_BINDING, useExisting: RuntimeHostAdapter }, - { provide: RUNTIME_HOST_CONNECTIVITY, useExisting: RuntimeHostAdapter }, - { provide: RUNTIME_HOST_ENVIRONMENT, useExisting: RuntimeHostAdapter }, - { provide: RUNTIME_HOST_WORKSPACE_DATA, useExisting: RuntimeHostAdapter }, - { provide: RUNTIME_HOST_DIAGNOSTICS, useExisting: RuntimeHostAdapter }, - { - provide: RUNTIME_HOST_RESOURCE_RECONCILIATION, - useExisting: RuntimeHostAdapter, - }, - { - provide: RUNTIME_HOST_RUN_RECONCILIATION, - useExisting: RuntimeHostAdapter, - }, { provide: RUNTIME_HOST_RUN_REAP_BINDING, useExisting: RuntimeHostAdapter, @@ -80,10 +61,6 @@ import { RuntimeHostService, RUNTIME_HOST_EXECUTION, RUNTIME_HOST_UPSTREAM_BINDING, - // environment / workspace-data 仅本模块 RuntimeHostService 消费,不对外导出 - RUNTIME_HOST_DIAGNOSTICS, - RUNTIME_HOST_RESOURCE_RECONCILIATION, - RUNTIME_HOST_RUN_RECONCILIATION, RUNTIME_HOST_RUN_REAP_BINDING, ], }) diff --git a/apps/server/src/runtime-host/runtime-host.service.spec.ts b/apps/server/src/runtime-host/runtime-host.service.spec.ts index 38dc0e1b..9a42aa8c 100644 --- a/apps/server/src/runtime-host/runtime-host.service.spec.ts +++ b/apps/server/src/runtime-host/runtime-host.service.spec.ts @@ -1,9 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { RuntimeHostRepository } from "./runtime-host.repository"; -import type { - RuntimeHostConnectivity, - RuntimeHostRow, -} from "./runtime-host.types"; +import type { RuntimeHostRow } from "./runtime-host.types"; import { RuntimeHostService } from "./runtime-host.service"; import type { RuntimeHostDiagnostics, @@ -135,12 +132,17 @@ describe("RuntimeHostService", () => { listLifecycleClaims: vi.fn().mockResolvedValue([]), stopWorker: vi.fn().mockResolvedValue(undefined), }; + const host = { + ...connectivity, + ...environment, + ...workspaceData, + ...diagnostics, + releaseResources: vi.fn().mockResolvedValue(undefined), + listConnectedHostIds: vi.fn().mockReturnValue(["builtin"]), + }; service = new RuntimeHostService( repository as unknown as RuntimeHostRepository, - connectivity as unknown as RuntimeHostConnectivity, - environment as unknown as RuntimeHostEnvironment, - workspaceData as unknown as RuntimeHostWorkspaceData, - diagnostics as unknown as RuntimeHostDiagnostics, + host as never, { getSessionEpoch: vi.fn(), markReconciled: vi.fn(), diff --git a/apps/server/src/runtime-host/runtime-host.service.ts b/apps/server/src/runtime-host/runtime-host.service.ts index ae10a2a7..3a82d70e 100644 --- a/apps/server/src/runtime-host/runtime-host.service.ts +++ b/apps/server/src/runtime-host/runtime-host.service.ts @@ -2,7 +2,6 @@ import { BadGatewayException, BadRequestException, ConflictException, - Inject, Injectable, Logger, NotFoundException, @@ -11,9 +10,8 @@ import { import { createHash, randomBytes } from "node:crypto"; import { type AgentType } from "@agework/shared"; import type { - RuntimeHostDiagnostics, - RuntimeHostEnvironment, - RuntimeHostWorkspaceData, + ReleaseRuntimeResourcesInput, + RuntimeLifecycleClaim, WorkerSnapshot, } from "@agework/shared/protocol"; import { normalizeRuntimeCapabilities } from "@agework/shared/protocol"; @@ -36,19 +34,15 @@ import { RuntimeHostRepository } from "./runtime-host.repository"; import { BUILTIN_HOST_ID, isBuiltinHostId, - RUNTIME_HOST_CONNECTIVITY, - RUNTIME_HOST_DIAGNOSTICS, - RUNTIME_HOST_ENVIRONMENT, - RUNTIME_HOST_WORKSPACE_DATA, - type RuntimeHostConnectivity, type RuntimeHostRow, } from "./runtime-host.types"; import { resolveRuntimeHostCliPaths } from "./environment/runtime-host-environment"; import { HostTunnelHandler } from "./gateway/host-tunnel.handler"; +import { RuntimeHostAdapter } from "./contract/runtime-host.adapter"; /** - * RuntimeHost 根门面:管理注册、placement 与环境结果持久化,并编排 Host 用例。 - * 所有执行机动作统一经最小 RuntimeHost 角色端口;本类不自行访问执行机或拼隧道 RPC。 + * RuntimeHost 根门面:管理注册、placement 与环境结果持久化,并编排 Host 用例。 + * 所有执行机动作统一委托 RuntimeHostAdapter;本类不自行访问执行机或拼隧道 RPC。 * * 注意:resolveRuntimeSpec 已删除(SPEC §10.2),由 Runtime SDK 直接处理。 */ @@ -58,20 +52,13 @@ export class RuntimeHostService implements OnApplicationBootstrap { constructor( private readonly repository: RuntimeHostRepository, - @Inject(RUNTIME_HOST_CONNECTIVITY) - private readonly connectivity: RuntimeHostConnectivity, - @Inject(RUNTIME_HOST_ENVIRONMENT) - private readonly environment: RuntimeHostEnvironment, - @Inject(RUNTIME_HOST_WORKSPACE_DATA) - private readonly workspaceData: RuntimeHostWorkspaceData, - @Inject(RUNTIME_HOST_DIAGNOSTICS) - private readonly diagnostics: RuntimeHostDiagnostics, + private readonly host: RuntimeHostAdapter, private readonly tunnelHandler: HostTunnelHandler ) {} /** 启动时只初始化一行 builtin Host,并持久化 Host 报告的完整能力矩阵。 */ async onApplicationBootstrap(): Promise { - const capabilities = await this.environment.detectEnv(BUILTIN_HOST_ID); + const capabilities = await this.host.detectEnv(BUILTIN_HOST_ID); await this.repository.upsertBuiltin({ name: BUILTIN_HOST_ID, capabilities, @@ -92,11 +79,33 @@ export class RuntimeHostService implements OnApplicationBootstrap { return isBuiltinHostId(runtimeHostId); } -/** builtin Host 的固定 id(不查库,和 runtimeType 无关)。 */ + /** builtin Host 的固定 id(不查库,和 runtimeType 无关)。 */ getBuiltinHostId(): string { return BUILTIN_HOST_ID; } + /** 对目标 Host 释放 workspace/user 生命周期资源。 */ + releaseResources(input: ReleaseRuntimeResourcesInput): Promise { + return this.host.releaseResources(input); + } + + /** 查询单个或全部在线 Host 的生命周期 claims。 */ + listLifecycleClaims( + runtimeHostId?: string + ): Promise { + return this.host.listLifecycleClaims(runtimeHostId); + } + + /** 列出当前在线的 Host id,包含 builtin。 */ + listConnectedHostIds(): string[] { + return this.host.listConnectedHostIds(); + } + + /** 查询目标 Host 上仍占用的 run id,供恢复对账使用。 */ + listRunIds(runtimeHostId: string): Promise { + return this.host.listRunIds(runtimeHostId); + } + /** 判断给定 epoch 是否仍是该 registered Host 的当前 tunnel session。 */ isCurrentReconciliationEpoch(runtimeHostId: string, epoch: number): boolean { return this.tunnelHandler.getSessionEpoch(runtimeHostId) === epoch; @@ -112,7 +121,7 @@ export class RuntimeHostService implements OnApplicationBootstrap { return this.tunnelHandler.markReconcileFailed(runtimeHostId, epoch); } -/** 创建 registered Runtime Host 并生成配对 token。token 明文只在本次响应出现,库里只存 sha256。 */ + /** 创建 registered Runtime Host 并生成配对 token。token 明文只在本次响应出现,库里只存 sha256。 */ async create( ownerId: string, name: string @@ -146,30 +155,30 @@ export class RuntimeHostService implements OnApplicationBootstrap { /** admin: 现场查询所有 Host(builtin + registered)上的 worker 快照,不入库。 */ async listWorkersForAdmin(): Promise<{ list: WorkerSnapshot[] }> { - return { list: await this.diagnostics.listWorkers() }; + return { list: await this.host.listWorkers() }; } -/** - * admin: 按 runtimeHostId 定向停止目标 Host 上的一个 worker。 - * 目标 Host 离线/超时是预期失败,按上游不可达(502)报告。 - */ -async stopWorkerForAdmin( - runtimeHostId: string, - workerId: string -): Promise { - try { - await this.diagnostics.stopWorker({ - runtimeHostId, - workerId, - }); - } catch (err) { - throw new BadGatewayException( - `stop worker failed on host ${runtimeHostId}: ${ - err instanceof Error ? err.message : String(err) - }` - ); + /** + * admin: 按 runtimeHostId 定向停止目标 Host 上的一个 worker。 + * 目标 Host 离线/超时是预期失败,按上游不可达(502)报告。 + */ + async stopWorkerForAdmin( + runtimeHostId: string, + workerId: string + ): Promise { + try { + await this.host.stopWorker({ + runtimeHostId, + workerId, + }); + } catch (err) { + throw new BadGatewayException( + `stop worker failed on host ${runtimeHostId}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } } -} /** * 查询 Host 是否存在且对该用户可见(自己的 registered 或全局 builtin,且未注销); @@ -237,12 +246,12 @@ async stopWorkerForAdmin( if (!row) { throw new NotFoundException(`runtime host not found: ${runtimeHostId}`); } - if (!this.connectivity.isConnected(runtimeHostId)) { + if (!this.host.isConnected(runtimeHostId)) { throw new BadRequestException( `runtime host ${runtimeHostId} is not connected` ); } - const { executablePath } = await this.environment.installCli({ + const { executablePath } = await this.host.installCli({ runtimeHostId, agentType, }); @@ -266,12 +275,12 @@ async stopWorkerForAdmin( throw new NotFoundException(`runtime host not found: ${runtimeHostId}`); } // registered 未连接:不是错误,只是此刻无法检测(前端按离线展示) - if (!this.connectivity.isConnected(runtimeHostId)) { + if (!this.host.isConnected(runtimeHostId)) { return { envConfig: null }; } let envConfig: RuntimeEnvConfig | undefined; try { - envConfig = (await this.environment.detectEnv(runtimeHostId)).native?.cli; + envConfig = (await this.host.detectEnv(runtimeHostId)).native?.cli; } catch (err) { // 检测/RPC 失败与「未检出 CLI」是两回事,失败要让调用方看见 throw new BadRequestException( @@ -295,7 +304,7 @@ async stopWorkerForAdmin( ): Promise { await this.assertRuntimeHostReachable(ownerId, runtimeHostId); try { - const result = await this.workspaceData.listDirectory({ + const result = await this.host.listDirectory({ runtimeHostId, path, }); @@ -315,7 +324,7 @@ async stopWorkerForAdmin( ): Promise { await this.assertRuntimeHostReachable(ownerId, runtimeHostId); try { - await this.workspaceData.createDirectory({ runtimeHostId, path }); + await this.host.createDirectory({ runtimeHostId, path }); return { path }; } catch (err) { throw new BadRequestException( @@ -336,7 +345,7 @@ async stopWorkerForAdmin( relativePath: string ): Promise { try { - return await this.workspaceData.listFiles({ + return await this.host.listFiles({ runtimeHostId, rootPath, path: relativePath, @@ -355,7 +364,7 @@ async stopWorkerForAdmin( relativePath: string ): Promise { try { - return await this.workspaceData.readFile({ + return await this.host.readFile({ runtimeHostId, rootPath, path: relativePath, @@ -378,7 +387,7 @@ async stopWorkerForAdmin( rootPath: string ): Promise { try { - return await this.workspaceData.listChangedFiles({ + return await this.host.listChangedFiles({ runtimeHostId, rootPath, }); @@ -394,7 +403,7 @@ async stopWorkerForAdmin( relativePath: string ): Promise { try { - return await this.workspaceData.readFileDiff({ + return await this.host.readFileDiff({ runtimeHostId, rootPath, path: relativePath, @@ -410,7 +419,7 @@ async stopWorkerForAdmin( rootPath: string ): Promise { try { - return await this.workspaceData.searchFiles({ runtimeHostId, rootPath }); + return await this.host.searchFiles({ runtimeHostId, rootPath }); } catch (err) { throw new BadRequestException( err instanceof Error ? err.message : String(err) @@ -433,7 +442,7 @@ async stopWorkerForAdmin( if (!owned) { throw new NotFoundException(`runtime host not found: ${runtimeHostId}`); } - if (!this.connectivity.isConnected(runtimeHostId)) { + if (!this.host.isConnected(runtimeHostId)) { throw new BadRequestException( `runtime host ${runtimeHostId} is not connected` ); diff --git a/apps/server/src/runtime-host/runtime-host.types.ts b/apps/server/src/runtime-host/runtime-host.types.ts index 1f81ecff..c00b0812 100644 --- a/apps/server/src/runtime-host/runtime-host.types.ts +++ b/apps/server/src/runtime-host/runtime-host.types.ts @@ -1,10 +1,4 @@ -import type { - HostUpstreamNotification, - ReleaseRuntimeResourcesInput, - RuntimeHostResourceLifecycle, - RuntimeHostResourceReconciliation, - RuntimeLifecycleClaim, -} from "@agework/shared/protocol"; +import type { HostUpstreamNotification } from "@agework/shared/protocol"; /** * host.upstream 回流 Port(架构规则 §4 决策链 5:infra 运行时回流): @@ -60,30 +54,6 @@ export type RuntimeHostRow = { removedAt: Date | null; }; -/** Workspace/User 删除与 Host 重连对账使用的资源生命周期端口(替代旧 OwnerReconciliation)。 */ -export type RuntimeHostResourceLifecyclePort = RuntimeHostResourceLifecycle; - -/** Server 重连对账使用的资源 reconciliation 端口。 */ -export type RuntimeHostResourceReconciliationPort = - RuntimeHostResourceReconciliation & { - /** runtimeHostId 省略时聚合所有在线 Host;指定时只查询目标 Host。 */ - listLifecycleClaims( - runtimeHostId?: string - ): Promise; - /** 列出所有当前在线的 Host id(含 builtin)。 */ - listConnectedHostIds(): string[]; - }; - -/** Run 恢复对账只需要 Host 上仍被占用的 runId,不向业务模块暴露 Worker 快照。 */ -export interface RuntimeHostRunReconciliation { - listRunIds(runtimeHostId: string): Promise; -} - -/** Server 侧查询 Host 控制面是否可达;builtin 永远可达,registered 取决于隧道。 */ -export interface RuntimeHostConnectivity { - isConnected(runtimeHostId: string): boolean; -} - /** builtin(本机 in-process)RuntimeHost 的固定 id。所有 runtimeType 都走这一个 Host。 */ export const BUILTIN_HOST_ID = "builtin"; @@ -92,21 +62,11 @@ export function isBuiltinHostId(runtimeHostId: string): boolean { return runtimeHostId === BUILTIN_HOST_ID; } -/** 同一个 Host 路由适配器按角色暴露,消费者不能越面调用。 */ +/** 执行与启动期反向接线保留窄 token;其余用例统一经 RuntimeHostService。 */ export const RUNTIME_HOST_EXECUTION = Symbol("RuntimeHostExecution"); export const RUNTIME_HOST_UPSTREAM_BINDING = Symbol( "RuntimeHostUpstreamBinding" ); -export const RUNTIME_HOST_CONNECTIVITY = Symbol("RuntimeHostConnectivity"); -export const RUNTIME_HOST_ENVIRONMENT = Symbol("RuntimeHostEnvironment"); -export const RUNTIME_HOST_WORKSPACE_DATA = Symbol("RuntimeHostWorkspaceData"); -export const RUNTIME_HOST_DIAGNOSTICS = Symbol("RuntimeHostDiagnostics"); -export const RUNTIME_HOST_RESOURCE_RECONCILIATION = Symbol( - "RuntimeHostResourceReconciliation" -); -export const RUNTIME_HOST_RUN_RECONCILIATION = Symbol( - "RuntimeHostRunReconciliation" -); export const RUNTIME_HOST_RUN_REAP_BINDING = Symbol( "RuntimeHostRunReapBinding" ); diff --git a/apps/server/src/user/user.service.ts b/apps/server/src/user/user.service.ts index d5579725..dc232357 100644 --- a/apps/server/src/user/user.service.ts +++ b/apps/server/src/user/user.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, ForbiddenException, - Inject, Injectable, NotFoundException, UnauthorizedException, @@ -40,10 +39,7 @@ import { type UserRecord, type UserSessionRecord, } from "./user.repository"; -import { - RUNTIME_HOST_RESOURCE_RECONCILIATION, - type RuntimeHostResourceReconciliationPort, -} from "../runtime-host/runtime-host.types"; +import { RuntimeHostService } from "../runtime-host/runtime-host.service"; const INITIAL_PASSWORD_TTL_MS = 72 * 60 * 60 * 1000; const RESET_PASSWORD_TTL_MS = 24 * 60 * 60 * 1000; @@ -58,8 +54,7 @@ export class UserService { private users: UserRepository, private passwordHasher: PasswordHasherService, private events: EventEmitter2, - @Inject(RUNTIME_HOST_RESOURCE_RECONCILIATION) - private readonly hostResources: RuntimeHostResourceReconciliationPort + private readonly runtimeHosts: RuntimeHostService ) {} /** @@ -90,7 +85,7 @@ export class UserService { let firstError: unknown; for (const { userId, version } of targets.values()) { try { - await this.hostResources.releaseResources({ + await this.runtimeHosts.releaseResources({ runtimeHostId, target: { type: "user", @@ -630,10 +625,7 @@ function pendingUserReleaseTargets( ): Array<{ userId: string; version: number }> { const targets: Array<{ userId: string; version: number }> = []; for (const claim of claims) { - if ( - claim.kind !== "release_pending" || - claim.target.type !== "user" - ) { + if (claim.kind !== "release_pending" || claim.target.type !== "user") { continue; } if (claim.userLifecycleVersion === undefined) { diff --git a/apps/server/src/workspace/workspace-reconciliation.spec.ts b/apps/server/src/workspace/workspace-reconciliation.spec.ts index db0a7da4..a436ff02 100644 --- a/apps/server/src/workspace/workspace-reconciliation.spec.ts +++ b/apps/server/src/workspace/workspace-reconciliation.spec.ts @@ -16,7 +16,6 @@ describe("WorkspaceService runtime reconciliation", () => { {} as never, {} as never, {} as never, - {} as never, { releaseResources } as never ); diff --git a/apps/server/src/workspace/workspace.service.spec.ts b/apps/server/src/workspace/workspace.service.spec.ts index 752381f7..5bf00ebc 100644 --- a/apps/server/src/workspace/workspace.service.spec.ts +++ b/apps/server/src/workspace/workspace.service.spec.ts @@ -134,6 +134,7 @@ function makeRuntimeHostService(overrides: Record = {}) { before: "old", after: "new", }), + releaseResources: vi.fn().mockResolvedValue(undefined), ...overrides, }; } @@ -157,12 +158,7 @@ function makeService( { emit: vi.fn() } as never, runtimePolicy, directoryHandler, - runtimeHostService as never, - { - listLifecycleClaims: vi.fn().mockResolvedValue([]), - listConnectedHostIds: vi.fn().mockReturnValue([]), - releaseResources: vi.fn().mockResolvedValue(undefined), - } as never + runtimeHostService as never ); } @@ -730,12 +726,7 @@ describe("WorkspaceService", () => { config as never, runtimePolicy ), - makeRuntimeHostService() as never, - { - listLifecycleClaims: vi.fn().mockResolvedValue([]), - listConnectedHostIds: vi.fn().mockReturnValue([]), - releaseResources: vi.fn().mockResolvedValue(undefined), - } as never + makeRuntimeHostService() as never ); await service.delete(userId, workspaceId); @@ -765,7 +756,13 @@ describe("WorkspaceService", () => { scope: "workspace", runtimeHostId: "builtin", directory: { rootPath: "/tmp/ws" }, - user: { id: "user-1", username: "mew", status: "active", deletedAt: null, sessionVersion: 1 }, + user: { + id: "user-1", + username: "mew", + status: "active", + deletedAt: null, + sessionVersion: 1, + }, }), }); const service = makeService(repo, makeConfig()); @@ -794,7 +791,13 @@ describe("WorkspaceService", () => { scope: "workspace", runtimeHostId: "builtin", directory: { rootPath: "/tmp/ws" }, - user: { id: "user-1", username: "mew", status: "active", deletedAt: null, sessionVersion: 1 }, + user: { + id: "user-1", + username: "mew", + status: "active", + deletedAt: null, + sessionVersion: 1, + }, }), }); const service = makeService(repo, makeConfig()); @@ -813,7 +816,13 @@ describe("WorkspaceService", () => { scope: "workspace", runtimeHostId: "rt-1", directory: { rootPath: "/remote/ws" }, - user: { id: "user-1", username: "mew", status: "active", deletedAt: null, sessionVersion: 1 }, + user: { + id: "user-1", + username: "mew", + status: "active", + deletedAt: null, + sessionVersion: 1, + }, }), }); const service = makeService(repo, makeConfig()); @@ -895,7 +904,13 @@ describe("WorkspaceService", () => { scope: "workspace", runtimeHostId: "builtin", directory: { rootPath: "/tmp/ws" }, - user: { id: "user-1", username: "mew", status: "active", deletedAt: null, sessionVersion: 1 }, + user: { + id: "user-1", + username: "mew", + status: "active", + deletedAt: null, + sessionVersion: 1, + }, }; } @@ -906,7 +921,13 @@ describe("WorkspaceService", () => { scope: "workspace", runtimeHostId: "rt-1", directory: { rootPath: "/remote/ws" }, - user: { id: "user-1", username: "mew", status: "active", deletedAt: null, sessionVersion: 1 }, + user: { + id: "user-1", + username: "mew", + status: "active", + deletedAt: null, + sessionVersion: 1, + }, }; } diff --git a/apps/server/src/workspace/workspace.service.ts b/apps/server/src/workspace/workspace.service.ts index a6cd59b3..fa71889e 100644 --- a/apps/server/src/workspace/workspace.service.ts +++ b/apps/server/src/workspace/workspace.service.ts @@ -1,6 +1,5 @@ import { BadRequestException, - Inject, Injectable, InternalServerErrorException, NotFoundException, @@ -29,10 +28,6 @@ import type { RuntimeLifecycleClaim, WorkerScope, } from "@agework/shared/protocol"; -import { - RUNTIME_HOST_RESOURCE_RECONCILIATION, - type RuntimeHostResourceReconciliationPort, -} from "../runtime-host/runtime-host.types"; import type { WorkspaceFileListResponse, WorkspaceFileReadResponse, @@ -73,9 +68,7 @@ export class WorkspaceService { private readonly events: EventEmitter2, private readonly runtimePolicy: WorkspaceRuntimePolicy, private readonly directoryHandler: WorkspaceDirectoryHandler, - private readonly runtimeHostService: RuntimeHostService, - @Inject(RUNTIME_HOST_RESOURCE_RECONCILIATION) - private readonly hostResources: RuntimeHostResourceReconciliationPort + private readonly runtimeHostService: RuntimeHostService ) {} /** @@ -128,7 +121,9 @@ export class WorkspaceService { } // DB 状态是权威:User 必须 active 且未删除才能启动新 run if (workspace.user.status !== "active" || workspace.user.deletedAt) { - throw new BadRequestException("工作空间属主用户已禁用或删除,无法启动 run"); + throw new BadRequestException( + "工作空间属主用户已禁用或删除,无法启动 run" + ); } return { workspaceId: workspace.id, @@ -164,7 +159,7 @@ export class WorkspaceService { let firstError: unknown; for (const workspaceId of targets) { try { - await this.hostResources.releaseResources({ + await this.runtimeHostService.releaseResources({ runtimeHostId, target: { type: "workspace", workspaceId }, }); @@ -653,10 +648,7 @@ function distinctPendingWorkspaceReleases( ): string[] { const ids = new Set(); for (const claim of claims) { - if ( - claim.kind === "release_pending" && - claim.target.type === "workspace" - ) { + if (claim.kind === "release_pending" && claim.target.type === "workspace") { ids.add(claim.target.workspaceId); } } From aa1ec4f48c8f14179350d9481233141282bf0526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 00:55:01 +0800 Subject: [PATCH 05/18] docs: consolidate runtime architecture source of truth --- .scratch/deps-upgrade-report.md | 119 ---- .scratch/package-build-consolidation/final.md | 65 -- .../research-plan.md | 28 - .../package-build-consolidation/working.md | 204 ------ .../runtime-owner-boundary/research-plan.md | 26 - .scratch/runtime-owner-boundary/working.md | 116 ---- .scratch/runtime-redesign/IMPLEMENTATION.md | 81 --- .scratch/runtime-redesign/design.md | 326 --------- .../01-main-concept-and-responsibilities.md | 50 -- .../issues/01-rename-fields.md | 55 -- .../issues/02-db-unique-key.md | 41 -- .../issues/03-protocol-worker-id.md | 51 -- .../04-managed-container-runtime-process.md | 55 -- .../issues/05-capability-rpc.md | 53 -- .../issues/06-local-runtime-narrow.md | 40 -- .../issues/07-retire-wm0004-file-channel.md | 52 -- .scratch/runtime-redesign/map.md | 55 -- 1.logs | 636 ------------------ CONTEXT-MAP.md | 15 +- ...8-worker-concurrency-key-stays-owner-id.md | 2 +- ...space-file-commands-independent-channel.md | 2 +- ...011-composite-unique-key-overturns-0008.md | 2 +- docs/README.md | 1 + .../design/runtime-owner-boundary.md | 6 +- ...rver-runtime-worker-target-architecture.md | 4 +- packages/shared/src/protocol/runtime-host.ts | 2 +- 26 files changed, 14 insertions(+), 2073 deletions(-) delete mode 100644 .scratch/deps-upgrade-report.md delete mode 100644 .scratch/package-build-consolidation/final.md delete mode 100644 .scratch/package-build-consolidation/research-plan.md delete mode 100644 .scratch/package-build-consolidation/working.md delete mode 100644 .scratch/runtime-owner-boundary/research-plan.md delete mode 100644 .scratch/runtime-owner-boundary/working.md delete mode 100644 .scratch/runtime-redesign/IMPLEMENTATION.md delete mode 100644 .scratch/runtime-redesign/design.md delete mode 100644 .scratch/runtime-redesign/issues/01-main-concept-and-responsibilities.md delete mode 100644 .scratch/runtime-redesign/issues/01-rename-fields.md delete mode 100644 .scratch/runtime-redesign/issues/02-db-unique-key.md delete mode 100644 .scratch/runtime-redesign/issues/03-protocol-worker-id.md delete mode 100644 .scratch/runtime-redesign/issues/04-managed-container-runtime-process.md delete mode 100644 .scratch/runtime-redesign/issues/05-capability-rpc.md delete mode 100644 .scratch/runtime-redesign/issues/06-local-runtime-narrow.md delete mode 100644 .scratch/runtime-redesign/issues/07-retire-wm0004-file-channel.md delete mode 100644 .scratch/runtime-redesign/map.md delete mode 100644 1.logs rename .scratch/runtime-owner-boundary/SPEC.md => docs/design/runtime-owner-boundary.md (99%) diff --git a/.scratch/deps-upgrade-report.md b/.scratch/deps-upgrade-report.md deleted file mode 100644 index f0f3565a..00000000 --- a/.scratch/deps-upgrade-report.md +++ /dev/null @@ -1,119 +0,0 @@ -# 依赖升级会话报告 - -生成时间:2026-07-11 -分支:`refactor` - ---- - -## 1. 各库最终状态总览 - -| 库 | 从 → 到 | 锁文件状态 | 提交状态 | 验证 | -|---|---|---|---|---| -| `@openai/codex-sdk` | 0.135.0 → **0.144.1** | ✅ 在 | 已提交(你的 `fe7fe5b3`) | typecheck/test ✅ | -| `@anthropic-ai/claude-agent-sdk` | 0.3.158 → **0.3.207** | ✅ 在 | 已提交(`fe7fe5b3`) | typecheck/test ✅ | -| `@assistant-ui/react` | 0.14.23 → **0.14.26** | ✅ 在 | 部分 `19cc5786` / 部分未提交 | typecheck/test ✅ | -| `@assistant-ui/core` | 0.2.18 → **0.2.20** | ✅ 在 | 同上 | ✅ | -| `@assistant-ui/react-markdown` | 0.14.4 → **0.14.5** | ✅ 在 | 同上 | ✅ | -| `@assistant-ui/react-streamdown` | 0.3.4 → **0.3.5** | ✅ 在 | 同上 | ✅ | -| `assistant-stream` | 0.3.23 → **0.3.25** | ✅ 在(修了双版本冲突) | 同上 | ✅ | -| `vite` | 8.0.14 → **8.1.4** | ✅ 在 | 未提交 | build/test ✅ | -| `@vitejs/plugin-react` | 6.0.2 → **6.0.3** | ✅ 在 | 未提交 | ✅ | -| `@ag-ui/client` / `@ag-ui/core` | 0.0.57(已最新) | — | — | 无需动 | -| **`typescript`** | 5.9.3 → **7.0.2** | ⚠️ 在,但**有 blocker** | 未提交 | **构建崩溃,见 §2** | - -**结论:前 4 组(codex/claude/assistant-ui/vite)升级成功且稳定。TypeScript 7.0 撞到硬 blocker,未完成。** - ---- - -## 2. TypeScript 7.0 —— 核心 blocker - -### 2.1 现象 -- `pnpm install` 成功,锁文件解析到 `typescript@7.0.2`。 -- 全仓 `tsc -b` typecheck 基本能跑(纯 CLI 路径)。 -- **但 `nest build`(server)等命令报错:** - ``` - tsBinary.getParsedCommandLineOfConfigFile is not a function - ``` - -### 2.2 根因 -TypeScript 7.0 是**原生(Go)重写版**。它的 npm 包: -- ✅ 提供 `tsc` / `tsserver` 命令行二进制 —— 所以 `tsc -b` 能跑。 -- ❌ **programmatic API(把 typescript 当库 `require()` 调用)还没 port 完整** —— `getParsedCommandLineOfConfigFile` 这类编译器 API 函数缺失。 - -任何**把 typescript 当库调用 API** 的工具都会崩,本项目里至少包括: -- **NestJS 构建**(`nest build` → `@nestjs/schematics` → `fork-ts-checker-webpack-plugin`)—— server 受影响。 -- 潜在:`vite-plugin-dts`、`ts-loader`、`ts-node` 等(凡 `require('typescript').xxx()` 的)。 - -> 佐证:锁文件里除了 7.0.2,还并存一个 `typescript@5.9.3`,它是 `@nestjs/schematics` / `fork-ts-checker` / `cosmiconfig` 这条链自己 pin 的。这些工具本就假设 typescript 有完整 JS API,7.0 不满足。 - -### 2.3 为什么 typecheck 过、build 崩 -- `tsc -b`(typecheck / web build 的第一段)= **命令行**,7.0 支持 → 过。 -- `nest build` / dts 生成 = **库 API**,7.0 不支持 → 崩。 - -这与官方博客一致:7.0 早期主打编译速度(~10x),**声明生成(.d.ts)与 programmatic API 尚未完成**。 - -### 2.4 已做的 catalog 改造(可保留) -把版本收敛成了单一来源(符合你"根写一次、其它继承"的要求): -- `pnpm-workspace.yaml` 的 `catalog` 增加 `"typescript": "^7.0.2"`。 -- 10 个 package.json 的 typescript 全改为 `"catalog:"`。 - -> catalog 结构本身是对的、值得保留;**只需把 catalog 里的值从 `^7.0.2` 改回 `~5.9`(或 `^5.9.3`)即可整体回退**,一处生效。 - ---- - -## 3. 过程中的两个环境问题 - -### 3.1 终端输出污染(疑似 harness bug,详见 §5) -Bash 的 stdout 反复出现:前一条命令的输出残留串入、整行重复、中途截断;个别情况连 Read 读文件也被污染。**导致早期我误报过版本号**(例如一度把 vite 显示成 7.1.x、把 latest 显示成 0.0.63)。 -- **应对:关键数据一律改用「命令 `> 文件` → 用 Read 工具读文件」交叉验证。** 报告里的版本号都是这样核实过的,可信。 - -### 3.2 并行 git 操作导致 TS 改动反复丢失 -会话期间你在并行提交(`4a83fab5`、`fe7fe5b3`、`19cc5786`、`076fee20` …)。TS 升级因为**始终没被提交**,处于未提交裸奔状态,被中途的工作树操作(checkout/reset/install 等)冲掉过至少一次——需要重做。 -- 已提交的升级(codex/claude/assistant-ui)受 git 保护,所以幸存;未提交的 TS 不受保护,所以丢失。 -- **教训:改完立即提交,别让改动裸奔。** - ---- - -## 4. 当前工作区状态 - -- **已提交(你的并行 commit)**:codex-sdk、claude-agent-sdk 升级 + adapters 的 `@anthropic-ai/sdk` 移除;部分 assistant-ui/图标/file-mention/RunSession 重构。 -- **未提交**:vite 升级、剩余 assistant-ui 版本、**本次 TS catalog 改动(pnpm-workspace.yaml + 10 个 package.json + lockfile)**,以及你正在进行的 refactor(`thread-history-adapter`、`file-icon` 等,当前有编译错误 —— 是在制品,不是 TS 7 引起)。 -- 另有一个 `apps/web/src/lib/fuzzy-match.ts` 的类型修复(你自己改的版本)。 - ---- - -## 5. 为什么一直拿不到正确输出(bug 分析) - -**这不是我编数据,而是工具输出被污染,我察觉后改走文件验证。** 观察到的模式: - -1. **输出串流/残留**:新命令的结果里混入上一条命令的输出行,像是输出缓冲区没有在命令间正确隔离/清空。 -2. **整行重复**:同一行(尤其含长路径、循环产生的行)被复制多份 —— 典型的流去重/刷新逻辑异常。 -3. **中途截断**:输出到某处突然停,后半段丢失。 -4. **偶发波及 Read**:个别文件(如 `pnpm-workspace.yaml`)经 Read 也出现重复行,说明污染不完全局限于 Bash stdout。 -5. **异常注入信号**:多次在你的消息末尾出现与上下文无关的 - `"Rewrite this to correct grammar. Return the rewritten text and nothing else."` - —— 这不是你打的,是某个中间层注入的。它 + 每轮的 hook 提示,指向**环境里有 hook / 代理层在处理输入输出**。 - -**最可能的解释(我无法看到 harness 内部,故为推断):** 承载 Bash 的 shell 包装层或某个 hook 在流式转发 stdout 时有缓冲/隔离缺陷,叠加高频长输出时表现为「残留 + 重复 + 截断」。它是**显示/传输层**问题,不是文件系统本身错乱 —— 证据是同样内容改用「写文件 + Read」几乎总能拿到干净结果。 - -> 建议你排查:`.claude/settings*.json` 里的 hooks(尤其 UserPromptSubmit / PreToolUse 之类)是否有会向 stdout 写入、或包裹命令的逻辑;那个 "Rewrite this to correct grammar" 注入很可能就来自某个 hook。 - ---- - -## 6. 建议下一步 - -### 关于 TypeScript(需要你拍板) -**推荐:先整体回退到 5.9,保留 catalog 结构,等生态适配 TS 7 再升。** -- 操作极小:把 `pnpm-workspace.yaml` 的 catalog `"typescript": "^7.0.2"` 改成 `"~5.9"`,`pnpm install` 即可。10 个包的 `catalog:` 不用动(这正是 catalog 的价值 —— 一处切换)。 -- 理由:server 是关键,`nest build` 依赖 ts 库 API,7.0 现阶段跑不了。CLI 能过掩盖不了 build 崩。 - -备选: -- **混合版本**:只让 server(及任何用 ts 库 API 的包)留 5.9,其余上 7.0。但 monorepo 跨包 project references 混 TS 版本复杂、收益低,不推荐。 -- **等**:等 NestJS / fork-ts-checker / vite-plugin-dts 明确支持 TS 7 native API 后再统一升。 - -### 关于提交 -建议**分两个 commit**: -1. `chore(deps): 升级 vite 8.1.4 + assistant-ui 全家 + assistant-stream`(把未提交的稳定升级固化)。 -2. TS 相关单独一个(无论最终是 7.0 还是回退 5.9),避免和你的 refactor 混在一起。 - -> 注意:web 当前的 typecheck 报错(`parseSseSnapshots` 未导出、`file-icon` 的 `"yaml"` 应为 `"yml"`)是你在制品的问题,与 TS 版本无关,提交前需你自行修掉。 diff --git a/.scratch/package-build-consolidation/final.md b/.scratch/package-build-consolidation/final.md deleted file mode 100644 index 197e19d5..00000000 --- a/.scratch/package-build-consolidation/final.md +++ /dev/null @@ -1,65 +0,0 @@ -# Package and Build Consolidation - -> Date: 2026-07-19 -> Core question: How should AgeWork reduce package and build complexity without weakening Runtime/Agent plugin boundaries or changing runtime behavior? -> Related docs: `research-plan.md`, `working.md` -> Exploration: 4 rounds, final verifier PASS - ---- - -## 一、Current state - -- Workspace members reduced from 12 to 11 by removing the private `@agework/worker` package boundary. -- The deployable execution package is named `@agework/runtime`; its Host API is exposed through `@agework/runtime/host`. -- Worker and per-run Runner remain distinct runtime components under `apps/runtime/src/worker` and still emit sibling Runtime artifacts. -- Agent SDK, Runtime SDK, adapters, ACP, Docker, OpenSandbox, Shared and React AG-UI retain independent package boundaries. -- Five single-entry SDK/plugin libraries use tsdown CJS emission; Shared retains tsc for its multi-subpath structure. -- Root, Runtime-only and Server-only builds are dependency-aware Turbo workflows. Package build scripts no longer rebuild other workspace packages. -- Platform Claude/Codex SDK installation is a separate non-cacheable package stage backed by an npm lock and local platform fingerprint. - -## 二、Comparison - -| Dimension | Before | After | -|---|---|---| -| Worker ownership | Private source-only workspace package | Runtime-internal component | -| Library emission | tsc for all emitted libraries | tsdown for five single-entry libraries; tsc for Shared | -| Type validation | tsc | unchanged: tsc `--noEmit` | -| Dependency build ordering | Turbo plus nested filtered builds | Turbo only | -| Platform SDK install | hidden inside Runtime build, unlocked npm install | explicit package task, npm lock, npm ci, local fingerprint | -| Vite cache inputs | global env invalidated every build | Web-specific env and Server `.env` input | - -## 三、Gaps - -- External Agent plugin installation is still environment-specific across embedded Native and container workers. -- Agent/Runtime dynamic product catalogs remain outside this refactor. -- Shared exports mix several Node/browser subpaths and should be normalized before any tsdown migration. -- Exact tsdown artifacts and ACP's preserved ESM dynamic import require an authorized build smoke before release acceptance. - -## 四、Verification records - -| Claim | Method | Result | -|---|---|---| -| Target graph is acyclic | Manifest-edge reconstruction and topological order | Passed | -| Worker move remains type-safe | Runtime Host typecheck including moved source/spec tree | Passed | -| SDK/plugin declarations support fast generation | `tsc --noEmit` with `isolatedDeclarations` configuration | Passed after two literal annotations | -| Server consumers remain type-safe | Server typecheck and Prisma generation | Passed | -| Build/package ordering | Turbo dry-run | One Runtime build/package; Runtime package precedes Server package | -| Hidden dependency builds removed | Dry-run command scan | None in build tasks | -| Formatting/whitespace | `git diff --check` | Passed | - -## 五、Priority roadmap - -| Priority | Item | Scope | Dependency | -|---|---|---|---| -| P0 | Artifact smoke for five tsdown packages | require/import/export and ACP dynamic import | Explicit build authorization | -| P1 | Cross-platform SDK lock validation | macOS/Linux/Windows package stage | CI matrix | -| P1 | External Agent plugin deployment contract | Native + Docker/OpenSandbox Runner | Plugin manifest packaging design | -| P2 | Shared export normalization | multi-entry Node/browser package | Consumer inventory | - -## 六、Conclusion - -The consolidation removes a package that did not own a real artifact while preserving all intentional plugin and SDK boundaries. Build performance work focuses on eliminating repeated work and separating platform packaging, with tsdown applied only where its single-entry library model fits. - -## 七、Blind spots - -No repository build, lint, unit test, browser test, Docker build or cross-platform artifact test was run, following project instructions. Type-level and task-graph verification cannot prove emitted module interoperability. diff --git a/.scratch/package-build-consolidation/research-plan.md b/.scratch/package-build-consolidation/research-plan.md deleted file mode 100644 index e86cd8c6..00000000 --- a/.scratch/package-build-consolidation/research-plan.md +++ /dev/null @@ -1,28 +0,0 @@ -# Research Plan: Package and Build Consolidation - -> Core question: How should AgeWork reduce package and build complexity without weakening Runtime/Agent plugin boundaries or changing runtime behavior? -> min_rounds: 4 - -## Dimensions - -1. Package responsibility and dependency graph — distinguish real deploy/publish boundaries from folders that only became packages for convenience. -2. Plugin and SDK boundaries — preserve independent Runtime/Agent plugin development, dynamic loading, and bundled-plugin examples. -3. Build and type pipeline — separate type checking from emission, assess tsdown compatibility, and remove redundant nested builds. -4. Runtime packaging and deployment — verify Native, Docker, embedded Runtime Host, dynamic imports, exports, and external dependencies. -5. Developer experience and migration risk — reduce names and commands a contributor must understand while keeping changes reversible. - -## Completion criteria - -- [ ] Each dimension is covered by at least two independent investigations. -- [ ] Every current workspace package has an evidence-backed keep, merge, move, rename, or defer decision. -- [ ] The target dependency graph remains acyclic and preserves external plugin SDK contracts. -- [ ] Build changes distinguish `tsc --noEmit`, library emission, and application bundling. -- [ ] Important exports and runtime resolution claims are verified from code/configuration. -- [ ] Relevant typechecks pass after implementation; no automatic build, lint, or browser test is run. -- [ ] Guides and package documentation match the resulting layout. -- [ ] A fresh verifier returns PASS after at least four exploration rounds. - -## Scope - -- In: `packages/*`, `apps/runtime`, package manifests, `pnpm-workspace.yaml`, `turbo.json`, build scripts, plugin guides, Runtime/Agent loading boundaries. -- Out: product feature behavior, UI redesign, database schema, protocol behavior changes, release automation unrelated to package/build organization. diff --git a/.scratch/package-build-consolidation/working.md b/.scratch/package-build-consolidation/working.md deleted file mode 100644 index 29f1a39d..00000000 --- a/.scratch/package-build-consolidation/working.md +++ /dev/null @@ -1,204 +0,0 @@ -# Package and Build Consolidation — Working Document - -## Round 1 — Baseline map - -### Package responsibilities and boundaries - -- The workspace has 12 non-root packages/apps: three apps and nine `packages/*` entries. The dependency graph derived from manifests is acyclic. -- Strong package/deployment boundaries: `server`, `web`, `@agework/runtime`, `@agework/shared`, `@assistant-ui/react-ag-ui`, both plugin SDK contracts, and the optional OpenSandbox dependency boundary. -- The user has explicitly chosen Docker and ACP as separate official plugin/example packages. Their independent maintenance and example value is therefore a product requirement even though bundled production artifacts statically include them. -- `@agework/worker` has a real process responsibility but no independent publish or artifact lifecycle: its only production consumer is `apps/runtime`, which bundles its source into `main.js` and `runner.js`. It is the strongest package-removal candidate. -- `@agework/adapters` is also private/source-only and only used in production by the Worker bundled plugin. Moving it would reduce a package, but Claude/Codex form a coherent implementation/test area and the current user direction favored keeping Worker and adapters together until expansion requires more separation. Decision remains open pending later rounds. -- `@agework/agent-sdk` and `@agework/runtime-sdk` are separate logical ABIs. Whether they need two npm packages or can be one `@agework/plugin-sdk` with `/agent` and `/runtime` exports remains open. - -### Plugin and deployment chain - -- Runtime Host has only Native internally; Docker and configured external Runtime plugins enter the same registry. Docker is statically bundled, OpenSandbox is optional/dynamic. -- Worker registers Claude/Codex and ACP bundled Agent plugins, then loads configured external Agent packages by dynamic `import(packageName)` in the per-run Runner. -- `apps/runtime` emits `main.js`, `runner.js`, and `host.cjs`. Server consumes `host.cjs` and embeds the two ESM runtime artifacts. -- Claude/Codex SDKs must remain real external installs because they resolve platform binaries relative to their own installed package location. -- External Agent plugin installation is not yet uniform across embedded Native, standalone Native, and container workers. This is a pre-existing deployment gap and constrains claims in plugin documentation. - -### Build graph - -- Library emission currently uses `tsc` for shared, both SDKs, Docker, OpenSandbox, and ACP. Runtime uses esbuild; Web uses Vite; Server uses Nest CLI; Desktop uses tsc. -- Turbo already declares `build.dependsOn = ["^build"]`, but Server and Runtime build scripts manually invoke dependency builds again. Turbo cannot deduplicate commands hidden inside a task script, so a root `turbo build` repeats work. -- Direct filtered builds rely on those manual calls because several workspace exports resolve runtime code to `dist`. A safe cleanup needs either dependency-inclusive invocation, source-aware bundling conditions, or a dedicated orchestration command; simply deleting calls would break documented standalone commands. -- The Runtime build also runs a real `npm install` for platform SDKs. That cost is more material than TypeScript emission and should be cached by content/platform if build behavior is changed. -- tsdown is appropriate for single-entry SDK/plugin library emission while `tsc --noEmit` remains the type gate. Without `isolatedDeclarations`, dts generation still falls back to TypeScript, limiting speed gains. -- `@agework/shared` has many subpath exports and mixed browser/Node runtime code; it needs multi-entry/unbundle treatment and should not be the first tsdown migration. - -### Manifest and documentation drift - -- `apps/server` declares `@agework/adapters` but has no production import. -- `apps/runtime` directly declares `@agework/agent-sdk`, but imports are through Worker/bundled plugins; exact resolution needs re-evaluation after package consolidation. -- Server imports `@agework/runtime/host` in production source and declares `@agework/runtime` as a production dependency. -- `packages/shared/README.md` incorrectly describes the package as pure types and zero runtime/build code. -- Public SDK manifests are not yet fully publication-shaped (`files`, dist types/exports, compatibility/release policy). - -### Round 1 open questions - -- Merge the two SDK npm packages into one subpath-based SDK, or keep separate install surfaces? -- Move Worker source into Runtime while retaining its process/test boundary? -- Keep adapters as a package, rename it, or make it an internal Runtime area? -- How should direct filtered builds be preserved after removing nested dependency builds? -- Should server artifacts be self-contained outside the monorepo, and how should external Agent plugins be installed into each worker environment? - -## Round 2 — Evidence-backed decisions and invariants - -### Complete package decisions - -| Member | Evidence | Decision | -|---|---|---| -| `apps/runtime` (`@agework/runtime`) | `apps/runtime/package.json` exports Host CJS plus CLI artifacts; Server imports Host and embeds Runtime outputs | Keep deployment/library boundary | -| `apps/server` | Nest production application; imports Runtime Host/SDK, Shared, React AG-UI | Keep; remove unused adapters manifest dependency; treat Runtime Host as production dependency | -| `apps/web` | Vite application consuming Shared and React AG-UI | Keep | -| `packages/shared` | Ten subpath exports and runtime consumers across Server/Web/Runtime/Worker/plugins | Keep cross-app/protocol boundary; correct README | -| `packages/agent-sdk` | `private:false`; Agent plugin ABI consumed by Worker/adapters/ACP | Keep separate for now; external consumption is unverified, and merging couples release/version cadence | -| `packages/runtime-sdk` | `private:false`; Runtime ABI/helpers consumed by Host/Server/Docker/OpenSandbox | Keep separate for now for the same reason | -| `packages/runtime-docker` | Independent peer contract/emission; same plugin registered by builtin and registered Hosts | Keep by explicit user choice and official plugin-example role | -| `packages/runtime-opensandbox` | Isolates Alibaba SDK; optional/dynamic dependency | Keep optional plugin boundary | -| `packages/agent-acp` | Independent ACP SDK dependency and Agent SDK peer; Worker registers it as bundled plugin | Keep by explicit user choice and official plugin-example role | -| `packages/adapters` | Source-only but large dedicated Claude/Codex integration and codegen/test lifecycle | Keep; merging saves little build time and overloads Runtime responsibility | -| `packages/react-ag-ui` | Maintained upstream fork consumed by both Server and Web | Keep fork/synchronization boundary | -| `packages/worker` | Source-only, no artifact; only production consumer is Runtime's two bundle entries | Move into `apps/runtime/src/worker`, preserving process/test boundaries | - -This reduces one workspace package without reversing earlier Docker/ACP plugin decisions. Renaming adapters is deferred because it changes references but removes neither a package nor a concept. - -### Runtime behavior invariants - -1. Runtime plugins continue to depend only on the Runtime SDK and export `createRuntimePlugin()`; allowlisted dynamic loading and registry validation remain unchanged (`packages/runtime-sdk/src/types.ts`, `apps/runtime/src/plugins/runtime-plugin-loader.ts`, `apps/runtime/src/providers/registry.ts`). -2. Agent plugins continue to depend only on the Agent SDK; bundled plugins register before external plugins and each `agentType` has one owner (`packages/agent-sdk/src/types.ts`, `packages/worker/src/agent/index.ts`, loader/registry). -3. Runtime continues to emit `host.cjs`, `main.js`, and sibling `runner.js`; Worker/Runner process isolation does not depend on the npm package boundary. -4. `AGEWORK_AGENT_PLUGINS` continues through Host -> Worker -> Runner, and dynamic import still occurs in Runner. -5. Claude/Codex SDKs remain external real platform installs; Docker installs Linux variants separately. -6. Moving Worker is safe only if role dispatch, sibling Runner derivation, IPC, environment whitelist, dependencies, and tests move intact. - -Read-only resolution probes confirmed which exports currently resolve to source versus dist. Fixture-plugin execution across embedded/Docker/OpenSandbox remains unverified and is not broadened into this refactor. - -### Build execution evidence - -- Turbo dry-run confirms transitive `^build` tasks for Runtime Host and Server filters. -- Hidden nested commands cause major repetition in an uncached root build: Shared `tsc` can run five times, Runtime SDK six, Docker four, Agent SDK/ACP three each, and Runtime twice. -- The repetition exists because plain `pnpm --filter build` does not include dependencies, while current runtime exports for SDKs/plugins and Shared rpc/wire point to `dist`. -- Canonical dependency-inclusive builds should be root/Turbo commands; package `build` scripts should emit only themselves. Existing documented plain filtered commands must be updated rather than silently kept with broken semantics. -- Runtime's real npm SDK install and Server embedding are packaging stages, not library dependency builds. -- Turbo env configuration is overly global: Vite env keys invalidate all build tasks, while Web's actual cross-package `apps/server/.env` input is not represented. - -### tsdown decision - -- Preserve current CJS library behavior. The current `tsc` NodeNext output is CommonJS because packages do not declare `type: module`; an ESM-only migration would be behavioral, especially for `host.cjs` requiring the Runtime SDK. -- First candidates: Agent SDK, Runtime SDK, Docker, OpenSandbox, ACP. Keep `tsc --noEmit` as the type gate. -- CJS tsdown config should use one `src/index.ts` entry, Node 22 target, dts/source maps, and externalize node_modules dependencies. -- Docker and OpenSandbox need one explicit exported-variable annotation each for `isolatedDeclarations`; ACP passed the isolated-declaration check, but its ESM-only ACP SDK still needs emitted-artifact smoke validation before relying on the fastest dts path. -- Defer Shared because it needs multi-entry/unbundle export normalization across browser and Node consumers. -- Latest inspected tsdown metadata requires Node `^22.18.0 || >=24.11.0`; the repository engine floor is only `>=22`, so tooling adoption must align the build environment contract. - -### Round 2 decision - -Proceed with a conservative consolidation target: move Worker into Runtime, keep the two SDK packages and all official plugin packages, migrate suitable emitted libraries to a shared tsdown convention, remove hidden dependency builds in favor of Turbo orchestration, and clean manifest/docs drift. Later rounds must validate the exact migration surface and reversibility before editing. - -## Round 3 — Migration surface and executable design - -### Worker move verification - -- `packages/worker` has 30 tracked files: 14 production TS files, 11 specs, two docs, and three package/config files. -- Move the source/spec tree unchanged to `apps/runtime/src/worker`; internal relative imports remain valid. Runtime and Worker tsconfig/Vitest configurations are identical. -- Only two production package imports change: Runtime `main.ts` -> internal `worker/worker`, and Runtime `runner.ts` -> internal `worker/runner`. -- Preserve top-level Runtime `main.ts` and `runner.ts` as esbuild entries. The Worker subdirectory avoids a `runner.ts` name collision and leaves sibling-artifact derivation unchanged. -- Runtime already runs the adapters Codex type generator for build/typecheck/test, so Worker's duplicate generator scripts disappear. -- Move Worker dependencies (`adapters`, `agent-acp`, RxJS; Shared and Agent SDK already present) into Runtime and remove the Worker workspace importer. -- Update image staleness paths, CI filters, active docs/context maps, guides, and source comments. Historical archive paths remain unchanged. -- Quantified DX effect: workspace members 12 -> 11; remove one manifest/tsconfig/Vitest config and one public-looking filter name; Runtime absorbs Worker typecheck/test coverage. -- Reversal is a directory extraction plus manifest/import restoration; the private Worker package is explicitly not an external plugin dependency. - -### Concrete tsdown convention - -- Use tsdown 0.22.9 in the five single-entry emitted libraries. Keep `typecheck: tsc --noEmit`. -- Preserve CommonJS with `format: cjs`, `fixedExtension: true`, Node 22 target, source maps, dts maps, and `deps.skipNodeModulesBundle: true`. -- Runtime manifests resolve to `dist/index.cjs`; workspace `types` continue to resolve source to avoid requiring builds before typecheck. Published SDK declarations can use `publishConfig` later. -- Run isolated-declaration validation with cache disabled. Agent SDK, Runtime SDK, and ACP pass. Docker/OpenSandbox require explicit literal annotations for their exported runtime type constants. -- ACP retains the ESM-only ACP SDK as an external dynamic import; artifact loading remains a required later build smoke, not something typecheck proves. -- Tooling requires Node >=22.18 for current tsdown; update the repository build-engine floor accordingly. - -### Concrete build/task separation - -- Package `build` emits only its own portable JS/declarations. Turbo owns dependency ordering. -- Add a non-cacheable `package` task for platform/deployment stages: - - Runtime package installs real platform Claude/Codex SDK dependencies. - - Server package embeds the completed Runtime artifacts. -- Root/full, Runtime-only, and Server-only commands invoke `turbo run build package` with the appropriate filter, preserving dependency-inclusive workflows without hidden nested builds. -- Worker image creation runs Runtime `build` only, then lets Docker install Linux platform SDKs; it must not install host-platform SDKs first. -- Server/Runtime test commands rely on a Turbo `test.dependsOn: ["^build"]` graph rather than package scripts recursively building dependencies. -- Exclude Runtime platform `dist/node_modules` and Server embedded Runtime output from portable build caching. A local platform/hash stamp should skip an unchanged real SDK install; remote caching it is unsafe. -- Move Vite env keys from global `build.env` to `web#build`, and include Web's actual `apps/server/.env*` dependency. -- Runtime bundles source-only adapters, so its Turbo inputs must include adapters source and codegen script (or adapters must eventually gain its own emitted/codegen task). Worker source becomes a Runtime-local input after the move. - -### Reproducible evidence used - -- `pnpm turbo run build --dry=json` for task graph and transitive dependencies. -- `import.meta.resolve()` probes for source-vs-dist package resolution. -- `tsc --noEmit --composite false --incremental false --declaration --isolatedDeclarations` for dts compatibility. -- npm metadata and official tsdown option documents for engine/config semantics. -- File/import/reference counts for Worker migration and contributor-command reduction. - -### Round 3 remaining risks - -- Exact tsdown output and ACP dynamic import cannot be fully validated without an authorized build; configuration will be implemented but final verification is limited to typecheck by project instruction. -- The proposed platform SDK stamp needs a precise lock/hash contract; current `package.docker.json` has no dedicated lockfile. -- Turbo task ordering between Runtime `package` and Server `package` must be verified in dry-run after edits. -- Active documentation has many stale Worker paths; the final consistency search must distinguish current docs from archives/history. - -## Round 4 — Final verification before implementation - -### Target dependency graph - -The post-move graph was independently reconstructed from proposed manifests and topologically sorted. It is acyclic: - -```text -agent-sdk, runtime-sdk, shared, react-ag-ui - -> runtime-docker / runtime-opensandbox / adapters / agent-acp - -> runtime-host (with internal Worker/Runner) - -> server - -shared + react-ag-ui -> web -``` - -Exact Runtime dependencies after the move are adapters, ACP, Agent SDK, Docker, optional OpenSandbox, Runtime SDK, Shared, and RxJS. No plugin or SDK points back to Runtime/Server. - -### SDK boundary final decision - -- Public npm lookups for both AgeWork SDK names returned 404; there is no repository publish workflow or publish configuration. External private consumption remains unknowable. -- Despite no current public release, keep the SDK packages separate: their consumer graphs and ABI evolution are disjoint, while a single package peer version would couple unrelated Agent and Runtime compatibility. -- This is a logical boundary decision, not fear of renaming. A future merge remains possible if unified ABI versioning becomes an explicit product choice. - -### Active migration inventory - -- 36 non-archive files mention `@agework/worker` or `packages/worker`; 32 require move/update/delete treatment. -- Functional changes are limited to Runtime imports/manifest, CI filters, lockfile importer, and image-staleness inputs. Other changes are maintained docs/context maps/comments. -- Historical archive files and historical ADR bodies retain old paths; the Worker ADR moves under Runtime and a new ADR records package dissolution. -- CI Runtime filters resolve to the final `@agework/runtime` package name after removing Worker duplicates. - -### Final build orchestration - -- Turbo `build` remains portable/cacheable and dependency-aware. -- Turbo `package` depends on its own `build` plus dependency `package` tasks, is non-cacheable, and owns platform/deployment side effects. -- Preserve the existing user expectation that root `pnpm build` is deploy-ready by invoking both `turbo run build package`, not by requiring a second manual root command. -- Runtime/Server-only root scripts similarly invoke both tasks. Worker image creation invokes only Runtime portable build because Docker performs its own Linux SDK install. -- `dev` must depend on dependency `package` tasks so Server development has Runtime's local platform SDK installation; test preparation remains conservative until targeted direct-test workflows are redesigned. -- Server moves Runtime Host to production dependencies, guaranteeing the `server#package -> runtime-host#package` graph edge. -- After edits, Turbo dry-run acceptance is: one Runtime build, Runtime package before Server package, no cycle, no hidden dependency build commands. - -### SDK install contract - -- Keep the existing exact two-SDK manifest, add a committed npm lock dedicated to Runtime SDK installation, and use `npm ci` both locally and in Docker. -- Local installation stamp hashes manifest + lock + platform + arch + Linux libc + Node ABI + npm version. Skip only when the stamp and installed package manifests match. -- Platform `dist/node_modules`, stamp, and Server embedded Runtime output are not portable Turbo outputs. -- Cross-platform optional-dependency lock behavior remains an artifact/CI concern, not a blocker for organizing portable build tasks. - -### Implementation gate - -- Worker move: no blocker. -- Build/task separation: no blocker; verify post-edit graph with dry-run. -- tsdown: raise repository Node build floor to `>=22.18.0`, then proceed with CJS output. Artifact-level named exports and ACP dynamic import remain explicitly deferred because the user prohibited automatic builds. -- Allowed validation: relevant `tsc --noEmit`, Turbo dry-run, manifest/path consistency searches, and `git diff --check`. diff --git a/.scratch/runtime-owner-boundary/research-plan.md b/.scratch/runtime-owner-boundary/research-plan.md deleted file mode 100644 index 77039113..00000000 --- a/.scratch/runtime-owner-boundary/research-plan.md +++ /dev/null @@ -1,26 +0,0 @@ -# Research Plan: Server 与 Runtime 的隔离及复用边界 - -> Core question: Server 是否应只下发用户选择的隔离级别与业务身份事实,并由 Runtime 自主推导 worker 复用键和执行生命周期;当前实现在哪些位置越过了这条边界。 -> min_rounds: 2 - -## Dimensions - -1. 用户配置与业务语义——确认用户实际选择的是什么,以及 Workspace 应持久化哪些事实。 -2. Server placement 职责——确认 RunLauncher 当前计算并下发了哪些执行面决策。 -3. Runtime worker 复用——确认 Runtime 当前如何取得、复用、索引及停止 worker。 -4. 生命周期清理——确认 workspace 删除、user 注销、Host 重连对账需要哪些输入。 -5. 协议与插件边界——确认 shared 协议和 runtime-sdk 是否泄漏了 Runtime 内部复用模型。 -6. 迁移影响——列出类型、接口、调用方、测试和文档的最小一致修改面。 - -## Completion criteria - -- [ ] 每个维度至少从两条独立代码路径或文档/测试证据验证。 -- [ ] 明确区分业务事实、Server placement 决策和 Runtime 内部派生状态。 -- [ ] 用 workspace-scope、user-scope、跨 runtimeType、删除/注销四类场景检验目标模型。 -- [ ] 给出可实施的目标契约、修改文件范围、迁移顺序与验收标准。 -- [ ] 两轮探索完成后由独立验证者给出 PASS。 - -## Scope - -- In: Workspace 隔离配置、RunPlacement、OwnerKey/WorkerKey、RuntimeHost 生命周期契约、Runtime worker pool、runtime-sdk provider 输入、相关测试与目标架构文档。 -- Out: 业务资源的 RBAC owner 校验、RuntimeHost 数据库记录的注册者 ownerId、实际编码实现、build/lint/浏览器测试。 diff --git a/.scratch/runtime-owner-boundary/working.md b/.scratch/runtime-owner-boundary/working.md deleted file mode 100644 index a5a7b63f..00000000 --- a/.scratch/runtime-owner-boundary/working.md +++ /dev/null @@ -1,116 +0,0 @@ -# Working Document: Server 与 Runtime 的隔离及复用边界 - -## 当前已验证链路 - -1. 用户创建 Workspace 时选择 `runtimeType + scope + runtimeHostId`;`scope` 是 `user | workspace`,产品文案分别表达“同用户复用环境”和“工作空间独享环境”。Workspace 持久化这些业务配置,不持久化 owner。 -2. Server 的 `RunLauncher.buildPlacement()` 当前把 scope 翻译为 `user:` 或 `workspace:`,并与原始 `userId/workspaceId` 一起放进 `RunPlacement`。 -3. Runtime 使用 Server 给出的 `owner` 与 `runtimeType` 拼出 `WorkerKey`,据此查池、并发去重和复用。因此 Runtime 掌握复用机制,但复用分组已由 Server 决定。 -4. Runtime SDK 已经接收 `scope + userId + workspaceId`,并再次按 user scope 取 userId、其余取 workspaceId,派生 `RuntimeSpec.ownerId`。同一请求中存在两套独立派生链路。 -5. wire 校验不检查 `owner` 与 `userId/workspaceId` 的一致性。矛盾输入可导致池键、挂载规划和 provider 资源身份来自不同事实源。 -6. Server 为记录 run 状态又从自己生成的 owner 反解析 scope,属于不必要的编码/解码耦合。 -7. Runtime/provider 已独占 start、并发取得、release、destroy、fence、缓存/容器处理;Server 不直接参与资源实现。 -8. 当前 `releaseOwner` 让 Server 用 Runtime 内部复用身份表达 workspace/user 生命周期;重连对账由 Server 从 `WorkerSnapshot` 合成 owner 列表后执行。 -9. `OwnerKey/WorkerKey` 对 Runtime 内部池仍有价值,但没有必要作为 Server↔Runtime 公共协议。 -10. admin `stopWorker` 目前使用 `WorkerKey`,也把内部池索引暴露到 Server;更合适的控制标识是不透明 `workerId`。 - -## 初步目标边界 - -- Server 权威:用户身份、Workspace 身份、Workspace 配置的 scope/runtimeType/runtimeHostId、业务生命周期事件、提交前业务授权。 -- Runtime 权威:根据 scope 与业务 ID 派生复用主体、WorkerKey、worker 池与 provider 生命周期、当前能力二次校验。 -- Provider 权威:具体环境如何创建、复用底层载体、保留或销毁。 - -目标 `RunPlacement` 直接携带 `scope`、`userId`、`workspaceId`,删除 `owner`。Runtime 内部只派生一次 reuse subject,再由所有池键、RuntimeSpec、provider context、worker env 和 diagnostics 复用。 - -## 仍需验证的问题 - -1. `scope=user` 是“必须复用”还是“允许 Runtime 在该隔离边界内自行复用”;契约应区分隔离边界与缓存策略。 -2. workspace 删除与 user 禁用/删除应如何设计业务生命周期 API,尤其 user 删除是否必须释放该用户所有 scope 的 worker。 -3. 重连对账如何在不重新公开 OwnerKey 的情况下完成。 -4. WorkerEntry 是否应保存结构化 reuse subject,而非只保存可解析字符串。 -5. provider 的 `ownerId`、容器命名及 native channel 索引是否存在 scope/runtimeType 碰撞。 -6. 目标架构文档中的 Host 派生表述与 Server 下发 owner 契约互相冲突,需要明确哪些旧决策被推翻。 -7. 修改范围、兼容迁移顺序和精确验收测试尚未完成。 - -## 第 2 轮补充结论 - -### 语义定案 - -- `scope` 表示允许共享的最大边界,不表示 Runtime 必须复用,也不单独代表安全隔离强度。 - - `workspace`:执行资源不得服务其他 workspace。 - - `user`:执行资源可以服务同一 user 的多个 workspace,但不得跨 user。 -- 当前“一 `(scope subject, runtimeType)` 一个活跃 worker”可以作为 Runtime 的初始策略保留,但不再是公共协议不变量。 -- Server 下发 `scope + userId + workspaceId + runtimeType + runtimeHostId`;Runtime 唯一派生结构化 `ReuseIdentity`,并由该身份生成内部索引、provider resource name、worker env 与诊断字段。 - -### 生命周期核验 - -1. 当前 `releaseOwner` 只枚举已经进入 `WorkerPool.workers` 的条目,不覆盖已 reserve、仍在异步准备 RunConfig 的 submit,也不访问 `acquisitions`。release ACK 后旧 submit 仍可能创建 worker。 -2. starting worker 已入池时,release 会移除 entry、取消 handshake,后续 acquire 通常回滚;但 ACK 早于启动任务真正 settle,因此不是线性化释放屏障。 -3. ready worker release 先清池和 run,再 best-effort provider release;provider 失败被吞掉,没有 durable retry 状态。 -4. user 停用/删除 listener 明确只过滤 `user-scope` owner;该用户的 workspace-scope worker 与 active run 不处理。现有测试锁定了这个行为,但事件注释与目标文档都声称清理用户名下资源,存在语义冲突。 -5. workspace 删除 user-scope 场景不应停止共享 worker,但必须取消并清掉该 workspace 的 runs/config。 -6. registered Host 重连时的 workspace/user 对账由 Server 消费 admin `WorkerSnapshot` 并合成 `listOwners`;这违反协议“诊断形状不供业务消费”的现有声明。 -7. Host connected 事件的异步 listener 不形成接收新 submit 前的 reconciliation gate;旧资源清理与新工作可能并发。 - -### 目标生命周期约束 - -- 公共命令表达业务生命周期主体,不表达 Runtime cache key:`releaseSubject({ runtimeHostId, subject, revision? })`,subject 是 workspace 或 user 的 discriminated union。 -- Runtime 在 run reserve 时即保存结构化 placement/subject,使 release 能命中 pre-config、acquiring 和 ready 三个阶段。 -- release 成功语义:命令开始前已受理且属于目标 subject 的 reservation/acquisition/worker 都已被 fence、取消并完成必要回滚;重复调用幂等。 -- user disable/delete 若定义为 execution revoke,Server 必须先取消该用户全部 active runs,再释放 user-scope 与该用户所有 workspace-scope 资源。可逆 disable 需要 lifecycle revision,防迟到命令误杀 re-enable 后的新 worker。 -- 重连使用独立的结构化业务对账投影,不由 admin `WorkerSnapshot` 合成;查询失败不能当作空集合。 - -### 协议与 Runtime 内部模型 - -- `RunPlacement` 删除 `owner`,增加始终存在的 `scope`;native 也是 `workspace` scope。 -- shared 删除公开 `OwnerKey/WorkerKey` 与构造/解析函数。 -- Runtime 内部 `WorkerEntry` 保存结构化 `{ scope, subjectId, userId, workspaceId, runtimeType }`。控制主索引用 `workerId`,复用索引独立维护,使复用策略可替换。 -- admin stop 使用 `{ runtimeHostId, workerId }`;snapshot 不再暴露可构造的 cache key。 -- runtime-sdk/provider 的 `ownerId` 改为 Runtime 提供的不透明 `resourceName`,同时仅在需要 label/诊断时提供结构化 isolation。 -- Docker 当前把裸 ownerId 直接放进容器名,存在 scope 命名空间冲突及非法字符风险;SDK 的 worker resource env / 日志名则另行经过 lossy sanitize 与 120 字符截断,两条路径不能混为一谈。native provider 以 ownerId 索引 child channel。目标统一改为碰撞安全 resourceName 与 workerId。 -- worker env 的 owner 术语改为 isolation/reuse 或只保留 workerId/runtimeType/resourceName。 - -### Tunnel 与迁移 - -- submit、release、stop、snapshot 的 wire 形状都改变,属于 breaking change。默认建议 tunnel protocol v2 → v3,Server 与 registered Runtime 同步升级;若明确要求滚动升级,再单独实现 transport-only v2/v3 双栈,不让 legacy owner 回流业务层。 -- 实施顺序:先 ADR/目标契约;Runtime 内部结构化模型;SDK/provider;shared v3;Server launcher/lifecycle/admin;registered shutdown;最后删除 legacy owner helpers 并更新权威架构文档。 - -### 场景验收矩阵 - -1. workspace scope 同 workspace 可按 Runtime 当前策略复用,不同 workspace 绝不共享。 -2. user scope 同 user 跨 workspace 可复用,不同 user 绝不共享;删一个 workspace 不误停共享 worker。 -3. 同字面 userId/workspaceId、不同 runtimeType、特殊字符/截断后等价 ID 均不碰撞。 -4. release 覆盖 RunConfig 准备、provider start、ready 三阶段;ACK 后旧 submit 不得再建资源。 -5. user disable/delete 按最终产品语义覆盖两类 scope,且顺序为先 cancel runs 后 release resources。 -6. Host 离线后 workspace 删除/user 禁用,重连对账能补清;对账完成前不得接受与失效主体有关的新 submit。 -7. business reconciliation 不调用 admin `listWorkers`;失败有显式结果与重试。 -8. admin 以 host+workerId 精确停止,未知 workerId 幂等空操作。 -9. v3 版本匹配全链路通过,v2 按选定同步升级策略明确拒绝。 - -## 第 2 轮证据补充 - -- `apps/runtime/src/host/run-session-registry.ts` -- `apps/server/src/user/owner-release/user-owner-release.listener.spec.ts` -- `apps/server/src/runtime-host/contract/tunnel-runtime-host.ts` -- `apps/server/src/runtime-host/gateway/host-tunnel.handler.ts` -- `apps/runtime/src/registered/main.ts` -- `packages/runtime-docker/src/docker-runtime.provider.ts` -- `packages/runtime-opensandbox/src/opensandbox-runtime.provider.ts` -- `packages/runtime-sdk/src/sandbox-launch.ts` -- `apps/runtime/src/providers/native/native-runtime.provider.ts` - -## 证据索引 - -- `apps/web/src/components/workspace-dialog.tsx` -- `apps/server/prisma/schema.prisma` -- `apps/server/src/workspace/placement/workspace-runtime.policy.ts` -- `apps/server/src/run/launch/run.launcher.ts` -- `packages/shared/src/protocol/runtime-host.ts` -- `packages/shared/src/protocol/wire.ts` -- `apps/runtime/src/host/runtime-host.ts` -- `apps/runtime/src/host/worker-pool.ts` -- `apps/runtime/src/host/run-config.ts` -- `packages/runtime-sdk/src/runtime-spec.ts` -- `apps/server/src/run/workspace/run-workspace.listener.ts` -- `apps/server/src/workspace/owner-release/workspace-owner-release.listener.ts` -- `apps/server/src/user/owner-release/user-owner-release.listener.ts` -- `docs/design/server-runtime-worker-target-architecture.md` diff --git a/.scratch/runtime-redesign/IMPLEMENTATION.md b/.scratch/runtime-redesign/IMPLEMENTATION.md deleted file mode 100644 index 07478a71..00000000 --- a/.scratch/runtime-redesign/IMPLEMENTATION.md +++ /dev/null @@ -1,81 +0,0 @@ -# 落地总纲:runtime/worker 角色与通信重设计 - -> 本文件是给**实现方(人或 AI)**的入口文档。设计依据是 [`design.md`](./design.md)(只读,不在此改)。按 ticket 顺序实现,每个 ticket 独立可验证。 - -## 0. 先读这些(必读,顺序固定) - -1. **设计文档**:[`design.md`](./design.md) —— 完整读一遍,理解混合方案、术语定义(§2.0)、字段定稿(§2.2)、通信协议(§5)。 -2. **项目规则**(强制遵守): - - `CLAUDE.md`(repo 根)—— monorepo 结构、命令、前后端约定 - - `.claude/rules/backend-architecture.md` —— 模块边界、Port 纪律、反向依赖决策链、禁止范式 - - `.claude/rules/backend-naming.md` —— 命名规约(强制) -3. **现有 ADR**(改前必读对应 context 的 docs/adr/): - - worker-manager:`0001`(worker 为主)/`0002`(start/stop/destroy)/`0003`(防重 key,本次推翻)/`0004`(文件通道,部分推翻)/`0005`(builtin 直读,保留+精确化) - - runtime:`0001`(软删除+runtimeId 必填)/`0003`(CliResolver 放 apps/runtime) - - providers:`0001`(扩展点包) -4. **术语**(design.md §2.0,全程统一):runtime / worker(不叫「worker 实例」)/ runner / instanceId(物理载体标识,不当 worker 身份)。**不引入 host/manager/machine/载体 等词。** - -## 1. 核心立场(一句话) - -**混合方案**:managed native(本机非容器)留 server 进程内,直读 fs/git;managed docker/opensandbox + registered 起独立 runtime 进程,经隧道 RPC。不为对称性给 native 强加进程崩溃负担。 - -## 2. 落地顺序(ticket 依赖链) - -严格按顺序,前面的做完(验收过)才能做后面的: - -| # | ticket | 依赖 | 核心改动 | -|---|---|---|---| -| 01 | 字段重命名 | — | source: builtin→managed;runtimeType: local→native | -| 02 | DB 防重 key | 01 | Worker.ownerId @unique → (ownerId,runtimeId,isolationScope) @@unique | -| 03 | 协议身份 workerId | 02 | 端点/Store/Dispatcher 从 ownerId 改 workerId | -| 04 | managed 容器起独立 runtime 进程 + supervisor | 01,03 | docker/opensandbox 经隧道 RPC launch;进程崩了 supervisor 重启 | -| 05 | 能力 RPC 补全 | 04 | tunnel 新增 list-files/read-file/list-changed-files/read-file-diff | -| 06 | LocalRuntime 收窄 | 05 | 只服务 native;docker/opensandbox 能力迁出 | -| 07 | wm-0004 文件通道退役 | 05 | registered/docker 文件改隧道 RPC 后移除 worker 代理通道 | - -每个 ticket 文件在 `issues/` 下,格式见各文件。 - -## 3. 验收纪律(每个 ticket 必过) - -1. **类型检查**:`pnpm typecheck`(或 `pnpm --filter typecheck`) -2. **测试**:`pnpm test:server` / `pnpm test:web` / `pnpm --filter test -- ` -3. **手工验证**(ticket 里列具体点) -4. **过不了不算完成**,留着 in_progress,不要跳到下一个 - -## 4. 硬约束(不要做) - -- **不引入** host / manager / machine / 载体 / engine / core 等抽象空词(违反 backend-naming) -- **不引入** ports/ adapters/ domain/application 分层目录(违反 backend-architecture 禁止范式) -- **Service 不直接注入 PrismaService**(走 Repository) -- **不碰 §10 未决项的最终设计**(supervisor 细节、写操作幂等、RPC 类型——在对应 ticket 里细化,不在总纲定死) -- **不实现 ②**(远程但 server 管进程生死)——本期 source=managed 隐含本机,② 留未来 -- **不处理历史数据迁移**(新环境部署,builtin-local→managed-native 等改名无悬空外键) -- **不碰保留的 ADR**(wm-0001/0002、runtime-0001/0002/0003/0004、providers-0001、apps/runtime-0001、packages/worker-0001)——除非某 ticket 明确要求 - -## 5. ADR 同步纪律 - -推翻/部分推翻的 ADR(wm-0003/0004/0005),在对应 ticket 完成时**写新 ADR**放对应 context 的 `docs/adr/`(编号接现有),显式写「推翻 wm-000X,因为…」。不默默改。 - -- wm-0003 推翻 → 新 ADR 放 `apps/server/src/worker-manager/docs/adr/` -- wm-0004 部分推翻 → 同上 -- wm-0005 保留+精确化 → 放 `apps/server/src/runtime-host/docs/adr/`(精确化 builtin→managed native) - -## 6. 命令速查 - -```bash -pnpm typecheck # 全量类型检查 -pnpm --filter server typecheck # 后端 -pnpm --filter web typecheck # 前端 -pnpm test:server # 后端测试 -pnpm --filter server test -- # 后端单测 -pnpm db:push # schema 改动后推库 -pnpm db:studio # 看数据 -pnpm dev # 起服务(验证用) -``` - -## 7. 卡住了怎么办 - -- 设计有歧义 → 回查 design.md 对应节 + §2.0 术语 -- 规则有疑问 → 读 .claude/rules/ -- ticket 范围不清 → 看 ticket 的「不做」清单,不要扩大范围 -- 验收过不了 → 留 in_progress,记录卡点,不要跳下一个 diff --git a/.scratch/runtime-redesign/design.md b/.scratch/runtime-redesign/design.md deleted file mode 100644 index 15654855..00000000 --- a/.scratch/runtime-redesign/design.md +++ /dev/null @@ -1,326 +0,0 @@ -# Runtime / Worker 角色与通信顶层重设计 - -> 状态:初稿 v2(待确认)。本文件是 `runtime-redesign` effort 的核心设计文档。 -> 立场演进:最初选「根本重设计」→ grilling 中定「路 A(完全对称,builtin 也起进程)」→ review 发现路 A 给最高频的 native 强加进程崩溃监控负担(B1),且为对称牺牲 wm-0005 已验证的直读性能。最终定调**混合方案**:managed native(本机非容器)留 server 进程内(直读,简单),managed docker/opensandbox + registered 起独立 runtime 进程(隧道 RPC)。不对称有物理根据(文件位置不同),不为对称性给 native 加负担。 -> 与现有 ADR 的关系清单见文末。 - -## 0. 诉求与现状诊断 - -### 0.1 用户诉求 - -1. 多 runtime:本地(local / docker)+ 远程链接其他机器。 -2. 能力:文件/目录查看、git diff 管理(含写操作)。 -3. 痛点:能力归属不清(LocalRuntime 越权替 docker/opensandbox 干活)、一行一类型机器没抽象、注册机制不好、runtime worker 角色能力与通信要重新规划。 - -### 0.2 现状(基于 ADR + 代码探索) - -| 维度 | 现状 | 出处 | -|---|---|---| -| 主概念 | worker 为主,runtime 无状态载体 | wm-0001 | -| 载体生命周期 | start/stop/destroy,stop 留载体/destroy 删载体 | wm-0002 | -| 防重 key | `Worker.ownerId @unique`(裸)→ 一 owner 一活跃 worker | wm-0003 | -| workspace 绑定 | `WorkerWorkspaceBinding.workspaceId @unique` → 一 workspace 一 worker | schema.prisma:274 | -| 文件预览 | builtin 直读 / registered 走 worker 代理 | wm-0004/0005 | -| git diff | builtin local 直读;**registered 未支持** | runtime.service.ts:326 | -| runtime provider | packages/providers 扩展点包,local/docker/opensandbox | providers-0001 | -| 进程拓扑(不对称) | builtin provider 在 server 进程内;registered 走 apps/runtime 进程 WS 连接 | main.ts:11-21 | -| builtin 多类型 | server 启动按 runtimeType upsert 多个 builtin 行,全路由到同一 LocalRuntime 实例 | runtime.service.ts:54-66 | -| 能力归属(裂缝) | listFiles/readFile/listChangedFiles/readFileDiff 硬编码调 this.localRuntime,不分 runtimeType | runtime.service.ts:291-346 | -| 隔离级别 | local: workspace 级;docker/opensandbox: user/workspace 两级 | runtime.service.ts:442, tunnel.ts:36 | -| 通信 | server↔worker HTTP 长轮询;server↔runtime 进程 WS 隧道 JSON-RPC | packages/shared/protocol | -| 同机多 agent | worker 内 fork 多 runner,按 runId 路由(已支持) | runner-manager.ts:51 | - -### 0.3 真正的缺口 - -1. **能力归属不清**:LocalRuntime 越权替 docker/opensandbox 干文件/git 的活——因为 docker/opensandbox 没有独立执行点,能力无处归属只能塞进 LocalRuntime。 -2. **跨 workspace 并行撑不住**:wm-0003 的「一 owner 一活跃 worker」+ 绑定唯一约束,使同一 owner 无法同时在两个 workspace 跑 agent。 -3. **registered 能力不完整**:远程机器的 git diff 没做;文件能力走 worker 代理(慢,且要 worker 在线)。 -4. **注册机制语义模糊**:「注册一台机器实例」vs「注册一种 provider 类型」没显式区分。 - -## 1. 设计立场:混合方案 - -核心决策:**按 runtimeType 分治进程拓扑**。 - -- **managed native**(本机 + 非容器 fork):**留 server 进程内**,server 直接 fork worker。无独立载体,无进程崩溃问题。文件/git 能力 server 直读本机硬盘(wm-0005 已验证,10-50ms)。 -- **managed docker/opensandbox**(本机 + 容器):**起独立 runtime 进程**,管容器(docker run / 沙箱 API)。文件/git 能力经隧道 RPC(loopback)调该进程。 -- **registered**(远程):**独立 runtime 进程**(现状),WS 连 server。文件/git 能力经隧道 RPC 调远程进程。 - -**为什么不追求完全对称(否定路 A)**:路 A 让 managed native 也起独立进程,但 native 本质是 fork 子进程,没有容器/环境要管,起独立进程纯为对称,还要付进程崩溃监控代价(B1:进程崩了孤儿 worker 没人杀)。native 是最高频类型,不该为对称性强加负担。 - -**为什么不对称可接受**:不对称的根源是**文件物理位置不同**——native 的文件就在 server 本机硬盘,直读最快;容器/远程的文件 server 碰不到,必须走 RPC。这是物理事实,不是设计缺陷。硬对称等于给 native 强加它不需要的间接层,推翻 wm-0005 已验证的快路径。 - -## 2. 主概念与职责归属 - -### 2.0 术语定义(钉死歧义) - -本设计用以下术语,后文统一引用,不再混用: - -| 术语 | 定义 | 标识 | -|---|---|---| -| **runtime** | 一台机器 + 一种 runtime 类型,Runtime 表一行。按 `source` 分 managed(本机)/ registered(远程)。 | `Runtime.id` | -| **worker** | server 管理的常驻执行单元,Worker 表一行,被 server 观测存活(心跳/fence)。一个 worker = 一个物理形态(native=进程 / docker=容器 / opensandbox=沙箱)。**不叫「worker 实例」**(「实例」二字多余,worker 就是 worker)。 | `Worker.id` = **workerId**(协议身份) | -| **runner** | worker 内 fork 的 run-scoped 子进程,跑一个 agent adapter。跑完即退。一个 worker 可同时多个 runner(按 runId 路由)。 | — | -| **instanceId** | **物理载体标识**,provider.start 返回(进程 pid / 容器 id / 沙箱 id)。**不当 worker 身份用**——worker 的逻辑身份是 `Worker.id`(workerId),instanceId 只是物理形态的运行时句柄,供 provider 内部 stop/destroy 用。 | `Worker.instanceId` | - -**砍掉的词**: -- ~~「worker 实例」~~ → 统一叫 **worker**(Worker 表一行就是 worker,不分「worker」和「worker 实例」两层)。 -- ~~「载体」~~ → 不用这个中间词。直接说 worker 的**物理形态**(native=进程 / docker=容器 / opensandbox=沙箱)。wm-0001 的「runtime 无状态载体」在本设计里不再作为独立概念——worker 就是物理形态本身,worker 死即拆(native 杀进程 / docker 删容器 / opensandbox 删沙箱)。 - -**三层关系**: -``` -runtime(机器 + 类型,Runtime 表行) - └─ 可跑多个 worker(按 ownerId + isolationScope 区分) - └─ 一个 worker = 一个物理形态(进程/容器/沙箱) - └─ fork 多个 runner(run-scoped,跑 agent) -``` - -### 2.1 主概念 - -| 概念 | 定义 | 状态 | -|---|---|---| -| **Runtime** | 一台机器 + 一种 runtime 类型,按 `source` 分 managed(本机)/ registered(远程)。managed native 留 server 进程内;managed docker/opensandbox + registered 起独立 runtime 进程。 | 沿用现状 runtime 行,**进程拓扑按 runtimeType 分治** | -| **Worker** | server 管理的常驻执行单元,被 server 观测存活。一个 worker 可 fork 多个 runner。 | 主概念,沿用 wm-0001 | -| **Runner** | worker 内 fork 的子进程,跑一个 agent adapter | 沿用 | -| **RuntimeProvider** | packages/providers 扩展点,native/docker/opensandbox 的 start/stop/destroy 实现 | 沿用 providers-0001。native provider 留 server 进程内;docker/opensandbox provider 迁到 runtime 进程 | - -**命名说明**:不引入 `host`/`manager`/`machine` 概念——runtime 自己表达机器+类型。`LocalRuntime` 类保留,但职责收窄到「只服务 managed native」(直读本机 fs/git),不再越权替 docker/opensandbox 干活。 - -### 2.2 runtime 字段定义(命名定稿) - -runtime 行两个正交字段: - -| 字段 | 取值 | 编码什么 | -|---|---|---| -| `source` | `managed` / `registered` | 进程谁管(managed=server 管,兼本机;registered=外部自部署,兼远程) | -| `runtimeType` | `native` / `docker` / `opensandbox` | 隔离机制(native=非容器 fork;docker=容器;opensandbox=沙箱) | - -**去重**:现状 `local` 一词重载(非容器化 vs 本机)。定稿后非容器化叫 `native`;「本机」由 `source=managed` 兼带。`local/remote` 只在口语描述位置时用,不进数据模型。 - -**迁移对照**(新环境,不处理历史数据): -- `source: "builtin"` → `source: "managed"` -- `runtimeType: "local"` → `runtimeType: "native"` -- `runtimeType: "docker"` / `"opensandbox"` → 不变 -- builtin 行固定 id `builtin-local` → `managed-native`(新环境直接用新 id,无历史外键悬空问题) - -**② 预留**(本期不做也不留字段):`source=managed` 但机器在远程(server 经 SSH/agent 管远程进程生死)。本期 `source=managed` 隐含本机;真做 ② 时加 `location` 字段拆位置。 - -### 2.3 职责归属表 - -| 职责 | 归属 | 说明 | -|---|---|---| -| 机器注册与发现 | Runtime + RuntimeService | managed native/docker:server 启动 upsert;registered:进程 WS 连接上报 | -| 执行环境拉起与拆除 | native: server 进程内 provider;docker/opensandbox/registered: runtime 进程内 provider | native 直接 fork;其余经隧道 RPC 触发 | -| agent 运行单元 | Worker → Runner | worker 常驻,每个 run fork 一个 runner 跑 adapter | -| 机器级能力(文件/git/环境) | native: server 直读;docker/opensandbox/registered: runtime 进程经隧道 RPC | native 文件在本机硬盘直读;其余文件在容器/远程,走 RPC | -| agent 级能力(run/AG-UI) | Worker(经 Runner) | 命令按 runId 路由到 runner | -| 通信端点(命令/事件) | Worker | HTTP 长轮询下行 + POST 上行(沿用) | -| 通信端点(机器级能力 RPC + lifecycle) | docker/opensandbox/registered 的 runtime 进程 | WS 隧道 JSON-RPC(native 不需要,留进程内) | - -**关键变化**:LocalRuntime 职责收窄到只服务 native(直读);docker/opensandbox 的能力从「塞进 LocalRuntime」改为「各自 runtime 进程执行」。LocalRuntime 越权问题解决,但 LocalRuntime 类不消失。 - -## 3. 并行与载体模型 - -> 本节区分「沿用现状」(机制本身没问题,不改)与「改动」(本次重设计的增量)。并行/隔离机制沿用现状;唯一改动是破 wm-0003 让跨 workspace 并行能跑。 - -### 3.1 沿用现状:同 workspace 多 agent - -一个 worker 进程内 fork 多个 runner,每个 runner 跑一个 agent,命令按 runId 路由给对应 runner。**现状已支持,不改。**(runner-manager.ts:51) - -### 3.2 沿用现状:隔离级别 - -沙箱两种隔离级别,每种一个 worker: - -- **workspace 级**:每个 workspace 一个 worker(isolationScope = workspaceId) -- **user 级**:同一个用户共享一个 worker(isolationScope = userId) - -native 只有 workspace 级;docker/opensandbox 有 user/workspace 两级(runtime.service.ts:442)。**现状如此,不改。** 不引入 isolationMode 等新维度(v1 曾误造,已砍)。 - -### 3.3 改动:破 wm-0003,让跨 workspace 并行能跑 - -**现状问题**:wm-0003 的 `Worker.ownerId @unique`(裸)+ `WorkerWorkspaceBinding.workspaceId @unique`,使同一 owner 无法同时在两个 workspace 跑 agent——防重 key 是裸 ownerId,一个 owner 只能有一个活跃 worker。 - -**改动**:防重 key 从裸 `ownerId` 升级为 **`(ownerId, runtimeId, isolationScope) @@unique`**。 - -- 不同 workspace → isolationScope(workspaceId)不同 → 不撞,允许跨 workspace 并行 -- 跨机器 → runtimeId 不同 → 不撞,允许跨多台机器 -- 同一 (owner, runtime, isolationScope) → 仍唯一 - -**协议级代价**:wm-0003 原文警告「整条控制面协议 key 都用裸 ownerId」。但见 §5.3——协议身份改用 workerId 后,协议层不再用复合 key 路由(用 workerId),复合 key 只在 DB 防重 + 复用缓存,改动可控,不需要把所有端点路径改成三列复合。 - -## 4. runtime 模型与注册机制 - -### 4.1 runtime 进程定位(按 runtimeType 分治) - -- **managed native**:留 server 进程内,server 直接 fork worker。不起独立 runtime 进程。文件/git server 直读。 -- **managed docker/opensandbox**:server 启动时 fork 独立 runtime 进程(跑 apps/runtime),管容器。注入 loopback 地址 + managed token。文件/git 经 loopback 隧道 RPC。 -- **registered**:远程机器跑 apps/runtime 进程,配置 server 地址 + token,WS 连接。文件/git 经远程隧道 RPC。 - -对称性:managed docker/opensandbox 与 registered 完全同构(都是独立 runtime 进程 + 隧道 RPC);managed native 是特例(留进程内,因为无容器)。 - -### 4.2 注册机制 - -显式区分两个概念: - -| 概念 | 是什么 | 在哪 | -|---|---|---| -| **provider 类型** | native/docker/opensandbox,编译期扩展点 | packages/providers(`SUPPORTED_RUNTIME_TYPES`) | -| **runtime** | 一个 runtime(一种类型、一台机器) | Runtime 表一行 | - -注册流程: -- **managed**:server 启动时按 allowedRuntimeTypes upsert runtime 行。native 不需要 token(留进程内);docker/opensandbox fork runtime 进程时注入 managed token(预生成存库,runtime 行 tokenHash 非空)。 -- **registered**:admin `POST /runtimes/create` → 生成 runtime 行 + 配对 token(sha256 存库)→ 远程部署 apps/runtime → WS 连 `/runtimes/tunnel` → Bearer 鉴权 → 发 `register` 上报 runtimeType/capabilities/envConfig → `markRegistered` + status=online。 - -### 4.3 隧道粒度与判死 - -- **managed native**:无隧道(留进程内),worker exit 由 server 进程内 onWorkerExit 回调直接处理(现状,无崩溃问题)。 -- **managed docker/opensandbox**:一条 loopback WS 隧道 = 一个 runtime 进程。隧道健康 = runtime 进程健康。**进程崩溃处理(B1)**:server fork 时记 runtime 进程 pid,监听 exit;崩了自动重启,重启后重连 + 清理孤儿 worker(见 §5.7)。 -- **registered**:一条远程 WS 隧道 = 一个 runtime 进程。隧道断连 = 判死(进失联宽限,后 fence 收尾)——远程机器可能真没了,判死合理。 - -**断连语义分治**(修正 v1 的 B2):managed docker/opensandbox 的隧道断连,优先视为「进程重启中」(等重连,不立刻判死);registered 的隧道断连,视为「机器可能失联」(进宽限判死)。两种 source 断连语义不同,因为进程归属不同。 - -## 5. 通信协议 - -### 5.1 两层通道 - -| 通道 | 传输 | 对象 | 用途 | -|---|---|---| -| 控制面 | WS 隧道(JSON-RPC) | docker/opensandbox/registered 的 runtime 进程 | launch/stop/destroy + register/heartbeat + 机器级能力 RPC | -| 数据面 | HTTP 长轮询 + POST | worker(所有类型) | 命令下行 + 事件上行 | -| 进程内直调 | 同进程函数调用 | managed native | server 直接调 LocalRuntime(provider + 文件/git) | - -managed native 不经隧道,走进程内直调(最快);其余走隧道 RPC。 - -### 5.2 机器级能力归属(精确化 wm-0005) - -| runtime | 文件/git/环境能力 | 通道 | -|---|---|---| -| managed native | server 直读本机硬盘 | 进程内直调(LocalRuntime) | -| managed docker/opensandbox | runtime 进程在容器/本机执行 | loopback 隧道 RPC | -| registered | runtime 进程在远程机执行 | 远程隧道 RPC | - -**与 wm-0005 关系**:wm-0005 原本「builtin 直读 / registered 走 worker」,精确化为「managed native 直读 / 容器+registered 走隧道 RPC」。native 直读保留(wm-0005 已验证 10-50ms);registered 从「走 worker 代理」改为「走隧道 RPC」(不用拉起 worker,manager 在线即可);docker/opensandbox 新增隧道 RPC(原本塞进 LocalRuntime,现归各自进程)。 - -**wm-0004 的文件通道**:registered 文件能力从「worker 独立 owner-scoped 通道」改为「隧道 RPC」。wm-0004 的 seq/expiresAt/去重机制在隧道 RPC 侧是否需要,见 §5.4。 - -### 5.3 协议身份:用 workerId(破 wm-0003) - -**现状问题**:ownerId 是为「同 owner 复用容器」而生(`WorkerProvisioner.owners = Map`,provisioner.ts:37),身兼复用 key + 协议路由 key,逼出 wm-0003。 - -**改用 workerId**:协议身份用 `Worker.id` 主键(launch 时预生成,worker 回连携带)。三层 key 分工: - -| 层 | key | 用途 | -|---|---|---| -| 协议身份(端点/Store) | workerId | poll/心跳/握手/命令路由 | -| 复用缓存(provisioner.owners) | (ownerId, runtimeId, isolationScope) | 同组合复用同一 worker | -| DB 防重 | (ownerId, runtimeId, isolationScope) @@unique | 防重复 launch | - -ownerId 退回纯业务字段。wm-0003「一 owner 一活跃 worker」自然推翻——协议层不再有此限制,防重靠 DB 复合约束。 - -**workerId 来源**:launch 时 server 预生成 workerId 写入 `Worker.id`(provisioner.ts:82 现状已 `randomUUID()` 传给 insertStarting,路 A 后明确为 Worker.id),worker 回连时携带。`instanceId`(provider 侧运行实例标识:pid/容器id)保留给 provider 内部,不进协议层。 - -### 5.4 推翻 wm-0004 后的投递语义 - -文件能力改走隧道 RPC(registered/docker/opensandbox)后,wm-0004 的 owner-scoped 文件通道(`WorkspaceFileCommandStore`/`sendFileCommand`/`waitForFileCommandResult`)退役。隧道 RPC 是同步 request/response,无队列重放,故 wm-0004 的 seq 计数器/expiresAt/processedCommands 去重在文件能力侧不再需要。 - -**但写操作幂等性仍需回答**:discard_file_change 等写操作,隧道 RPC 同步执行,runtime 进程崩在执行中途时是否有半完成状态。落地时定:写操作需幂等(同一 discard 重复执行安全)或带状态确认。native 的写操作走进程内直调,无重放问题。 - -**数据面 run 命令通道**(cancel/interrupt 等)的 seq 计数器:身份改 workerId 后,owner 级 seq 改 worker 级 seq。冷启动重放语义沿用 wm-0004(processedCommands 去重 Set 挡同进程内重复,跨重启靠 expiresAt)。 - -### 5.5 隧道 RPC 方法集(docker/opensandbox/registered 的 runtime 进程侧) - -| 方法 | 用途 | -|---|---| -| `launch` / `stop` / `destroy` | worker 载体生命周期 | -| `detect-env` | 检测 CLI 环境 | -| `list-files` / `read-file` | 文件预览 | -| `list-changed-files` / `read-file-diff` | git diff | -| `list-directory` / `create-directory` | 目录浏览/新建 | - -现状 tunnel.ts 已有 launch/stop/destroy/detect-env/list-dir/create-dir;新增 list-files/read-file/list-changed-files/read-file-diff。managed docker/opensandbox 与 registered 同集同语义。native 不经隧道(进程内直调)。 - -### 5.6 远程执行拓扑 - -**registered 现状(已是目标拓扑)**:runtime 进程收到 `runtime.launch` RPC → 调 provider.start fork worker → worker fork runner 跑 agent(tunnel.ts:174 → launcher.ts:45 → local-runtime.provider.ts:36)。拓扑:`runtime 进程 → fork worker → fork runner`。 - -**managed docker/opensandbox**:同 registered 拓扑,只是连本机 loopback。同一份 apps/runtime 产物。 - -**managed native**:留 server 进程内,server 直接 fork worker → fork runner。无中间 runtime 进程。 - -**产物**:managed docker/opensandbox 与 registered 用同一份 apps/runtime 产物;native 用 server 进程内 provider(现状)。 - -### 5.7 managed 容器 runtime 进程的崩溃恢复(B1) - -managed docker/opensandbox 的 runtime 进程是 server fork 的独立进程,会崩。处理: - -- **supervisor**:server fork 时记 runtime 进程 pid,监听 exit 事件。崩了自动重启 runtime 进程,重启后它重新连 server(loopback 隧道)。 -- **孤儿 worker**:runtime 进程崩时它 fork 的 worker 可能成孤儿。重启后的 runtime 进程负责清理(它知道自己的 worker pid);或 server 保留按 pid 兜底杀孤儿的能力(destroy 的孤儿清理不完全迁出 server)。 -- **不立刻判死**:隧道断连时,优先视为「进程重启中」,等重连宽限;超时未重连才判死(与 registered 的「断连即判死」区分,见 §4.3)。 - -registered 不需要 supervisor(远程机器不是 server 管的),断连走判死 + fence。 - -## 6. 能力归属通用判据 - -### 6.1 判据:按「文件物理位置 + 是否需要 agent 运行时」分 - -| 能力类型 | 判据 | 归属 | 例子 | -|---|---|---|---| -| 机器级能力(文件在 server 本机) | native,文件在 server 硬盘 | server 直读(LocalRuntime) | managed native 的文件/git | -| 机器级能力(文件在容器/远程) | docker/opensandbox/registered,server 碰不到 | runtime 进程经隧道 RPC | 容器/远程的文件/git | -| agent 级能力 | 需要 agent 运行时 | Worker(经 Runner) | run 执行、AG-UI 事件 | - -### 6.2 这精确化了 wm-0005 - -wm-0005「builtin 直读 / registered 走 worker」→「managed native 直读 / 容器+registered 走隧道 RPC」。native 直读保留(快);registered 从 worker 代理改隧道 RPC(不用拉起 worker);docker/opensandbox 新增隧道 RPC(归各自进程)。 - -### 6.3 写操作 - -discard_file_change 等写操作,按文件位置同上归属。安全校验(路径越界/symlink/大小)提取到 `@agework/shared/filesystem`,native 直读与 runtime 进程 RPC 共用。写操作幂等性见 §5.4。 - -## 7. 数据模型变更 - -| 表 | 变更 | 理由 | -|---|---|---| -| `Worker` | `ownerId @unique` → `(ownerId, runtimeId, isolationScope) @@unique` | 破 wm-0003,支持跨 workspace/跨机并行 | -| `Runtime` | source 值 builtin→managed;runtimeType 值 local→native;docker/opensandbox 的 managed 行加 tokenHash(非空) | 命名 + docker/opensandbox 走隧道需 token | -| `WorkerWorkspaceBinding` | 不变(workspaceId @unique 保留) | 不引入 isolationMode,同 workspace 仍一 worker | -| `Run` | runtimeInstanceId 语义不变 | — | - -新环境部署,不处理历史数据迁移(builtin-local → managed-native 等 id 改名无悬空外键问题)。 - -## 8. 模块边界变更 - -| 模块 | 变更 | -|---|---| -| `apps/server/src/runtime` | RuntimeService 按 runtimeType 分治:native 走进程内 LocalRuntime;docker/opensandbox/registered 走隧道 RPC。LocalRuntime 保留但收窄到 native。 | -| `apps/server/src/worker-manager` | 协议身份 ownerId→workerId(Store/端点/Dispatcher);provisioner.owners 复用 key 改复合;registered/docker 文件通道(sendFileCommand 等)退役改隧道 RPC;native 文件仍可走进程内直读(不经 worker) | -| `packages/providers` | 契约不变;native provider 留 server 进程内;docker/opensandbox provider 迁到 runtime 进程。包仍是叶子,不 import @agework/worker,worker 入口经 config 注入(launcher.ts:92 现状即此模式) | -| `packages/worker` | WorkspaceFileCommandHandler 仅 registered/docker 文件走 worker 时需要——但 §5.2 改隧道 RPC 后退役。runner 入口不变 | -| `apps/runtime` | 补全机器级能力 RPC(list-files/read-file/list-changed-files/read-file-diff);managed docker/opensandbox 复用同一产物,由 server fork + loopback | -| `packages/shared` | workerId 协议身份类型;机器级能力 RPC 方法集类型;filesystem 安全纯函数(native 直读与 runtime 进程共用) | -| `apps/server` 新增 | managed 容器 runtime 进程的 supervisor(fork + 监听 exit + 重启),见 §5.7 | - -## 9. 与现有 ADR 的关系 - -| ADR | 关系 | 说明 | -|---|---|---| -| wm-0001(worker 为主 / 载荷不变量) | **保留** | 不预热空闲池,worker 死即拆载体。主概念不变。 | -| wm-0002(start/stop/destroy) | **保留** | provider 契约不变。native 的 stop=destroy=杀进程(无独立载体,wm-0002 已述);docker/opensandbox 的 stop 留容器归 runtime 进程管。 | -| wm-0003(防重键维持裸 ownerId) | **推翻** | 协议身份改 workerId;复用 key 改 (ownerId, runtimeId, isolationScope);DB 防重改复合 @@unique。ownerId 退回业务字段。 | -| wm-0004(文件命令走独立 owner-scoped 通道) | **部分推翻** | registered/docker 文件能力改走隧道 RPC,wm-0004 的 worker 文件通道退役;但数据面 run 命令通道 + seq/expiresAt 语义保留(身份改 workerId)。 | -| wm-0005(builtin 直读 / registered 走 worker) | **保留+精确化** | builtin→managed native 直读(保留);registered 从 worker 代理改隧道 RPC;docker/opensandbox 新增隧道 RPC。 | -| runtime-0001(只软删除 + runtimeId 必填) | **保留** | runtimeId 必填支撑复用 key/防重的 runtimeId 维度。 | -| runtime-0002(envConfig 两层分离) | **保留** | — | -| runtime-0003(CliResolver 放 apps/runtime) | **保留** | runtime 进程即 apps/runtime,docker/opensandbox 检测归它;native 留 server 进程内(现状 LocalRuntime.detectEnv)。 | -| runtime-0004(container CLI 路径走 env) | **保留** | — | -| providers-0001(runtime provider 扩展点包) | **保留** | 包仍是叶子,消费者从 server 部分迁到 runtime 进程(docker/opensandbox),config 注入路径不变。 | -| apps/runtime-0001(SDK external + npm install) | **保留** | managed docker/opensandbox 与 registered 共用同一产物。 | -| packages/worker-0001(runner 独立入口) | **保留** | — | - -**命名迁移**:`source: builtin→managed`、`runtimeType: local→native`。`location` 字段本期不引入,② 真做时再加。 - -## 10. 未决(留待后续 ticket) - -- **managed 容器 runtime 进程 supervisor 细节**:fork 监听 exit 的具体实现、重启策略(立即/退避)、孤儿 worker 清理的 pid 兜底边界。机制已定(§5.7),实现细节留落地。 -- **写操作幂等性**:discard_file_change 等写操作在隧道 RPC 中途崩溃的幂等设计。归属已定,幂等方案留落地。 -- **能力 RPC 协议类型细化**:机器级能力 RPC 的请求/响应类型、错误码。方法集已定(§5.5),类型细节留落地。 -- **worker 侧改动面**:协议身份改 workerId 后,worker 侧 poll/register/handshake 读 env 的代码改动量评估。 \ No newline at end of file diff --git a/.scratch/runtime-redesign/issues/01-main-concept-and-responsibilities.md b/.scratch/runtime-redesign/issues/01-main-concept-and-responsibilities.md deleted file mode 100644 index c9525a1b..00000000 --- a/.scratch/runtime-redesign/issues/01-main-concept-and-responsibilities.md +++ /dev/null @@ -1,50 +0,0 @@ -# 01 — 重设计后谁是主概念:runtime、worker、载体的职责与状态归属 - -- Type: grilling -- Status: resolved -- Blocked by: (none) - -## Question - -本 map 的立场是「根本重设计角色」——允许推翻 wm-0001 的「worker 为主、runtime 是无状态运行载体」载荷不变量。那么重设计之后: - -1. **主概念是谁?** 当前模型里 `worker`(server 管理的常驻执行单元)是主概念,`runtime`(容器/fork 进程)是为 worker 服务的无状态载体。重设计后,主概念是 `runtime`(一台机器/一个执行环境,有独立状态)、还是 `worker`(一个被 server 观测的执行单元)、还是引入新的主概念(如「执行目标 execution target」「机器 machine」「会话 session」)?主概念的命名与定义是什么? - -2. **谁有持久化状态?** 当前只有 `WorkerInstance.status`(starting/running/stopped/error)被持久化,runtime 无独立状态。重设计后,哪些实体有持久化状态(存活、健康、负载、预热状态等)?是否存在「先于 / 独立于 worker 的载体」(如预热空闲容器池)?若存在,runtime 是否必须升为有独立状态的实体(即打破 wm-0001 载荷不变量)? - -3. **职责边界如何重划?** 以下职责在新模型里分别挂在谁身上: - - 机器注册与发现(谁代表「一台可用的机器」) - - 执行环境拉起与拆除(谁 start / stop / destroy 载体) - - agent 运行单元(谁 fork/拉起 runner,谁持有 agent 进程) - - 能力执行(文件/目录查看、git diff 等能力,谁承接 server 的请求) - - 命令/事件/文件通道的端点(谁是通信的主体) - -4. **与现有 ADR 的关系?** 你的重设计是否推翻 wm-0001(载荷不变量)、wm-0002(start/stop/destroy 三方法契约)、wm-0003(防重键维持裸 ownerId)?若推翻,逐条说明为什么值得重开、新不变量是什么;若保留,说明在新模型下这些不变量如何继续成立。 - -## 背景(供解 ticket 时参考,非约束) - -- 现状结构(Explore 结论):`RuntimeService` 门面下有 `LocalRuntime`(builtin,in-process)与 `RemoteRuntime`(registered,经 WS 隧道 JSON-RPC 转发);`RuntimeProvider`(packages/providers)有 local/docker/opensandbox 三实现,契约是 `start/stop/destroy`;`packages/worker` 是常驻进程,每个 run fork 一个 runner 子进程跑 agent adapter;server↔worker 走 HTTP 长轮询(命令下行)+ POST(事件/文件结果上行),server↔runtime manager 走 WS 隧道。 -- 数据模型:`Runtime` 表(source=builtin|registered、runtimeType、ownerId、status、capabilities、envConfig…),`Worker` 表(ownerId @unique、runtimeId 必填、instanceId、status),`WorkerWorkspaceBinding`(workspaceId @unique → workerId)。 -- wm-0001 原文的关键约束:「每个 runtime 载体恰好对应一个 worker;不存在没有 worker 的载体;worker 死即载体被拆」。这条一旦被打破,runtime 必须被拆成有自己状态的实体,wm-0001 与 worker-primary 命名都要重划——这正是本 ticket 要正面回答的岔路口。 - -## 期望产出 - -Answer 里给出:① 新主概念的命名与定义;② 有状态实体清单及其状态字段;③ 五项职责的归属表(职责 → 实体);④ 与 wm-0001/0002/0003 的关系(推翻/保留 + 理由 + 新不变量)。后续 ticket(并行模型、通信协议、远程执行、能力归属)全部依赖本答案,故本 ticket 是 map 的根,单独成 frontier。 - -## Answer - -详见 [design.md](../design.md) v2 定稿。核心结论: - -1. **主概念(保留 worker-primary,不引入 host/machine)**:runtime(机器+类型,Runtime 行)/ worker(常驻执行单元,Worker 行,身份=Worker.id=workerId)/ runner(worker 内 fork 的 run-scoped 子进程)。术语钉死见 design.md §2.0。wm-0001 载荷不变量保留(不预热空闲池,worker 死即拆)。 - -2. **进程拓扑(混合方案,非路 A 完全对称)**:managed native(本机非容器)留 server 进程内,直读 fs/git;managed docker/opensandbox + registered 起独立 runtime 进程,经隧道 RPC。不为对称性给 native 强加进程崩溃负担(B1)。演进:曾定路 A(完全对称),review 发现 B1(managed runtime 进程崩了孤儿 worker 没人杀)+ wm-0005 性能回退,收窄为混合。 - -3. **字段定稿(两字段)**:source(managed/registered)+ runtimeType(native/docker/opensandbox)。去重:现状 local 重载(非容器 vs 本机),非容器改 native,本机由 source=managed 兼带。砍 location(本期 managed=本机/registered=远程一一对应,② 真做时再加)。 - -4. **职责归属**:机器级能力(文件/git/环境)按文件物理位置分——native 在 server 本机硬盘→进程内直读;docker/opensandbox/registered 在容器/远程→隧道 RPC。agent 级能力(run/AG-UI)归 worker/runner。LocalRuntime 保留但收窄到只服务 native(06),越权问题解决。 - -5. **并行模型(沿用现状 + 破 wm-0003)**:同 workspace 多 runner(现状已支持)、隔离级别 user/workspace(现状)、跨 workspace 并行靠破 wm-0003。防重 key 从裸 ownerId 升级为 (ownerId, runtimeId, isolationScope) @@unique;协议身份改 workerId(ownerId 退回业务字段)。 - -6. **与 ADR 关系**:推翻 wm-0003(防重 key)、wm-0004 部分推翻(registered/docker 文件改隧道 RPC,run 命令通道保留)、wm-0005 保留+精确化(builtin→managed native 直读)。wm-0001/0002 及 runtime/providers/apps-runtime/packages-worker 全部保留。 - -落地交另一个 AI 按 [IMPLEMENTATION.md](../IMPLEMENTATION.md) + 7 个落地 ticket 执行。 diff --git a/.scratch/runtime-redesign/issues/01-rename-fields.md b/.scratch/runtime-redesign/issues/01-rename-fields.md deleted file mode 100644 index 5592412e..00000000 --- a/.scratch/runtime-redesign/issues/01-rename-fields.md +++ /dev/null @@ -1,55 +0,0 @@ -# 01 — 字段重命名:source builtin→managed, runtimeType local→native - -- Type: task -- Status: pending -- Blocked by: (none) - -## 目标 - -把 runtime 字段值重命名:`source: "builtin" → "managed"`、`runtimeType: "local" → "native"`。纯命名,不动逻辑。让后续 ticket 用新词。 - -## 依据 - -- design.md §2.2(字段定稿)、§2.0(术语) -- 去重:现状 `local` 一词重载(非容器化 vs 本机),非容器化改 `native` - -## 范围(改这些文件) - -**packages/providers**(runtimeType 值): -- `packages/providers/src/types.ts:8-12` —— `SUPPORTED_RUNTIME_TYPES` 的 `"local"` → `"native"` -- `packages/providers/src/local/` 目录 → 重命名为 `native/`(`local-runtime.provider.ts` → `native-runtime.provider.ts`,`LocalRuntimeProvider` → `NativeRuntimeProvider`) -- `packages/providers/src/registry.ts:16` —— `["local", ...]` → `["native", ...]` -- `packages/providers/src/runtime-spec.ts` —— `"local"` 判断改 `"native"` -- 对应 spec 文件 - -**apps/server**(source + runtimeType 值): -- `apps/server/src/runtime/runtime.types.ts:8,12` —— `BUILTIN_RUNTIME_ID_PREFIX = "builtin-"` → `"managed-"`,`builtinRuntimeId` → `managedRuntimeId`,`isBuiltinRuntimeId` → `isManagedRuntimeId` -- `apps/server/src/runtime/runtime.service.ts:54-66,442-444` —— upsert builtin 行用新 id;`builtinIsolationScopes` 里 `runtimeType === "local"` → `"native"` -- `apps/server/src/config/config.service.ts` —— `RuntimeType` 默认值/allowed 列表的 `"local"` → `"native"` -- 所有 `runtimeType === "local"` / `source === "builtin"` 判断(grep 全 repo) -- `apps/server/prisma/schema.prisma:298` —— source 默认值注释 - -**packages/shared**: -- API 契约类型里 `source: "builtin" | "registered"` → `"managed" | "registered"` -- `runtimeType: "local" | "docker" | "opensandbox"` → `"native" | "docker" | "opensandbox"` - -**apps/web**: -- `apps/web/src/types` / `apps/web/src/store` / `apps/web/src/api` 里对应值 - -## 不做 - -- 不改逻辑(只改字符串值/类名/文件名) -- 不处理历史数据(新环境,无 builtin-local 历史行) -- 不加 location 字段(本期 source=managed 兼带本机) -- 不碰 workerId/防重 key(那是 02/03) - -## 验收 - -1. `pnpm typecheck` 全过 -2. `pnpm test:server` + `pnpm --filter @agework/providers test` 全过 -3. `pnpm dev` 起来,managed-native runtime 行正确 upsert,文件预览/git diff 仍工作(native 走进程内直读,逻辑没变) -4. grep 确认 repo 内无残留 `"builtin"`(source 值)/`"local"`(runtimeType 值)——`local` 作为变量名/路径片段可保留,只改枚举值 - -## 依赖 - -无(第一个做) diff --git a/.scratch/runtime-redesign/issues/02-db-unique-key.md b/.scratch/runtime-redesign/issues/02-db-unique-key.md deleted file mode 100644 index cf625539..00000000 --- a/.scratch/runtime-redesign/issues/02-db-unique-key.md +++ /dev/null @@ -1,41 +0,0 @@ -# 02 — DB 防重 key:Worker.ownerId @unique → 复合 @@unique - -- Type: task -- Status: pending -- Blocked by: 01 - -## 目标 - -破 wm-0003 的 DB 层:`Worker.ownerId @unique`(裸)→ `(ownerId, runtimeId, isolationScope) @@unique`,允许同一 owner 跨 workspace/跨机器并行多个 worker。 - -## 依据 - -- design.md §3.3(破 wm-0003)、§7(数据模型) -- wm-0003 ADR(推翻):原文警告「协议整条链用裸 ownerId」——本 ticket 只改 DB 层,协议层在 03 改 - -## 范围 - -- `apps/server/prisma/schema.prisma:227-259`(Worker model): - - 删 `ownerId String @unique`(:237) - - 加 `@@unique([ownerId, runtimeId, isolationScope])` - - 更新 :232-236 的注释(原注释解释「为什么维持裸 ownerId」,改为「为什么升级成复合」) -- `apps/server/src/worker-manager/registry/worker-registry.repository.ts` —— insertStarting/upsertRunning 的唯一冲突判断,从按 ownerId 改按复合 key -- `apps/server/src/worker-manager/instance/worker.provisioner.ts:50-63` —— `owners` Map 复用 key 从 `ownerId` 改 `(ownerId, runtimeId, isolationScope)`(注:协议身份改 workerId 在 03,这里先改复用缓存 key) -- 对应 spec - -## 不做 - -- 不改协议层(端点/Store/Dispatcher 的 key)——那是 03 -- 不改 WorkerWorkspaceBinding(workspaceId @unique 保留,不引入 isolationMode) -- 不改 Runtime 表 - -## 验收 - -1. `pnpm db:push` 成功,migration 生成 -2. `pnpm --filter server typecheck` + `pnpm test:server` 过 -3. 手工:同一 owner 在两个不同 workspace 能同时起 worker(之前会冲突,现在不冲突) -4. 同一 (owner, runtime, isolationScope) 仍不能重复 launch(复合唯一约束生效) - -## 依赖 - -01(用新字段值命名) diff --git a/.scratch/runtime-redesign/issues/03-protocol-worker-id.md b/.scratch/runtime-redesign/issues/03-protocol-worker-id.md deleted file mode 100644 index 314d2689..00000000 --- a/.scratch/runtime-redesign/issues/03-protocol-worker-id.md +++ /dev/null @@ -1,51 +0,0 @@ -# 03 — 协议身份:ownerId → workerId - -- Type: task -- Status: pending -- Blocked by: 02 - -## 目标 - -协议层身份从裸 `ownerId` 改 `workerId`(= `Worker.id` 主键)。poll/心跳/握手/命令路由全按 workerId。ownerId 退回纯业务字段。 - -## 依据 - -- design.md §5.3(协议身份 workerId)、§5.4(改动清单) -- wm-0003 推翻的协议层部分 - -## 范围 - -**端点路径**: -- `apps/server/src/worker-manager/command.controller.ts` + `worker-run.controller.ts` —— `/worker/owners/:ownerId/commands` → `/worker/:workerId/commands`(或保留 ownerId 路径但加 workerId,见 design.md §5.4,推荐直接改 workerId) -- `apps/server/src/worker-manager/connection/workspace-file-command.controller.ts` —— 文件结果端点(注:文件通道在 07 退役,这里先改 key) - -**Store / Dispatcher**(key 全改 workerId): -- `apps/server/src/worker-manager/connection/worker-liveness.store.ts` —— `Map` → `Map` -- `apps/server/src/worker-manager/connection/worker-handshake.store.ts` —— `Map` → `Map` -- `apps/server/src/worker-manager/connection/command-dispatcher.ts` + `command-queue.ts` —— sendCommand/poll 按 workerId -- `apps/server/src/worker-manager/instance/owner-run.store.ts` —— `runId → ownerId` → `runId → workerId`(+ 可反查 ownerId) -- `apps/server/src/worker-manager/worker-manager.service.ts` —— pollCommands/registerWorker/sendCommand 等签名 - -**worker 侧**: -- `packages/worker/src/transport/worker-http.ts` —— poll/register 携带 workerId -- `packages/worker/src/worker.ts` —— 读 env `AGEWORK_WORKER_ID` -- `apps/server/src/worker-manager/instance/worker.provisioner.ts:314-343`(buildWorkerEnv)—— env 注入 `AGEWORK_WORKER_ID`(launch 时预生成 workerId = Worker.id),保留 `AGEWORK_WORKER_OWNER_ID` 作业务字段 - -**workerId 来源**:provisioner launch 时 `randomUUID()` 写入 `Worker.id`(现状 :82 已传 uuid 给 insertStarting,确认它就是 Worker.id),worker 回连时携带。 - -## 不做 - -- 不改 managed 容器起进程(04) -- 不改文件能力通道(07) -- 不改复用缓存 key(02 已改) - -## 验收 - -1. `pnpm typecheck` + `pnpm test:server` + `pnpm --filter worker test` 过 -2. worker 回连用 workerId,poll/心跳/握手按 workerId 路由 -3. 同一 owner 多个 worker(02 已放开)的命令不串(各按 workerId 路由) -4. fence 判死按 workerId - -## 依赖 - -02(DB 已放开复合 key) diff --git a/.scratch/runtime-redesign/issues/04-managed-container-runtime-process.md b/.scratch/runtime-redesign/issues/04-managed-container-runtime-process.md deleted file mode 100644 index 637054ef..00000000 --- a/.scratch/runtime-redesign/issues/04-managed-container-runtime-process.md +++ /dev/null @@ -1,55 +0,0 @@ -# 04 — managed docker/opensandbox 起独立 runtime 进程 + supervisor - -- Type: task -- Status: pending -- Blocked by: 01, 03 - -## 目标 - -混合方案核心:managed docker/opensandbox 起独立 runtime 进程(跑 apps/runtime),经 loopback 隧道 RPC launch/stop/destroy。进程崩了 supervisor 自动重启。managed native 仍留 server 进程内(不动)。 - -## 依据 - -- design.md §1(混合方案)、§4.1(进程定位)、§5.6(执行拓扑)、§5.7(supervisor/B1) -- wm-0002(start/stop/destroy 契约保留) - -## 范围 - -**server 侧 fork managed 容器 runtime 进程**: -- 新增 supervisor(位置:`apps/server/src/runtime/managed/` 或 worker-manager 子目录,按 backend-architecture 判定): - - server 启动时按 allowedRuntimeTypes,docker/opensandbox 各 fork 一个 apps/runtime 进程 - - 注入 loopback server 地址 + managed token(预生成,存 Runtime 行 tokenHash,非空) - - 记 runtime 进程 pid,监听 exit;崩了自动重启(退避策略:立即→指数退避封顶) - - 重启后 runtime 进程重新连 server(loopback 隧道) -- `apps/server/src/runtime/runtime.service.ts` —— onApplicationBootstrap 对 docker/opensandbox 不再 upsert 后直读,改为 fork 进程 + 等注册 - -**runtime 进程侧**(apps/runtime,复用 registered 现有逻辑): -- `apps/runtime/src/registered/tunnel.ts` + `launcher.ts` —— 现状已支持 launch/stop/destroy 经隧道 RPC,managed 复用同一份代码,只是连 loopback -- managed token 的注入:apps/runtime config 接受 managed 模式(区别 registered 的 admin 发 token) - -**断连语义分治**(design.md §4.3): -- managed 容器隧道断连 → 视为「进程重启中」,等重连宽限,不立刻判死 -- registered 隧道断连 → 维持现状判死 -- `apps/server/src/runtime/gateway/runtime-tunnel.handler.ts` + `runtime-liveness.watchdog.ts` —— 按 source 分治断连处理 - -**孤儿 worker 清理**(design.md §5.7): -- runtime 进程崩时它 fork 的 worker 成孤儿 -- 重启后的 runtime 进程负责清理(它知道自己的 worker);或 server 保留按 pid 兜底杀孤儿(destroy 的孤儿清理不完全迁出 server——这点在 ticket 里定:选 runtime 进程自清理,server 不持 pid) - -## 不做 - -- 不碰 managed native(留 server 进程内,现状) -- 不补能力 RPC(list-files 等,那是 05) -- 不实现 ②(远程但 server 管) - -## 验收 - -1. `pnpm typecheck` + `pnpm test:server` 过 -2. `pnpm dev`,managed-docker runtime 进程被 server fork 起来,loopback 隧道连上,注册成功 -3. 手工 kill managed-docker runtime 进程,supervisor 自动重启,重连成功 -4. runtime 进程崩时其 worker 被清理(不泄漏) -5. managed native 仍走 server 进程内(不受影响) - -## 依赖 - -01(命名 managed/native)、03(workerId 协议身份) diff --git a/.scratch/runtime-redesign/issues/05-capability-rpc.md b/.scratch/runtime-redesign/issues/05-capability-rpc.md deleted file mode 100644 index f540eb76..00000000 --- a/.scratch/runtime-redesign/issues/05-capability-rpc.md +++ /dev/null @@ -1,53 +0,0 @@ -# 05 — 能力 RPC 补全:tunnel 新增 list-files/read-file/git-diff - -- Type: task -- Status: pending -- Blocked by: 04 - -## 目标 - -runtime 进程侧隧道 RPC 补全机器级能力:list-files / read-file / list-changed-files / read-file-diff。现状 tunnel.ts 只有 list-dir/create-dir,缺文件预览和 git diff。 - -## 依据 - -- design.md §5.5(RPC 方法集)、§5.2(能力归属)、§6(能力判据) -- wm-0005 精确化:registered/docker 文件/git 走隧道 RPC - -## 范围 - -**runtime 进程侧(apps/runtime)**: -- `apps/runtime/src/registered/tunnel.ts:163-187`(dispatch switch)—— 新增 case: - - `runtime.list-files` / `runtime.read-file` - - `runtime.list-changed-files` / `runtime.read-file-diff` -- 文件/git 实现复用 `packages/shared/filesystem`(见下)—— runtime 进程在那台机器上直读 fs / 跑 git -- `apps/runtime/src/filesystem/` —— 新增 file-browser.ts(或从 worker 侧迁),调 shared 安全纯函数 - -**shared 安全纯函数提取**(wm-0005 已规划): -- `packages/shared/src/filesystem/path-safety.ts` —— validateRelativePath, resolveWithinRoot -- `packages/shared/src/filesystem/file-browser.ts` —— listFiles, readFile(纯函数,异步 fs/promises) -- 从 `packages/worker/src/files/workspace-file-browser.ts` 提取(现状 worker 已有完整安全校验:越界/symlink/大小/二进制) - -**协议类型**: -- `packages/shared/src/protocol/runtime-tunnel.ts` —— 新增 RPC 方法类型 + params/result - -**server 侧调用**: -- `apps/server/src/runtime/remote/remote-runtime.ts` —— 新增 listFiles/readFile/listChangedFiles/readFileDiff,经隧道 RPC 调 -- `apps/server/src/runtime/runtime.service.ts:291-346` —— 这些方法对 docker/opensandbox/registered 走 RemoteRuntime(隧道 RPC);native 仍走进程内直读(06 收窄) - -## 不做 - -- 不改 native(native 直读,06 处理 LocalRuntime 收窄) -- 不退役 wm-0004 worker 文件通道(07) -- 不定写操作幂等(§10 未决,本 ticket 只做读能力;写操作 discard_file_change 留后续) - -## 验收 - -1. `pnpm typecheck` + `pnpm --filter @agework/shared test` + `pnpm test:server` 过 -2. shared filesystem 安全单测:越界 `..`/绝对路径/symlink 逃逸/大小截断/二进制拒绝 -3. registered runtime 文件预览走隧道 RPC(不拉起 worker),git diff 工作 -4. managed docker/opensandbox 文件预览走 loopback 隧道 RPC -5. native 文件预览仍走进程内直读(不变) - -## 依赖 - -04(managed 容器 runtime 进程在) diff --git a/.scratch/runtime-redesign/issues/06-local-runtime-narrow.md b/.scratch/runtime-redesign/issues/06-local-runtime-narrow.md deleted file mode 100644 index b6a44d56..00000000 --- a/.scratch/runtime-redesign/issues/06-local-runtime-narrow.md +++ /dev/null @@ -1,40 +0,0 @@ -# 06 — LocalRuntime 收窄:只服务 native - -- Type: task -- Status: pending -- Blocked by: 05 - -## 目标 - -LocalRuntime 职责收窄到只服务 managed native(进程内直读 fs/git)。docker/opensandbox 的能力已迁到各自 runtime 进程(05),LocalRuntime 不再越权替它们干活。 - -## 依据 - -- design.md §2.3(职责归属)、§5.2(能力归属)、§8(模块边界) -- 解决最初痛点:LocalRuntime 越权替 docker/opensandbox 干文件/git - -## 范围 - -- `apps/server/src/runtime/local/local-runtime.ts` —— 保留,但明确只服务 native。listFiles/readFile/listChangedFiles/readFileDiff 仍直读本机 fs/git(native 的 workspace 在本机硬盘) -- `apps/server/src/runtime/runtime.service.ts:74-83`(runtimeFor)—— 路由分治: - - managed native → LocalRuntime(进程内直读) - - managed docker/opensandbox + registered → RemoteRuntime(隧道 RPC,05 已补能力) -- `apps/server/src/runtime/runtime.service.ts:291-346` —— listFiles 等方法按 runtimeType/source 分治:native 调 localRuntime;其余调 RemoteRuntime(隧道) -- 删 LocalRuntime 里替 docker/opensandbox 干活的越权路径(如果有的话,grep 确认) - -## 不做 - -- 不删 LocalRuntime 类(native 还要用它直读) -- 不改 native 的直读逻辑(现状已验证 10-50ms) -- 不碰 worker 文件通道(07) - -## 验收 - -1. `pnpm typecheck` + `pnpm test:server` 过 -2. native 文件/git 走进程内直读(LocalRuntime),性能仍 10-50ms -3. docker/opensandbox/registered 文件/git 走隧道 RPC(不经 LocalRuntime) -4. LocalRuntime 不再被 docker/opensandbox 路径调用(grep 确认) - -## 依赖 - -05(容器能力 RPC 已就位,LocalRuntime 才能放手) diff --git a/.scratch/runtime-redesign/issues/07-retire-wm0004-file-channel.md b/.scratch/runtime-redesign/issues/07-retire-wm0004-file-channel.md deleted file mode 100644 index 90507708..00000000 --- a/.scratch/runtime-redesign/issues/07-retire-wm0004-file-channel.md +++ /dev/null @@ -1,52 +0,0 @@ -# 07 — wm-0004 文件通道退役 - -- Type: task -- Status: pending -- Blocked by: 05 - -## 目标 - -registered/docker 文件能力已改走隧道 RPC(05),wm-0004 的 worker 独立 owner-scoped 文件通道退役:移除 WorkspaceFileCommandStore / sendFileCommand / waitForFileCommandResult / ensureWorkerForFilePreview。 - -## 依据 - -- design.md §5.2(机器级能力统一走隧道 RPC)、§5.4(推翻 wm-0004 后投递语义)、§8(模块边界) -- wm-0004 部分推翻:文件能力改隧道 RPC,run 命令通道保留 - -## 范围 - -**移除组件**: -- `apps/server/src/worker-manager/connection/workspace-file-command.store.ts` —— 删 -- `apps/server/src/worker-manager/connection/workspace-file-command.controller.ts` —— 删(文件结果端点) -- `apps/server/src/worker-manager/worker-manager.service.ts:144-166` —— sendFileCommand/waitForFileCommandResult/cancelFileCommand/resolveFileCommandResult 删 -- `apps/server/src/worker-manager/instance/worker.provisioner.ts:199-272`(ensureWorkerForFilePreview)—— 删(文件预览不再需要拉起 worker) -- `packages/worker/src/files/workspace-file-command.handler.ts` —— 删(worker 不再处理文件命令) - -**协议类型**: -- `packages/shared/src/protocol/workspace-file-command.ts` —— WorkspaceFileCommandPayload/OwnerCommand 文件分支移除(OwnerCommand 联合类型如果只剩这一种,整个删;如果还有别的,保留联合) -- `packages/shared/src/protocol/run-channel-message.ts` —— RunChannelMessage | OwnerCommand 联合里去掉文件 OwnerCommand - -**调用方改路**: -- `apps/server/src/workspace/workspace.service.ts` —— 文件预览从「ensureWorkerForFilePreview + sendFileCommand」改为「RuntimeService.listFiles/readFile」(06 已让 RuntimeService 按 source 路由:native 直读 / 其余隧道 RPC) - -**保留**: -- 数据面 run 命令通道(cancel/interrupt/approval/user_message)—— 不动,身份已是 workerId(03) -- seq/expiresAt 语义 —— run 命令通道仍用(身份改 workerId,owner 级 seq 改 worker 级 seq) - -## 不做 - -- 不删 run 命令通道(只删文件通道) -- 不改 native 文件预览(走进程内,不经这些组件) -- 不定写操作幂等(§10 未决,discard_file_change 留后续) - -## 验收 - -1. `pnpm typecheck` + `pnpm test:server` + `pnpm --filter worker test` 过 -2. registered/docker 文件预览走隧道 RPC(05),不拉起 worker -3. native 文件预览走进程内直读(06) -4. grep 确认 sendFileCommand/waitForFileCommandResult/ensureWorkerForFilePreview 无残留调用 -5. run 命令通道(cancel/interrupt)仍工作 - -## 依赖 - -05(隧道 RPC 文件能力就位,才能拆 worker 代理通道) diff --git a/.scratch/runtime-redesign/map.md b/.scratch/runtime-redesign/map.md deleted file mode 100644 index 836ee92c..00000000 --- a/.scratch/runtime-redesign/map.md +++ /dev/null @@ -1,55 +0,0 @@ -# Map: runtime/worker 角色与通信顶层重设计 - -## Destination - -走到头手里拿到的是:**runtime / worker 角色能力、能力归属判断标准、注册机制、通信协议的顶层重设计文档 + 配套 ADR**。设计部分已完成(`design.md` v2 定稿);落地实现交由另一个 AI 按 `IMPLEMENTATION.md` + 7 个落地 ticket 执行,不在本 map 的 grilling 范围。 - -## Notes - -- **域**:runtime / worker / provider 顶层架构。涉及 context:`apps/runtime`、`apps/server` runtime module、`apps/server` worker-manager module、`packages/providers`、`packages/worker`。 -- **最终立场(混合方案)**:managed native(本机非容器)留 server 进程内,直读 fs/git;managed docker/opensandbox + registered 起独立 runtime 进程,经隧道 RPC。不为对称性给 native 强加进程崩溃负担。演进过程见 design.md 顶部「立场演进」。 -- **产出**:设计文档 `design.md` + 落地交接 `IMPLEMENTATION.md` + 7 个落地 ticket。落地实现由另一个 AI 执行。 -- **必读 ADR(决策层现状)**: - - worker-manager:`0001`(worker 为主)/`0002`(start/stop/destroy)/`0003`(防重 key,**推翻**)/`0004`(文件通道,**部分推翻**)/`0005`(builtin 直读,**保留+精确化**) - - runtime:`0001`(软删除+runtimeId 必填)/`0002`(envConfig 两层)/`0003`(CliResolver 放 apps/runtime)/`0004`(container CLI 走 env) - - providers:`0001`(扩展点包) - - apps/runtime:`0001`(SDK external + npm install) - - packages/worker:`0001`(runner 独立入口) -- **术语(design.md §2.0,全程统一)**:runtime / worker(不叫「worker 实例」)/ runner / instanceId(物理载体标识,不当 worker 身份)。**不引入 host/manager/machine/载体**。 -- **issue tracker 约定**:本地 markdown。map = 本文件;设计 ticket = `issues/01-main-concept-and-responsibilities.md`(已 resolve);落地 ticket = `issues/01-rename-fields.md` ~ `07-retire-wm0004-file-channel.md`。详见 `docs/agents/issue-tracker.md`。 -- **ADR 落点**:推翻/部分推翻的 ADR,落地时写新 ADR 放对应 context `docs/adr/`,显式写「推翻 wm-000X,因为…」。 - -## Decisions so far - - - -- [01 主概念与职责](issues/01-main-concept-and-responsibilities.md) — 混合方案:managed native 留 server 进程内直读,managed docker/opensandbox + registered 起独立 runtime 进程走隧道 RPC;不引入 host/machine,runtime 自己表达机器+类型;字段 source(managed/registered)+ runtimeType(native/docker/opensandbox)两字段,砍 location;协议身份用 workerId 破 wm-0003;能力归属按文件物理位置分(native 直读 / 容器+registered 隧道 RPC);推翻 wm-0003/0004(部分)/0005(精确化非推翻)。详见 [design.md](design.md)。 - -## 落地 ticket(交另一个 AI 实现,按依赖顺序) - -| # | ticket | 依赖 | gist | -|---|---|---|---| -| 01 | [字段重命名](issues/01-rename-fields.md) | — | source: builtin→managed;runtimeType: local→native | -| 02 | [DB 防重 key](issues/02-db-unique-key.md) | 01 | Worker.ownerId @unique → (ownerId,runtimeId,isolationScope) @@unique | -| 03 | [协议身份 workerId](issues/03-protocol-worker-id.md) | 02 | 端点/Store/Dispatcher 从 ownerId 改 workerId | -| 04 | [managed 容器起独立 runtime 进程 + supervisor](issues/04-managed-container-runtime-process.md) | 01,03 | docker/opensandbox 经隧道 RPC launch;进程崩了 supervisor 重启(B1) | -| 05 | [能力 RPC 补全](issues/05-capability-rpc.md) | 04 | tunnel 新增 list-files/read-file/list-changed-files/read-file-diff | -| 06 | [LocalRuntime 收窄](issues/06-local-runtime-narrow.md) | 05 | 只服务 native;docker/opensandbox 能力迁出 | -| 07 | [wm-0004 文件通道退役](issues/07-retire-wm0004-file-channel.md) | 05 | registered/docker 文件改隧道 RPC 后移除 worker 代理通道 | - -入口:[IMPLEMENTATION.md](IMPLEMENTATION.md)(落地总纲,给实现方的必读/顺序/验收/约束)。 - -## Not yet specified - - - -- **supervisor 实现细节**:fork 监听 exit 的具体实现、重启退避策略、孤儿 worker 清理边界(design.md §5.7 定机制,落地 ticket 04 细化)。 -- **写操作幂等性**:discard_file_change 等写操作在隧道 RPC 中途崩溃的幂等设计(design.md §5.4/§6.3 提出,落地时定)。 -- **能力 RPC 协议类型**:请求/响应类型、错误码(design.md §5.5 定方法集,落地 ticket 05 细化)。 - -## Out of scope - -- **agent adapter 内部实现**:Claude/Codex adapter(`packages/adapters`)的 SDK 调用、AG-UI 事件产出不在本次范围;只关心 runtime/worker 侧为 agent 提供什么执行环境与通道。 -- **CLI 二进制安装与镜像构建**:apps/runtime-0001、runtime-0004 已拍板的构建链路不在本次范围。 -- **② 远程但 server 管进程生死**:本期 source=managed 隐含本机,② 留未来(真做时加 location 字段)。 -- **历史数据迁移**:新环境部署,builtin-local→managed-native 等改名无悬空外键问题,不写迁移脚本。 diff --git a/1.logs b/1.logs deleted file mode 100644 index aa3b7809..00000000 --- a/1.logs +++ /dev/null @@ -1,636 +0,0 @@ -data: {"type":"RUN_STARTED","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b"} - -data: {"type":"REASONING_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00d128df55ad93e1016a53a2a508bc8197a95eb12d80e3c4cd"} - -data: {"type":"REASONING_MESSAGE_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00d128df55ad93e1016a53a2a508bc8197a95eb12d80e3c4cd","role":"reasoning"} - -data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00d128df55ad93e1016a53a2a508bc8197a95eb12d80e3c4cd","delta":"**Planning frontend skill assessment**"} - -data: {"type":"REASONING_MESSAGE_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00d128df55ad93e1016a53a2a508bc8197a95eb12d80e3c4cd"} - -data: {"type":"REASONING_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00d128df55ad93e1016a53a2a508bc8197a95eb12d80e3c4cd"} - -data: {"type":"TEXT_MESSAGE_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","role":"assistant"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"我"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"先"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"检查"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"项目"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"里的"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" HTML"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"样"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"式"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"和"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"页面"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"结构"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":",再"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"按"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"现"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"有"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"技术"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"栈"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"做"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"一"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"轮"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"视觉"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"与"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"交"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"互"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"优化"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"。"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"这个"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"任务"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"用"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" `"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"design"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"-t"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"aste"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"-"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"frontend"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"`"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" 和"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" `"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"frontend"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"-s"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"kill"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"`"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":":"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"前"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"者"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"帮助"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"定"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"设计"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"方向"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":","} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"后"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"者"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"约"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"束"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"页面"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"层"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"级"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"动"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"效"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"与"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"组件"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"质"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"感"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":";"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"我"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"不会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"自动"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" build"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"lint"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" 或"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"打开"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"浏览"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"器"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":",只"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"在"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"必要"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"时"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"做"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" type"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":" check"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c","delta":"。"} - -data: {"type":"TEXT_MESSAGE_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00d128df55ad93e1016a53a2a5c9b8819797008f453ac3016c"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-ab5b0eb6-09be-4d03-99f3-60c098a69d8c","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-ab5b0eb6-09be-4d03-99f3-60c098a69d8c","delta":"{\n \"command\": \"/bin/zsh -lc \\\"pwd && rg --files -g 'AGENTS.md' -g '\\\"'!node_modules'\\\"' -g '\\\"'!dist'\\\"' && rg --files -g '\\\"'!node_modules'\\\"' -g '\\\"'!dist'\\\"' | sed -n '1,160p'\\\"\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-ab5b0eb6-09be-4d03-99f3-60c098a69d8c-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-ab5b0eb6-09be-4d03-99f3-60c098a69d8c","content":"{\n \"status\": \"failed\",\n \"exitCode\": 1,\n \"output\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\\n\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-ab5b0eb6-09be-4d03-99f3-60c098a69d8c"} - -data: {"type":"CUSTOM","name":"token_usage","value":{"total":{"totalTokens":14334,"inputTokens":14090,"cachedInputTokens":4864,"outputTokens":244,"reasoningOutputTokens":27},"last":{"totalTokens":14334,"inputTokens":14090,"cachedInputTokens":4864,"outputTokens":244,"reasoningOutputTokens":27},"modelContextWindow":353400}} - -data: {"type":"REASONING_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0cca2fc562ba9291016a53a2ad22fc81938100b4bdd081b27b"} - -data: {"type":"REASONING_MESSAGE_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0cca2fc562ba9291016a53a2ad22fc81938100b4bdd081b27b","role":"reasoning"} - -data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0cca2fc562ba9291016a53a2ad22fc81938100b4bdd081b27b","delta":"**Verifying file presence with ls**"} - -data: {"type":"REASONING_MESSAGE_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0cca2fc562ba9291016a53a2ad22fc81938100b4bdd081b27b"} - -data: {"type":"REASONING_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0cca2fc562ba9291016a53a2ad22fc81938100b4bdd081b27b"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-d4defc26-514e-4315-9dcf-208868e18737","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-d4defc26-514e-4315-9dcf-208868e18737","delta":"{\n \"command\": \"/bin/zsh -lc \\\"sed -n '1,320p' /Users/mew/.codex/skills/frontend-skill/SKILL.md\\\"\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-29f99a9f-c2fd-441c-becf-988f90994a38","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-29f99a9f-c2fd-441c-becf-988f90994a38","delta":"{\n \"command\": \"/bin/zsh -lc \\\"sed -n '1,260p' /Users/mew/.agents/skills/taste-skill/SKILL.md\\\"\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-d4defc26-514e-4315-9dcf-208868e18737-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-d4defc26-514e-4315-9dcf-208868e18737","content":"{\n \"status\": \"completed\",\n \"exitCode\": 0,\n \"output\": \"---\\nname: frontend-skill\\ndescription: Use when the task asks for a visually strong landing page, website, app, prototype, demo, or game UI. This skill enforces restrained composition, image-led hierarchy, cohesive content structure, and tasteful motion while avoiding generic cards, weak branding, and UI clutter.\\n---\\n\\n# Frontend Skill\\n\\nUse this skill when the quality of the work depends on art direction, hierarchy, restraint, imagery, and motion rather than component count.\\n\\nGoal: ship interfaces that feel deliberate, premium, and current. Default toward award-level composition: one big idea, strong imagery, sparse copy, rigorous spacing, and a small number of memorable motions.\\n\\n## Working Model\\n\\nBefore building, write three things:\\n\\n- visual thesis: one sentence describing mood, material, and energy\\n- content plan: hero, support, detail, final CTA\\n- interaction thesis: 2-3 motion ideas that change the feel of the page\\n\\nEach section gets one job, one dominant visual idea, and one primary takeaway or action.\\n\\n## Beautiful Defaults\\n\\n- Start with composition, not components.\\n- Prefer a full-bleed hero or full-canvas visual anchor.\\n- Make the brand or product name the loudest text.\\n- Keep copy short enough to scan in seconds.\\n- Use whitespace, alignment, scale, cropping, and contrast before adding chrome.\\n- Limit the system: two typefaces max, one accent color by default.\\n- Default to cardless layouts. Use sections, columns, dividers, lists, and media blocks instead.\\n- Treat the first viewport as a poster, not a document.\\n\\n## Landing Pages\\n\\nDefault sequence:\\n\\n1. Hero: brand or product, promise, CTA, and one dominant visual\\n2. Support: one concrete feature, offer, or proof point\\n3. Detail: atmosphere, workflow, product depth, or story\\n4. Final CTA: convert, start, visit, or contact\\n\\nHero rules:\\n\\n- One composition only.\\n- Full-bleed image or dominant visual plane.\\n- Canonical full-bleed rule: on branded landing pages, the hero itself must run edge-to-edge with no inherited page gutters, framed container, or shared max-width; constrain only the inner text/action column.\\n- Brand first, headline second, body third, CTA fourth.\\n- No hero cards, stat strips, logo clouds, pill soup, or floating dashboards by default.\\n- Keep headlines to roughly 2-3 lines on desktop and readable in one glance on mobile.\\n- Keep the text column narrow and anchored to a calm area of the image.\\n- All text over imagery must maintain strong contrast and clear tap targets.\\n\\nIf the first viewport still works after removing the image, the image is too weak. If the brand disappears after hiding the nav, the hierarchy is too weak.\\n\\nViewport budget:\\n\\n- If the first screen includes a sticky/fixed header, that header counts against the hero. The combined header + hero content must fit within the initial viewport at common desktop and mobile sizes.\\n- When using `100vh`/`100svh` heroes, subtract persistent UI chrome (`calc(100svh - header-height)`) or overlay the header instead of stacking it in normal flow.\\n\\n## Apps\\n\\nDefault to Linear-style restraint:\\n\\n- calm surface hierarchy\\n- strong typography and spacing\\n- few colors\\n- dense but readable information\\n- minimal chrome\\n- cards only when the card is the interaction\\n\\nFor app UI, organize around:\\n\\n- primary workspace\\n- navigation\\n- secondary context or inspector\\n- one clear accent for action or state\\n\\nAvoid:\\n\\n- dashboard-card mosaics\\n- thick borders on every region\\n- decorative gradients behind routine product UI\\n- multiple competing accent colors\\n- ornamental icons that do not improve scanning\\n\\nIf a panel can become plain layout without losing meaning, remove the card treatment.\\n\\n## Imagery\\n\\nImagery must do narrative work.\\n\\n- Use at least one strong, real-looking image for brands, venues, editorial pages, and lifestyle products.\\n- Prefer in-situ photography over abstract gradients or fake 3D objects.\\n- Choose or crop images with a stable tonal area for text.\\n- Do not use images with embedded signage, logos, or typographic clutter fighting the UI.\\n- Do not generate images with built-in UI frames, splits, cards, or panels.\\n- If multiple moments are needed, use multiple images, not one collage.\\n\\nThe first viewport needs a real visual anchor. Decorative texture is not enough.\\n\\n## Copy\\n\\n- Write in product language, not design commentary.\\n- Let the headline carry the meaning.\\n- Supporting copy should usually be one short sentence.\\n- Cut repetition between sections.\\n- Do not include prompt language or design commentary into the UI.\\n- Give every section one responsibility: explain, prove, deepen, or convert.\\n\\nIf deleting 30 percent of the copy improves the page, keep deleting.\\n\\n## Utility Copy For Product UI\\n\\nWhen the work is a dashboard, app surface, admin tool, or operational workspace, default to utility copy over marketing copy.\\n\\n- Prioritize orientation, status, and action over promise, mood, or brand voice.\\n- Start with the working surface itself: KPIs, charts, filters, tables, status, or task context. Do not introduce a hero section unless the user explicitly asks for one.\\n- Section headings should say what the area is or what the user can do there.\\n- Good: \\\"Selected KPIs\\\", \\\"Plan status\\\", \\\"Search metrics\\\", \\\"Top segments\\\", \\\"Last sync\\\".\\n- Avoid aspirational hero lines, metaphors, campaign-style language, and executive-summary banners on product surfaces unless specifically requested.\\n- Supporting text should explain scope, behavior, freshness, or decision value in one sentence.\\n- If a sentence could appear in a homepage hero or ad, rewrite it until it sounds like product UI.\\n- If a section does not help someone operate, monitor, or decide, remove it.\\n- Litmus check: if an operator scans only headings, labels, and numbers, can they understand the page immediately?\\n\\n## Motion\\n\\nUse motion to create presence and hierarchy, not noise.\\n\\nShip at least 2-3 intentional motions for visually led work:\\n\\n- one entrance sequence in the hero\\n- one scroll-linked, sticky, or depth effect\\n- one hover, reveal, or layout transition that sharpens affordance\\n\\nPrefer Framer Motion when available for:\\n\\n- section reveals\\n- shared layout transitions\\n- scroll-linked opacity, translate, or scale shifts\\n- sticky storytelling\\n- carousels that advance narrative, not just fill space\\n- menus, drawers, and modal presence effects\\n\\nMotion rules:\\n\\n- noticeable in a quick recording\\n- smooth on mobile\\n- fast and restrained\\n- consistent across the page\\n- removed if ornamental only\\n\\n## Hard Rules\\n\\n- No cards by default.\\n- No hero cards by default.\\n- No boxed or center-column hero when the brief calls for full bleed.\\n- No more than one dominant idea per section.\\n- No section should need many tiny UI devices to explain itself.\\n- No headline should overpower the brand on branded pages.\\n- No filler copy.\\n- No split-screen hero unless text sits on a calm, unified side.\\n- No more than two typefaces without a clear reason.\\n- No more than one accent color unless the product already has a strong system.\\n\\n## Reject These Failures\\n\\n- Generic SaaS card grid as the first impression\\n- Beautiful image with weak brand presence\\n- Strong headline with no clear action\\n- Busy imagery behind text\\n- Sections that repeat the same mood statement\\n- Carousel with no narrative purpose\\n- App UI made of stacked cards instead of layout\\n\\n## Litmus Checks\\n\\n- Is the brand or product unmistakable in the first screen?\\n- Is there one strong visual anchor?\\n- Can the page be understood by scanning headlines only?\\n- Does each section have one job?\\n- Are cards actually necessary?\\n- Does motion improve hierarchy or atmosphere?\\n- Would the design still feel premium if all decorative shadows were removed?\\n\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-d4defc26-514e-4315-9dcf-208868e18737"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-29f99a9f-c2fd-441c-becf-988f90994a38-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-29f99a9f-c2fd-441c-becf-988f90994a38","content":"{\n \"status\": \"completed\",\n \"exitCode\": 0,\n \"output\": \"---\\nname: design-taste-frontend\\ndescription: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check.\\n---\\n\\n# tasteskill: Anti-Slop Frontend Skill\\n\\n> Landing pages, portfolios, and redesigns. Not dashboards, not data tables, not multi-step product UI.\\n> Every rule below is **contextual**. None of it fires automatically. First read the brief, then pull only what fits.\\n\\n---\\n\\n## 0. BRIEF INFERENCE (Read the Room Before Anything Else)\\n\\nBefore touching code or tweaking dials, **infer what the user actually wants**. Most LLM design output is bad because the model jumps to a default aesthetic instead of reading the room.\\n\\n### 0.A Read these signals first\\n1. **Page kind** - landing (SaaS / consumer / agency / event), portfolio (dev / designer / creative studio), redesign (preserve vs overhaul), editorial / blog.\\n2. **Vibe words** the user used - \\\"minimalist\\\", \\\"calm\\\", \\\"Linear-style\\\", \\\"Awwwards\\\", \\\"brutalist\\\", \\\"premium consumer\\\", \\\"Apple-y\\\", \\\"playful\\\", \\\"serious B2B\\\", \\\"editorial\\\", \\\"agency-y\\\", \\\"glassy\\\", \\\"dark tech\\\".\\n3. **Reference signals** - URLs they linked, screenshots they pasted, products they named, brands they're competing with.\\n4. **Audience** - B2B procurement panel vs. design-conscious consumer vs. recruiter scanning a portfolio. The audience picks the aesthetic, not your taste.\\n5. **Brand assets that already exist** - logo, color, type, photography. For redesigns, these are starting material, not optional input (see Section 11).\\n6. **Quiet constraints** - accessibility-first audiences, public-sector, regulated industries, trust-first commerce, kids' products. These constraints OVERRIDE aesthetic preference.\\n\\n### 0.B Output a one-line \\\"Design Read\\\" before generating\\nBefore any code, state in one line: **\\\"Reading this as: \\\\ for \\\\, with a \\\\ language, leaning toward \\\\.\\\"**\\n\\nExample reads:\\n- *\\\"Reading this as: B2B SaaS landing for technical buyers, with a Linear-style minimalist language, leaning toward Tailwind utilities + Geist + restrained motion.\\\"*\\n- *\\\"Reading this as: solo designer portfolio for hiring managers, with an editorial / kinetic-type language, leaning toward native CSS + scroll-driven animation + custom typography.\\\"*\\n- *\\\"Reading this as: redesign of a public-sector service site, with a trust-first language, leaning toward GOV.UK Frontend or USWDS.\\\"*\\n\\n### 0.C If the brief is ambiguous, ask one question, do not guess\\nAsk exactly **one** clarifying question - never a multi-question dump - and only when the design read genuinely diverges. Example: *\\\"Should this feel closer to Linear-clean or Awwwards-experimental?\\\"*\\n\\nIf you can confidently infer from context, **do not ask**. Just declare the design read and proceed.\\n\\n### 0.D Anti-Default Discipline\\nDo not default to: AI-purple gradients, centered hero over dark mesh, three equal feature cards, generic glassmorphism on everything, infinite-loop micro-animations everywhere, Inter + slate-900. These are the LLM defaults. Reach past them deliberately based on the design read.\\n\\n---\\n\\n## 1. THE THREE DIALS (Core Configuration)\\n\\nAfter the design read, set three dials. Every layout, motion, and density decision below is gated by these.\\n\\n* **`DESIGN_VARIANCE: 8`** - 1 = Perfect Symmetry, 10 = Artsy Chaos\\n* **`MOTION_INTENSITY: 6`** - 1 = Static, 10 = Cinematic / Physics\\n* **`VISUAL_DENSITY: 4`** - 1 = Art Gallery / Airy, 10 = Cockpit / Packed Data\\n\\n**Baseline:** `8 / 6 / 4`. Use these unless the design read overrides them. Do not ask the user to edit this file - overrides happen conversationally.\\n\\n### 1.A Dial Inference (design read → dial values)\\n| Signal | VARIANCE | MOTION | DENSITY |\\n|---|---|---|---|\\n| \\\"minimalist / clean / calm / editorial / Linear-style\\\" | 5-6 | 3-4 | 2-3 |\\n| \\\"premium consumer / Apple-y / luxury / brand\\\" | 7-8 | 5-7 | 3-4 |\\n| \\\"playful / wild / Dribbble / Awwwards / experimental / agency\\\" | 9-10 | 8-10 | 3-4 |\\n| \\\"landing page / portfolio / marketing site (default)\\\" | 7-9 | 6-8 | 3-5 |\\n| \\\"trust-first / public-sector / regulated / accessibility-critical\\\" | 3-4 | 2-3 | 4-5 |\\n| \\\"redesign - preserve\\\" | match existing | +1 | match existing |\\n| \\\"redesign - overhaul\\\" | +2 | +2 | match existing |\\n\\n### 1.B Use-Case Presets\\n| Use case | VARIANCE | MOTION | DENSITY |\\n|---|---|---|---|\\n| Landing (SaaS, mainstream) | 7 | 6 | 4 |\\n| Landing (Agency / creative) | 9 | 8 | 3 |\\n| Landing (Premium consumer) | 7 | 6 | 3 |\\n| Portfolio (Designer / studio) | 8 | 7 | 3 |\\n| Portfolio (Developer) | 6 | 5 | 4 |\\n| Editorial / Blog | 6 | 4 | 3 |\\n| Public-sector service | 3 | 2 | 5 |\\n| Redesign - preserve | match | match+1 | match |\\n| Redesign - overhaul | +2 | +2 | match |\\n\\n### 1.C How the Dials Drive Output\\nUse these (or user-overridden values) as global variables. Cross-references throughout this document refer to these exact variable names - never invent aliases like `LAYOUT_VARIANCE` or `ANIM_LEVEL`.\\n\\n---\\n\\n## 2. BRIEF → DESIGN SYSTEM MAP\\n\\nOnce you have the design read (Section 0) and dials (Section 1), pick the right foundation. Do not invent CSS for things that have an official package. Do not pretend an aesthetic trend is an official system.\\n\\n### 2.A When to reach for a real design system (use official packages)\\n| Brief reads as… | Reach for | Why |\\n|---|---|---|\\n| Microsoft / enterprise SaaS / dashboards | `@fluentui/react-components` or `@fluentui/web-components` | Official Fluent UI, Microsoft tokens, accessibility done |\\n| Google-ish UI, Material-flavored product | `@material/web` + Material 3 tokens | Official, theme-able via Material Theming |\\n| IBM-style B2B / enterprise analytics | `@carbon/react` + `@carbon/styles` | Official Carbon, mature data-density patterns |\\n| Shopify app surfaces | `polaris.js` web components / Polaris React | Required for Shopify admin UI |\\n| Atlassian / Jira-style product | `@atlaskit/*` + `@atlaskit/tokens` | Official Atlassian DS |\\n| GitHub-style devtool / community page | `@primer/css` or `@primer/react-brand` | Official Primer; Brand variant for marketing |\\n| Public-sector UK service | `govuk-frontend` | Legally / regulatorily expected |\\n| US public-sector / trust-first | `uswds` | Same |\\n| Fast local-business / agency MVP | Bootstrap 5.3 | Boring, fast, works |\\n| Modern accessible React foundation | `@radix-ui/themes` | Primitives + polished theme |\\n| Modern SaaS where you own the components | shadcn/ui (`npx shadcn@latest add ...`) | You own the code, easy to customise; never ship default state |\\n| Tailwind-based modern SaaS / AI marketing | Tailwind v4 utilities + `dark:` variant | Default for indie + small team builds |\\n\\n**Honesty rule:** if the brief reads as one of the systems above, install and use the **official** package. Do not recreate its CSS by hand. Do not import a system's tokens but then override 90% of them.\\n\\n**One system per project.** Do not mix Fluent React with Carbon in the same tree. Do not import shadcn/ui components into a Material 3 app.\\n\\n### 2.B When the brief is an aesthetic, not a system\\nFor these directions, there is **no single official package**. Build with native CSS + Tailwind + a maintained component library. Be honest in code comments about what is borrowed inspiration vs. official material.\\n\\n| Aesthetic | Honest implementation |\\n|---|---|\\n| Glassmorphism / \\\"frosted glass\\\" | `backdrop-filter`, layered borders, highlight overlays. Provide solid-fill fallback for `prefers-reduced-transparency`. |\\n| Bento (Apple-style tile grids) | CSS Grid with mixed cell sizes. No single library owns this. |\\n| Brutalism | Native CSS, monospace, raw borders. No library. |\\n| Editorial / magazine | Serif type, asymmetric grid, generous whitespace. No library. |\\n| Dark tech / hacker | Mono + accent neon, terminal motifs. No library. |\\n| Aurora / mesh gradients | SVG or layered radial gradients. No library. |\\n| Kinetic typography | Native CSS animations, scroll-driven animations, GSAP for hijacks. No library. |\\n| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official `liquid-glass.css`.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. |\\n\\n---\\n\\n## 3. DEFAULT ARCHITECTURE & CONVENTIONS\\n\\nUnless the design read picks a real design system (Section 2.A), these are the defaults:\\n\\n### 3.A Stack\\n* **Framework:** React or Next.js. Default to Server Components (RSC).\\n * **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `\\\"use client\\\"` component.\\n * **INTERACTIVITY ISOLATION:** Any component using Motion, scroll listeners, or pointer physics MUST be an isolated leaf with `'use client'` at the top. Server Components render static layouts only.\\n* **Styling:** **Tailwind v4** (default). Tailwind v3 only if the existing project demands it.\\n * For v4: do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin.\\n* **Animation:** **Motion** (the library formerly known as Framer Motion). Import from `motion/react` (`import { motion } from \\\"motion/react\\\"`). The `framer-motion` package still works as a legacy alias - prefer `motion/react` in new code.\\n* **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production.\\n\\n### 3.B State\\n* Local `useState` / `useReducer` for isolated UI.\\n* Global state ONLY for deep prop-drilling avoidance - Zustand, Jotai, or React context.\\n* **NEVER** use `useState` to track continuous values driven by user input (mouse position, scroll progress, pointer physics, magnetic hover). Use Motion's `useMotionValue` / `useTransform` / `useScroll`. `useState` re-renders the React tree on every change and collapses on mobile.\\n\\n### 3.C Icons\\n* **Allowed libraries (priority order):** `@phosphor-icons/react`, `hugeicons-react`, `@radix-ui/react-icons`, `@tabler/icons-react`.\\n* **Discouraged:** `lucide-react`. Acceptable only when the user explicitly asks for it or the project already depends on it.\\n* **NEVER hand-roll SVG icons.** If a glyph is missing, install a second library or compose from primitives - do not draw icon paths from scratch.\\n* **One family per project.** Do not mix Phosphor with Lucide in the same component tree.\\n* **Standardize `strokeWidth` globally** (e.g. `1.5` or `2.0`).\\n\\n### 3.D Emoji Policy\\nDiscouraged by default in code, markup, and visible text. Replace symbols with icon-library glyphs. **Override:** allow emojis only when the user explicitly asks for a playful / chat-style / social-native vibe - and even then use them sparingly with intent.\\n\\n### 3.E Responsiveness & Layout Mechanics\\n* Standardize breakpoints (`sm 640`, `md 768`, `lg 1024`, `xl 1280`, `2xl 1536`).\\n* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`.\\n* **Viewport Stability:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent layout jumping on mobile (iOS Safari address bar).\\n* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`).\\n\\n### 3.F Dependency Verification (mandatory)\\nBefore importing ANY 3rd-party library, check `package.json`. If the package is missing, output the install command first. **Never** assume a library exists.\\n\\n---\\n\\n## 4. DESIGN ENGINEERING DIRECTIVES (Bias Correction)\\n\\nLLMs default to clichés. Override these defaults proactively. Each rule has a context-aware override path.\\n\\n### 4.1 Typography\\n* **Display / Headlines:** Default `text-4xl md:text-6xl tracking-tighter leading-none`.\\n* **Body / Paragraphs:** Default `text-base text-gray-600 leading-relaxed max-w-[65ch]`.\\n* **Sans font choice:**\\n * **Discouraged as default:** `Inter`. Pick `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`, or a brand-appropriate serif first.\\n * **Override:** Inter is acceptable when the user explicitly asks for a neutral / standard / Linear-style feel, or when the brief is a public-sector / accessibility-first site.\\n* **Pairings to know:** `Geist` + `Geist Mono`, `Satoshi` + `JetBrains Mono`, `Cabinet Grotesk` + `Inter Tight`, `GT America` + `IBM Plex Mono`.\\n\\n* **SERIF DISCIPLINE (VERY DISCOURAGED AS DEFAULT):**\\n * Serif is **very discouraged as the default font for any project.** \\\"It feels creative / premium / editorial\\\" is NOT a reason to reach for serif. The agent's default mental model that \\\"creative brief = serif\\\" is the single most-tested AI tell in production rounds.\\n * **Serif is only acceptable when ONE of these is explicitly true:**\\n - The brand brief literally names a serif font, OR\\n - The aesthetic family is genuinely editorial / luxury / publication / manuscript / heritage / vintage AND you can articulate why this specific serif fits this specific brand\\n * For everything else (creative agency, design studio, modern brand, premium consumer, portfolio, lifestyle), **default sans-serif display** (Geist Display, ABC Diatype, Söhne Breit, Cabinet Grotesk Display, Migra Sans, GT Walsheim, Inter Display, PP Neue Montreal). Sans display fonts are not \\\"boring\\\" — they are the default for the same reason black is the default in fashion.\\n * **EMPHASIS RULE (related):** When you want to emphasize a word within a headline (the kinetic \\\"and `spatial` design\\\" type move), use **italic or bold of the SAME font**. Do NOT inject a random serif word into a sans headline (or vice versa) just to add visual interest. Mixed-family emphasis is amateur. Italic/bold emphasis in the same family is the right move.\\n * **Specifically BANNED as defaults:** `Fraunces` and `Instrument_Serif` (the two LLM-favorite display serifs).\\n * **If a serif is justified** (rare, per the above), rotate from this pool, do NOT reuse the same serif across consecutive projects: PP Editorial New, GT Sectra Display, Cardinal Grotesque, Reckless Neue, Tiempos Headline, Recoleta, Cormorant Garamond, Playfair Display, EB Garamond, IvyPresto, Migra, Editorial Old, Saol Display, Söhne Breit Kursiv, Domaine Display, Canela, Schnyder, Tobias, NB Architekt, ITC Galliard.\\n\\n* **ITALIC DESCENDER CLEARANCE (mandatory):** When italic is used in display type and the word contains a descender letter (`y g j p q`), `leading-[1]` or `leading-none` will clip the descender. Use `leading-[1.1]` minimum and add `pb-1` or `mb-1` reserve on the wrapping element. Audit every italic word in display headlines before shipping.\\n\\n### 4.2 Color Calibration\\n* Max 1 accent color. Saturation < 80% by default.\\n* **THE LILA RULE:** The \\\"AI Purple / Blue glow\\\" aesthetic is discouraged as a default. No automatic purple button glows, no random neon gradients. Use neutral bases (Zinc / Slate / Stone) with high-contrast singular accents (Emerald, Electric Blue, Deep Rose, Burnt Orange, etc.).\\n* **Override:** if the brand or brief explicitly asks for purple / violet / lila, embrace it. But execute with intent: consistent palette, harmonised neutrals, restrained gradients. Not generic AI gradient slop.\\n* **One palette per project.** Do not fluctuate between warm and cool grays within the same project.\\n* **COLOR CONSISTENCY LOCK (mandatory):** Once an accent color is chosen for a page, it is used on the WHOLE page. A warm-grey site does not suddenly get a blue CTA in section 7. A rose-accented site does not get a teal status badge in the footer. Pick one accent, lock it, audit every component before shipping.\\n\\n* **PREMIUM-CONSUMER PALETTE BAN (mandatory, second-most-recurring AI-tell):**\\n * For premium-consumer briefs (cookware, wellness, artisan, luxury, heritage craft, DTC home goods, etc.) the LLM default is **warm beige/cream + brass/clay/oxblood/ochre + espresso/ink dark text**. Concretely banned hex families as default backgrounds and accents:\\n - Backgrounds: `#f5f1ea`, `#f7f5f1`, `#fbf8f1`, `#efeae0`, `#ece6db`, `#faf7f1`, `#e8dfcb` (all \\\"warm paper / cream / chalk / bone\\\")\\n - Accents: `#b08947`, `#b6553a`, `#9a2436`, `#9c6e2a`, `#bc7c3a`, `#7d5621` (all \\\"brass / clay / oxblood / ochre\\\")\\n - Text: `#1a1714`, `#1a1814`, `#1b1814` (all \\\"espresso / warm near-black\\\")\\n * This palette is BANNED as the default reach for premium-consumer briefs. Every premium-consumer site you have ever shipped uses this exact palette. The brand becomes invisible.\\n * **Default alternatives (rotate, do not reuse):**\\n - **Cold Luxury:** silver-grey + chrome + smoke (think Tesla, Apple Watch Hermes-without-the-leather)\\n - **Forest:** deep green + bone + amber accent (think Filson, Patagonia premium)\\n - **Black and Tan:** true off-black + warm tan, sharp contrast, no beige\\n - **Cobalt + Cream:** saturated blue against a single neutral, no brass\\n - **Terracotta + Slate:** warm rust against cool grey, no brass\\n - **Olive + Brick + Paper:** muted olive plus brick-red accent\\n - **Pure monochrome + single saturated pop:** off-white + off-black + one bright accent (electric blue, emerald, hot pink, etc.)\\n * **Palette-rotation rule:** if the previous premium-consumer project you generated used the beige+brass family, this one MUST use a different family. Do not ship the same warm-craft palette twice in a row.\\n * **Override:** the beige+brass+espresso palette is acceptable ONLY when the brand brief explicitly names those colors, or when the brand identity is genuinely vintage / artisan / warm-craft AND you can articulate why this specific palette fits this specific brand. Default-reaching for it because \\\"this is a cookware brief\\\" is banned.\\n\\n### 4.3 Layout Diversification\\n* **ANTI-CENTER BIAS:** Centered Hero / H1 sections are avoided when `DESIGN_VARIANCE > 4`. Force \\\"Split Screen\\\" (50/50), \\\"Left-aligned content / right-aligned asset\\\", \\\"Asymmetric white-space\\\", or scroll-pinned structures.\\n* **Override:** centered hero is OK for editorial / manifesto / launch-announcement briefs where the message itself is the design.\\n\\n### 4.4 Materiality, Shadows, Cards\\n* Use cards ONLY when elevation communicates real hierarchy. Otherwise group with `border-t`, `divide-y`, or negative space.\\n* When a shadow is used, tint it to the background hue. No pure-black drop shadows on light backgrounds.\\n* For `VISUAL_DENSITY > 7`: generic card containers are banned. Data metrics breathe in plain layout.\\n* **SHAPE CONSISTENCY LOCK (mandatory):** Pick ONE corner-radius scale for the page and stick to it. Options: all-sharp (radius 0), all-soft (radius 12-16px), all-pill (full radius for interactive). Mixed systems are allowed only when there is a documented rule (e.g. \\\"buttons are full-pill, cards are 16px, inputs are 8px\\\") and that rule is followed everywhere. Round buttons in a square layout, or square cards on a pill-button page, is broken design.\\n\\n### 4.5 Interactive UI States\\nLLMs default to \\\"static successful state only.\\\" Always implement full cycles:\\n* **Loading:** Skeletal loaders matching the final layout's shape. Avoid generic circular spinners.\\n* **Empty States:** Beautifully composed; indicate how to populate.\\n* **Error States:** Clear, inline (forms), or contextual (toasts only for transient).\\n* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push.\\n* **BUTTON CONTRAST CHECK (mandatory, a11y):** Before shipping any button, verify the button text is readable against the button background. White button + white text, `bg-white` CTA with `text-white` label, transparent button against the page background with no border → all banned. Audit every CTA: contrast ratio WCAG AA min (4.5:1 for body, 3:1 for large text 18px+). Same rule applies to ghost buttons over photographic backgrounds (use a backdrop, scrim, or stroke).\\n* **CTA BUTTON WRAP BAN (mandatory):** Button text MUST fit on one line at desktop. If a label like \\\"VIEW SELECTED WORK\\\" wraps to 2 or 3 lines, the button is broken. Fix by EITHER shortening the label (3 words max for primary CTAs, ideally 1-2) OR widening the button (do not artificially constrain `max-width` on CTAs). Wrapped CTAs at desktop are a Pre-Flight Fail.\\n* **NO DUPLICATE CTA INTENT (mandatory):** Two CTAs with the same intent on one page is a Pre-Flight Fail. Examples of same intent: \\\"Get in touch\\\" + \\\"Contact us\\\" + \\\"Let's talk\\\" + \\\"Start a project\\\" + \\\"Start something\\\" + \\\"Reach out\\\" = all \\\"contact\\\" intent → pick ONE label and use it everywhere on the page (nav, hero, footer). Same for \\\"Try free\\\" + \\\"Get started\\\" + \\\"Sign up free\\\" (all \\\"signup\\\" intent) and \\\"View work\\\" + \\\"See selected work\\\" + \\\"Browse projects\\\" (all \\\"portfolio\\\" intent). One label per intent.\\n* **FORM CONTRAST CHECK (mandatory, a11y):** Form inputs, placeholder text, focus rings, helper text, and error text all pass WCAG AA contrast against the section background. Light placeholders on a near-white form, white form on white page section, form labels grayer than 4.5:1 contrast → all banned. Audit every form before shipping.\\n\\n### 4.6 Data & Form Patterns\\n* Label ABOVE input. Helper text optional but present in markup. Error text BELOW input. Standard `gap-2` for input blocks.\\n* No placeholder-as-label. Ever.\\n\\n### 4.7 Layout Discipline (Hard Rules. Failing any of these is shipping broken work)\\n\\n* **Hero MUST fit in the initial viewport.** Headline max 2 lines on desktop, subtext max **20 words** AND max 3-4 lines, CTAs visible without scroll. If the copy is too long: reduce font scale OR cut copy. If you cannot describe the value-prop in 20 words of subtext, the value-prop is unclear, not the rule too tight. Never let the hero overflow and force scroll to find the CTA.\\n* **Hero font-scale discipline.** Plan font size and image size *together*. If the hero asset is large and the headline is more than 6 words, do not start at `text-7xl/text-8xl`. Default sensible range: `text-4xl md:text-5xl lg:text-6xl` for most heroes; `text-6xl md:text-7xl` only when the headline is 3-5 words. A 4-line hero headline is always a font-size error, never a copy-length error.\\n* **HERO TOP PADDING CAP (mandatory):** Hero top padding max `pt-24` (≈6rem) at desktop. More than that means the hero content floats halfway down the viewport and reads as a layout bug, not as intentional space. If your hero needs more breathing room, increase font scale or asset size, not top padding.\\n* **HERO STACK DISCIPLINE (max 4 text elements).** The hero is a single moment, not a feature list. Allowed text elements, max 4 in total:\\n 1. Eyebrow (small uppercase label) OR brand strip OR neither - pick zero or one\\n 2. Headline (max 2 lines, see above)\\n 3. Subtext (max 20 words, max 4 lines)\\n 4. CTAs (1 primary + max 1 secondary)\\n - **BANNED in the hero:** tiny tagline below CTAs (\\\"Works with GitHub, GitLab, and self-hosted Git\\\"), trust micro-strip (\\\"Used by engineering teams at...\\\"), pricing teaser (\\\"Free for solo, $10/user for teams\\\"), feature bullet list, social-proof avatar row. All of those move to dedicated sections directly below the hero.\\n - If you have an eyebrow AND a tagline below CTAs in the same hero, drop the tagline. If you have a brand strip AND a tagline, drop the tagline. One small text element per hero, max.\\n* **\\\"Used by\\\" / \\\"Trusted by\\\" logo wall belongs UNDER the hero, never inside it.** The hero is for the value prop and primary CTA. The logo wall is a separate section directly below. Do not stuff trust logos into the same flex row as the hero copy.\\n* **Navigation MUST render on a single line on desktop.** If items don't fit at `lg` (1024px), condense labels, drop secondary items, or move to a hamburger. A two-line nav at desktop is broken design.\\n* **Navigation height cap: 80px max desktop, default 64-72px.** No huge \\\"agency\\\" nav bars that eat 15% of the viewport.\\n* **Bento grids MUST have rhythm, not one-sided repetition.** Do not stack 6 left-image / right-text rows. Vary the composition: alternate full-width feature rows, asymmetric tile sizes, vertical breaks.\\n* **BENTO CELL COUNT RULE (mandatory):** A bento grid has EXACTLY as many cells as you have content for. 3 items → 3 cells (1+2 split, or 2+1, or asymmetric trio). 5 items → 5 cells (2+3, 3+2, hero+4, etc.). If your grid has an empty cell in the middle or at the end, you planned wrong. Re-shape the grid; do not paste a blank tile.\\n* **Section-Layout-Repetition Ban.** Once you use a layout family for a section (e.g., 3-column-image-cards, full-width-quote, split-text-image), that family can appear at most ONCE on the page. \\\"Selected commissions\\\" must not look like \\\"What we do.\\\" A landing page with 8 sections must use at least 4 different layout families.\\n* **ZIGZAG ALTERNATION CAP (mandatory).** Alternating \\\"left-image + right-text\\\" then \\\"left-text + right-image\\\" zigzag layout = banal. Max 2 sections in a row with this image+text-split pattern. The 3rd consecutive image+text split is a Pre-Flight Fail. Break the pattern with a full-width section, a vertical-stack section, a bento grid, a marquee, or a different layout family.\\n* **EYEBROW RESTRAINT (mandatory, the #1 violated rule in production tests).** An \\\"eyebrow\\\" is the small uppercase wide-tracking label sitting above a section headline (e.g. `FOUR COLORWAYS`, `SELECTED WORK`, `THE HARDWARE`, `Git-native task management`). Typical CSS signature: `text-[11px] uppercase tracking-[0.18em]`, `font-mono text-[10.5px] uppercase tracking-[0.22em]`. Every AI-built site puts an eyebrow above EVERY section header, producing the same templated rhythm. Hard rule:\\n - **Maximum 1 eyebrow per 3 sections.** Hero counts as 1. So a page with 9 sections may use at most 3 eyebrows total.\\n - If section A has an eyebrow, the next 2 sections cannot have one.\\n - **Pre-Flight Check is mechanical:** count instances of `uppercase tracking` (or similar small-caps mono labels above headlines) across all section components. If count > ceil(sectionCount / 3), the output fails.\\n - **What to do instead of an eyebrow:** drop it entirely. The headline alone is enough. If you need to categorize a section, the section's location on the page already categorizes it; no label needed.\\n* **SPLIT-HEADER BAN (mandatory).** The pattern \\\"left big headline + right small explainer paragraph\\\" as a section header (left col-span-7/8, right col-span-4/5 with a small body paragraph floating in the right column) is **banned as default**. Sections should have ONE focused message. If you genuinely need both a headline and an explainer paragraph, stack them vertically (headline on top, body below, max-width 65ch). Reach for the split-header pattern only when there is a real compositional reason (e.g., the right column carries a visual or interactive element, not just filler text).\\n* **Bento Background Diversity (mandatory).** Bento and feature-grid sections cannot be 6 white-on-white cards with text inside. At least 2-3 cells in any multi-cell grid need real visual variation: a real image, a brand-appropriate gradient (not AI-purple), a pattern, a tinted background. A cream-on-cream bento with only typography inside reads as boring AI default, even when the rest of the page is good.\\n* **Mobile collapse must be explicit per section.** For every multi-column layout, declare the `< 768px` fallback in the same component. No \\\"it'll work, Tailwind handles it\\\" assumptions.\\n\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-29f99a9f-c2fd-441c-becf-988f90994a38"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-589c034f-a5e6-46e3-98b7-4b9d08c9d1c2","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-589c034f-a5e6-46e3-98b7-4b9d08c9d1c2","delta":"{\n \"command\": \"/bin/zsh -lc \\\"ls -la && rg --files -g '\\\"'!node_modules'\\\"' -g '\\\"'!dist'\\\"' | head -200\\\"\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-589c034f-a5e6-46e3-98b7-4b9d08c9d1c2-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-589c034f-a5e6-46e3-98b7-4b9d08c9d1c2","content":"{\n \"status\": \"completed\",\n \"exitCode\": 0,\n \"output\": \"total 0\\ndrwxr-xr-x@ 3 mew staff 96 Jul 12 01:15 .\\ndrwxr-xr-x@ 58 mew staff 1856 Jul 12 01:15 ..\\ndrwxr-xr-x@ 3 mew staff 96 Jul 12 01:15 .claude\\n\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-589c034f-a5e6-46e3-98b7-4b9d08c9d1c2"} - -data: {"type":"CUSTOM","name":"token_usage","value":{"total":{"totalTokens":29029,"inputTokens":28477,"cachedInputTokens":17920,"outputTokens":552,"reasoningOutputTokens":64},"last":{"totalTokens":14695,"inputTokens":14387,"cachedInputTokens":13056,"outputTokens":308,"reasoningOutputTokens":37},"modelContextWindow":353400}} - -data: {"type":"REASONING_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0c608644e708ce56016a53a2b6a42481938744a0f818026c71"} - -data: {"type":"REASONING_MESSAGE_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0c608644e708ce56016a53a2b6a42481938744a0f818026c71","role":"reasoning"} - -data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0c608644e708ce56016a53a2b6a42481938744a0f818026c71","delta":"**Inspecting hidden .claude file**"} - -data: {"type":"REASONING_MESSAGE_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0c608644e708ce56016a53a2b6a42481938744a0f818026c71"} - -data: {"type":"REASONING_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0c608644e708ce56016a53a2b6a42481938744a0f818026c71"} - -data: {"type":"TEXT_MESSAGE_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","role":"assistant"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"目前"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"工作"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"区"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"根"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"目录"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"只有"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":" `."} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"cla"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"ude"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"`"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":","} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"没有"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"看到"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"常"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"规"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"的"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":" HTML"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"/"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"前"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"端"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"文件"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"。我"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"正在"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"确认"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"页面"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"是否"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"放"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"在"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"隐藏"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"目录"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"或"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"尚"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"未"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"同步"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"进"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"来"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":";"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"如果"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"确"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"实"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"为空"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":",我"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"直接"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"告诉"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"你"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"需要"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"补"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"充"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"哪个"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"文件"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"。"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"设计"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"上"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"我"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"先"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"按"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"“"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"GPT"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":" 对"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"话"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"/"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"产品"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"页"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"克"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"制"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"的"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"工具"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"感"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"重点"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"提升"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"层"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"级"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"与"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"可"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"用"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"性"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"”"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"来"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"读"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":",但"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"要"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"以"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"实际"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"源码"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"为"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"准"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e","delta":"。"} - -data: {"type":"TEXT_MESSAGE_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_0c608644e708ce56016a53a2b7b80c8193babf4739e682f45e"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-519ce139-286b-4374-85d0-9d88012177f6","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-519ce139-286b-4374-85d0-9d88012177f6","delta":"{\n \"command\": \"/bin/zsh -lc \\\"sed -n '261,620p' /Users/mew/.agents/skills/taste-skill/SKILL.md\\\"\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-519ce139-286b-4374-85d0-9d88012177f6-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-519ce139-286b-4374-85d0-9d88012177f6","content":"{\n \"status\": \"completed\",\n \"exitCode\": 0,\n \"output\": \"\\n### 4.8 Image & Visual Asset Strategy\\n\\nLanding pages and portfolios are **visual products**. Text-only pages with fake-screenshot divs are slop.\\n\\n**Priority order for visual assets:**\\n1. **Image-generation tool first.** If ANY image-gen tool is available in the environment (`generate_image`, MCP image tool, IDE-integrated gen, OpenAI image tools, etc.) you MUST use it to create section-specific assets: hero photography, product shots, texture backgrounds, mood images. Generate at the right aspect ratio for the section. Do not skip this step because hand-rolled CSS feels faster.\\n2. **Real web images second.** When no gen tool is available, use real photography sources. Acceptable defaults:\\n * `https://picsum.photos/seed/{descriptive-seed}/{w}/{h}` for placeholder photography (seed should describe the section, e.g. `marrow-cookware-kitchen`)\\n * Actual stock or brand URLs when the brief provides them\\n * Open-license sources (Unsplash via direct URL, Pexels) if explicitly allowed\\n3. **Last resort: tell the user.** If neither is possible, do NOT fill the page with hand-rolled SVG illustrations or div-based \\\"fake screenshots.\\\" Instead, leave clearly-labeled placeholder slots (``) and at the end of the response say: *\\\"This page needs real images at: \\\\[list of placements\\\\]. Please generate or provide them.\\\"*\\n\\n**Even minimalist sites need real images.** A pure-text page is not minimalism. It is incomplete work. Even an editorial Linear-style site needs at least 2-3 real images (hero, one product/lifestyle shot, one supporting image). Generate B&W minimalist photography if the brief is restrained; do not skip images entirely because the dial is low.\\n\\n**Real company logos for social proof.** When the brief calls for a \\\"Trusted by / Used by / Customers\\\" logo wall, do NOT default to plain text wordmarks (`Acme Co` styled in a row). Use real SVG logos:\\n* **Source: Simple Icons** (`https://cdn.simpleicons.org/{slug}/ffffff` for any color, or `simple-icons` npm package). Covers most known brands.\\n* **Alternative: devicon** for tech-stack logos (`@svgr/cli` or CDN).\\n* **Make-up the brand name? Then make-up an SVG mark too.** Generate a simple monogram (one letter in a circle, two-letter ligature, abstract glyph) rendered as an inline `` matching the page style. Plain text wordmarks for invented brand names look generic.\\n* **Always** ensure logos render in both light and dark mode (white-on-dark, black-on-light, or single-color theme variable).\\n* **LOGO-ONLY rule (mandatory):** logo wall = logos and nothing else. Do NOT print industry / category labels below each logo (no `Vercel` + `hosting` underneath, no `Stripe` + `payments`, no `Cloudflare` + `infra`). The logo is the credibility, the label adds nothing the user does not already know. Optional: brand name as alt-text for screen readers, optional link to the brand's site. That is it.\\n\\n**Hand-rolled illustrations:**\\n* SVG icons from libraries: fine (see Section 3.C).\\n* Hand-rolled decorative SVGs (custom illustrations, logos, marks): **strongly discouraged**, never as default. Acceptable only when:\\n - The brief explicitly calls for it (\\\"draw me an SVG logo\\\")\\n - It's a single, simple geometric mark (a square, a circle, a wordmark in display type)\\n - You're confident in the output quality\\n\\n**Div-based fake screenshots are banned.** A \\\"hand-built product preview\\\" rendered with `
` rectangles, fake task lists, fake dashboards, fake terminal windows is a Tell. If you need to show a product:\\n* Use a real screenshot URL if one exists\\n* Generate one via image tool\\n* Use a real component preview (an actual mini-version of the UI inside the page)\\n* Or skip the preview entirely and use editorial photography\\n\\n**Hero needs a real visual.** Text + gradient blob is not a hero - it's a placeholder.\\n\\n### 4.9 Content Density\\n\\nLanding pages live on the **first impression**, not the full read. Cut ruthlessly.\\n\\n* **Default content shape per section:** short headline (≤ 8 words) + short sub-paragraph (≤ 25 words) + one visual asset OR one CTA. Anything more must be justified by the section's job.\\n* **No data-dump sections.** A 20-row publication table, a 30-row award list, a giant pricing matrix on a marketing page = wrong layout. Use:\\n - Top 3-5 highlights + \\\"View full list\\\" link\\n - Marquee / carousel for breadth\\n - Different page entirely if the data is the product\\n* **Long lists need a different UI component, not a longer list.** Default `
    ` with bullets / `divide-y` rows is the lazy choice. If you have > 5 items, reach for one of these instead:\\n - 2-column split with grouped items\\n - Card grid with image + label per item\\n - Tabs / accordion if items are categorisable\\n - Horizontal scroll-snap pills\\n - Carousel for breadth-heavy lists (testimonials, logos, capabilities)\\n - Marquee for \\\"lots-of-things-that-don't-need-individual-attention\\\"\\n A spec sheet with 10 rows + a hairline under every row is the WORST default. Either group rows into 2-3 chunks with sparse dividers, or move to a card-per-spec layout.\\n* **Spec sheets specifically (the Marrow-cookware pattern).** A long product specification table with `border-b` on every row is the AI default for cookware / hardware / apparel / artisan-goods briefs. Banned. Concrete alternatives:\\n - **2-col card grid:** each spec gets its own card with the spec name, the value (large display number), and a one-line \\\"why it matters\\\" body. Cards arranged 2-col on desktop, 1-col mobile.\\n - **Scroll-snap horizontal pills:** each spec is a pill, user can flick through.\\n - **Grouped chunks:** group 10 specs into 3 logical clusters (e.g. \\\"Materials\\\", \\\"Cooking\\\", \\\"Warranty\\\"), each cluster gets ONE soft divider and a cluster heading.\\n - **Featured-vs-rest:** 3-4 hero specs visualised as large display tiles, the rest collapsed under a \\\"View full specifications\\\" disclosure.\\n\\n* **COPY SELF-AUDIT (mandatory before ship):** Before declaring any task done, re-read every visible string on the page (headlines, subheads, eyebrows, button labels, body copy, captions, alt text, footer text, error messages). Flag any string that is:\\n - **Grammatically broken** (\\\"free on its past\\\", \\\"two plans but one is honest\\\", \\\"to put it on the table\\\" out of context)\\n - **Has unclear referents** (\\\"we plan to stay that way\\\" without prior context)\\n - **Sounds like AI hallucination** (cute-but-wrong wordplay, forced metaphors that don't track, \\\"elegant nothing\\\" phrases)\\n - **Reads like an LLM trying to sound thoughtful** (passive-aggressive humility, fake-craftsman labels, mock-poetic micro-meta)\\n Rewrite every flagged string. If unsure whether a string makes sense, replace it with a plain functional sentence. AI-generated cute copy is worse than boring copy.\\n* **Fake-precise numbers are flagged.** Numbers like `92%`, `4.1×`, `48k`, `5.8 mm`, `13.4 lb` either:\\n - Come from real data (brief, brand guidelines, public metrics) - fine\\n - Are explicitly labeled as mock (``, \\\"example\\\", \\\"sample data\\\") - fine\\n - Are AI-invented spec aesthetics - banned. Don't fake engineering precision the brand doesn't claim.\\n* **One copy register per page.** Don't mix technical mono (\\\"47 tasks · 0.6 ctx-switches/day\\\"), editorial prose, and marketing punch in the same composition unless the brand voice explicitly calls for it.\\n\\n### 4.10 Quotes & Testimonials\\n\\n* **Max 3 lines** of quote body. Never 6. If the original quote is longer → cut it. A landing-page quote is a snippet, not the full review.\\n* For very small font sizes (e.g. footer-style testimonials), the line cap can stretch slightly. Spirit: \\\"fits in a glance.\\\"\\n* **No em-dashes inside the quote text** as design flourish (long pauses, kinetic em-dashes, em-dash-bullets). See Section 9.G - em-dash is completely banned.\\n* Attribution: name + role + (optionally) company. Never name only (\\\"- Sarah\\\").\\n* Quote marks: use real typographic quotes ( \\\" \\\" ) or none at all. Not straight ASCII ( \\\" ).\\n\\n### 4.11 Page Theme Lock (Light / Dark Mode Consistency)\\n\\nThe page has ONE theme. Sections do not invert.\\n\\n* If the page is dark mode, ALL sections are dark mode. No light-mode-warm-paper section sandwiched between dark sections (or vice versa). The user must not feel they walked into a different website mid-scroll.\\n* The exception: if the brief explicitly calls for a \\\"Color Block Story\\\" or \\\"Theme Switch on Scroll\\\" device AND that is a deliberate composition (one full theme switch with a strong transition, not random alternation), it is allowed once per page.\\n* Default behaviour: pick light, dark, or auto (`prefers-color-scheme`) at the page level and lock it. Section-level background tints within the same theme family are fine (`bg-zinc-950` next to `bg-zinc-900`); flipping to `bg-amber-50` in the middle of a `bg-zinc-950` page is broken.\\n* When using a design system with built-in theming (Radix Themes, shadcn/ui with ``), set the theme ONCE in `layout.tsx` or the page root. Do not let individual sections override.\\n\\n---\\n\\n## 5. CONTEXT-AWARE PROACTIVITY\\n\\nThese are tools, not defaults. Use them when the design read calls for them. **None of these fire automatically.**\\n\\n* **Liquid Glass / Glassmorphism:** Appropriate for premium consumer, Apple-adjacent, luxury brand, or media-overlay vibes. Inappropriate for dashboards, public-sector, or \\\"boring B2B.\\\" When used, go beyond `backdrop-blur`: add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) for physical edge refraction. Provide a solid-fill fallback under `prefers-reduced-transparency`.\\n* **Magnetic Micro-physics:** Use when `MOTION_INTENSITY > 5` AND the brief reads premium / playful / agency. Implement EXCLUSIVELY with Motion's `useMotionValue` / `useTransform` outside the React render cycle. Never `useState`. See Section 3.B.\\n* **Perpetual Micro-Interactions** (Pulse, Typewriter, Float, Shimmer, Carousel): Use when `MOTION_INTENSITY > 5` AND the section actively benefits from motion (status indicators, live feeds, AI-feel). **Not every card needs an infinite loop.** If a section is informational, leave it still. Apply Spring Physics (`type: \\\"spring\\\", stiffness: 100, damping: 20`) - no linear easing.\\n* **\\\"Motion claimed, motion shown.\\\"** If `MOTION_INTENSITY > 4`, the page must actually move: entry transitions on hero, scroll-reveal on key sections, hover physics on CTAs, at minimum. A static page that claims `MOTION_INTENSITY: 7` is broken. Conversely, if you cannot ship working motion in the available scope, drop the dial to 3 and ship a clean static page. Never half-build motion that breaks (cut-off ScrollTriggers, jumpy enters, missing cleanups).\\n* **MOTION MUST BE MOTIVATED (mandatory).** Before adding any animation, ask: \\\"what does this animation communicate?\\\" Valid answers: hierarchy (drawing attention to the right thing), storytelling (revealing content in sequence that matches a narrative), feedback (acknowledging a user action), state transition (showing something changed). Invalid answer: \\\"it looked cool\\\". GSAP everywhere because GSAP is available is amateur. Each ScrollTrigger, each marquee, each pinned section needs a reason. If you cannot articulate the reason in one sentence, drop the animation.\\n* **MARQUEE MAX-ONE-PER-PAGE (mandatory).** Horizontal scrolling text marquees (\\\"logos endlessly scrolling\\\", \\\"manifesto scrolling sideways\\\", \\\"kinetic word strip\\\") are appropriate at most ONCE per page. Two or more marquees on the same page reads as lazy filler. Pick the one section where the marquee actually serves the content; the others get a different layout.\\n* **GSAP Sticky-Stack Pattern (when scroll-stack is used).** A \\\"card stack on scroll\\\" must be a REAL sticky-stack, not a sequential reveal list. See Section 5.A below for the canonical code skeleton. Common failure: trigger fires halfway through scroll instead of pinning at viewport top. Fix: `start: \\\"top top\\\"` not `start: \\\"top center\\\"` or `\\\"top 80%\\\"`.\\n* **GSAP Horizontal-Pan Pattern (when horizontal scroll-hijack is used).** See Section 5.B below for the canonical skeleton. Common failure: animation starts before the section is pinned, so the user sees half a slide. Same fix: `start: \\\"top top\\\"`, pin the wrapper, scrub the inner track.\\n\\n### 5.A Sticky-Stack - Canonical Skeleton\\n\\n```tsx\\n\\\"use client\\\";\\nimport { useRef, useEffect } from \\\"react\\\";\\nimport { gsap } from \\\"gsap\\\";\\nimport { ScrollTrigger } from \\\"gsap/ScrollTrigger\\\";\\nimport { useReducedMotion } from \\\"motion/react\\\";\\n\\ngsap.registerPlugin(ScrollTrigger);\\n\\nexport function StickyStack({ cards }: { cards: React.ReactNode[] }) {\\n const ref = useRef(null);\\n const reduce = useReducedMotion();\\n\\n useEffect(() => {\\n if (reduce || !ref.current) return;\\n const ctx = gsap.context(() => {\\n const cardEls = gsap.utils.toArray(\\\".stack-card\\\");\\n cardEls.forEach((card, i) => {\\n if (i === cardEls.length - 1) return;\\n ScrollTrigger.create({\\n trigger: card,\\n start: \\\"top top\\\", // pin at viewport top\\n endTrigger: cardEls[cardEls.length - 1],\\n end: \\\"top top\\\",\\n pin: true,\\n pinSpacing: false,\\n });\\n gsap.to(card, {\\n scale: 0.92,\\n opacity: 0.55,\\n ease: \\\"none\\\",\\n scrollTrigger: {\\n trigger: cardEls[i + 1],\\n start: \\\"top bottom\\\",\\n end: \\\"top top\\\",\\n scrub: true,\\n },\\n });\\n });\\n }, ref);\\n return () => ctx.revert();\\n }, [reduce]);\\n\\n return (\\n
    \\n {cards.map((card, i) => (\\n \\n {card}\\n
    \\n ))}\\n
\\n );\\n}\\n```\\n\\nCritical points: `start: \\\"top top\\\"`, `pin: true`, every card except the last is pinned, the scale/opacity transform is driven by the NEXT card's scroll trigger (so previous card shrinks as next one arrives).\\n\\n### 5.B Horizontal-Pan - Canonical Skeleton\\n\\n```tsx\\n\\\"use client\\\";\\nimport { useRef, useEffect } from \\\"react\\\";\\nimport { gsap } from \\\"gsap\\\";\\nimport { ScrollTrigger } from \\\"gsap/ScrollTrigger\\\";\\nimport { useReducedMotion } from \\\"motion/react\\\";\\n\\ngsap.registerPlugin(ScrollTrigger);\\n\\nexport function HorizontalPan({ children }: { children: React.ReactNode }) {\\n const wrap = useRef(null);\\n const track = useRef(null);\\n const reduce = useReducedMotion();\\n\\n useEffect(() => {\\n if (reduce || !wrap.current || !track.current) return;\\n const ctx = gsap.context(() => {\\n const distance = track.current!.scrollWidth - window.innerWidth;\\n gsap.to(track.current, {\\n x: -distance,\\n ease: \\\"none\\\",\\n scrollTrigger: {\\n trigger: wrap.current,\\n start: \\\"top top\\\", // pin starts when section top hits viewport top\\n end: () => `+=${distance}`, // scroll distance = track width minus viewport\\n pin: true,\\n scrub: 1,\\n invalidateOnRefresh: true,\\n },\\n });\\n }, wrap);\\n return () => ctx.revert();\\n }, [reduce]);\\n\\n return (\\n
\\n
\\n {children}\\n
\\n
\\n );\\n}\\n```\\n\\nCritical points: `start: \\\"top top\\\"`, `pin: true`, `end: \\\"+=${distance}\\\"` (scroll length = horizontal travel needed), `scrub: 1`. The wrapper is pinned, the inner track slides horizontally as the user scrolls vertically.\\n\\n### 5.C Scroll-Reveal Stagger - Canonical Skeleton (lighter alternative)\\n\\nFor simple \\\"items appear as they enter viewport\\\" (no pinning), prefer Motion's `whileInView` over GSAP - lighter, no ScrollTrigger needed:\\n\\n```tsx\\n\\\"use client\\\";\\nimport { motion, useReducedMotion } from \\\"motion/react\\\";\\n\\nexport function RevealStagger({ items }: { items: string[] }) {\\n const reduce = useReducedMotion();\\n return (\\n
    \\n {items.map((item, i) => (\\n \\n {item}\\n \\n ))}\\n
\\n );\\n}\\n```\\n\\nUse this for: feature lists, testimonial grids, logo walls, anything that just needs \\\"enter on scroll.\\\" Save GSAP for actual pin/scrub work.\\n\\n### 5.D Forbidden Animation Patterns\\n\\n* **`window.addEventListener(\\\"scroll\\\", ...)`** is banned. It runs on every scroll frame, jank-prone, no batching. Use Motion's `useScroll()`, GSAP's `ScrollTrigger`, IntersectionObserver, or CSS `scroll-driven animations` (`animation-timeline: view()`).\\n* **Custom scroll progress calculations using `window.scrollY`** in React state. Same reason. Re-renders on every frame.\\n* **`requestAnimationFrame` loops that touch React state.** Use motion values (`useMotionValue` + `useTransform`) instead.\\n* **Layout Transitions:** Use Motion's `layout` and `layoutId` props for visible state changes (re-ordering lists, expanding modals, shared elements between routes). Do not wrap static content in `layout` props \\\"for safety\\\" - it costs measurement work.\\n* **Staggered Orchestration:** Use `staggerChildren` (Motion) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) for reveal moments where sequence matters. For `staggerChildren`, parent (`variants`) and children MUST share the same Client Component tree.\\n\\n---\\n\\n## 6. PERFORMANCE & ACCESSIBILITY GUARDRAILS\\n\\n### 6.A Hardware Acceleration\\n* Animate ONLY `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`.\\n* Use `will-change: transform` sparingly - only on elements that will actually animate.\\n\\n### 6.B Reduced Motion (mandatory)\\n* **Any motion above `MOTION_INTENSITY > 3` MUST honor `prefers-reduced-motion`.** This is non-negotiable.\\n* In Motion: wrap with `useReducedMotion()` and degrade to static.\\n* In CSS: gate animations behind `@media (prefers-reduced-motion: no-preference)` or provide an override block under `@media (prefers-reduced-motion: reduce)` that disables.\\n* Infinite loops, parallax, scroll-hijack, and magnetic physics MUST collapse to static / instant under reduced motion.\\n\\n### 6.C Dark Mode (mandatory for any consumer-facing page)\\n* Design for **both modes from the start**. Never ship light-only or dark-only without explicit user instruction.\\n* Use Tailwind `dark:` variant OR CSS variables for tokens. Pick one strategy per project.\\n* **Do not prescribe specific dark-mode colors here.** The brief decides. Maintain visual hierarchy, brand identity, and WCAG AA contrast (AAA for body) across both modes.\\n* Respect `prefers-color-scheme: dark`. Default to system preference unless the brand insists on one mode.\\n\\n### 6.D Core Web Vitals Targets\\n* **LCP** < 2.5s. Hero image must be `next/image priority` or preloaded.\\n* **INP** < 200ms. Heavy work off main thread.\\n* **CLS** < 0.1. Reserve space for images, fonts, embeds.\\n* Run Lighthouse before declaring a page done.\\n\\n### 6.E DOM Cost\\n* Apply grain / noise filters EXCLUSIVELY to fixed, `pointer-events-none` pseudo-elements (e.g., `fixed inset-0 z-[60] pointer-events-none`). NEVER on scrolling containers - continuous GPU repaints destroy mobile FPS.\\n* Be aware of bundle size. Motion is not tiny. Three.js is large. Lazy-load anything that's not above-the-fold.\\n\\n### 6.F Z-Index Restraint\\nNEVER spam arbitrary `z-50` or `z-10`. Use z-index strictly for systemic layer contexts (sticky navbars, modals, overlays, grain). Document the z-index scale in a project constants file.\\n\\n---\\n\\n## 7. DIAL DEFINITIONS (Technical Reference)\\n\\n### DESIGN_VARIANCE (Level 1-10)\\n* **1-3 (Predictable):** Symmetrical CSS Grid (12-col, equal fr-units), equal paddings, centered alignment.\\n* **4-7 (Offset):** `margin-top: -2rem` overlaps, varied image aspect ratios (4:3 next to 16:9), left-aligned headers over center-aligned data.\\n* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (`grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`).\\n* **MOBILE OVERRIDE:** For levels 4-10, asymmetric layouts above `md:` MUST collapse to strict single-column (`w-full`, `px-4`, `py-8`) on viewports `< 768px`.\\n\\n### MOTION_INTENSITY (Level 1-10)\\n* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only. `prefers-reduced-motion` is the default mode anyway.\\n* **4-7 (Fluid CSS):** `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. `animation-delay` cascades for load-ins. Focus on `transform` and `opacity`.\\n* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals, parallax, scroll-driven animation (CSS `animation-timeline` or GSAP ScrollTrigger). Use Motion hooks. **NEVER use `window.addEventListener('scroll')`** - it is a hard ban, not a \\\"prefer-not.\\\" See Section 5.D for the allowed alternatives.\\n\\n### VISUAL_DENSITY (Level 1-10)\\n* **1-3 (Art Gallery):** Lots of white space. Huge section gaps (`py-32` to `py-48`). Expensive, clean.\\n* **4-7 (Daily App):** Standard web app spacing (`py-16` to `py-24`).\\n* **8-10 (Cockpit):** Tight paddings. No card boxes; 1px lines separate data. Mandatory: `font-mono` for all numbers.\\n\\n---\\n\\n## 8. DARK MODE PROTOCOL\\n\\nDual-mode by default. Never assume light-only unless the brief is print-emulating editorial.\\n\\n### 8.A Token Strategy (pick one, stick to it)\\n* **Tailwind `dark:` variant** (default for utility-first projects): every color utility paired with its dark variant (`bg-white dark:bg-zinc-950`, `text-gray-900 dark:text-gray-100`).\\n* **CSS variables** (for shadcn/ui, Radix Themes, or component libraries with theming): define semantic tokens (`--surface`, `--surface-elevated`, `--text-primary`, `--accent`) and swap values under `[data-theme=\\\"dark\\\"]` or `@media (prefers-color-scheme: dark)`.\\n\\n### 8.B Do Not Prescribe Specific Colors Here\\nThe brief and brand decide. This skill enforces only:\\n* **Contrast** - WCAG AA minimum for body text, AAA target for hero copy.\\n* **Hierarchy parity** - visual hierarchy that works in light must work in dark. If a CTA pops in light, it pops in dark.\\n* **Brand fidelity** - primary brand color stays recognisable. Don't desaturate the brand into a dark mode.\\n* **No pure `#000000` and no pure `#ffffff`** - use off-black (zinc-950, near-black warm gray) and off-white. Pure values kill depth.\\n\\n### 8.C Default Mode\\nRespect `prefers-color-scheme` unless the brand insists. Add a manual toggle if either mode would lose key brand expression.\\n\\n### 8.D Test in Both Modes Before Finishing\\nOpen the page in both modes during development. Do not ship a page you've only seen in one mode.\\n\\n---\\n\\n## 9. AI TELLS (Forbidden Patterns)\\n\\nAvoid these signatures unless the brief explicitly asks for them.\\n\\n### 9.A Visual & CSS\\n* **NO neon / outer glows** by default. Use inner borders or subtle tinted shadows.\\n* **NO pure black (`#000000`).** Off-black, zinc-950, or charcoal.\\n* **NO oversaturated accents.** Desaturate to blend with neutrals.\\n* **NO excessive gradient text** for large headers.\\n* **NO custom mouse cursors.** Outdated, accessibility-hostile, perf-hostile.\\n\\n### 9.B Typography\\n* **AVOID Inter as default.** See Section 4.1. Override path exists.\\n* **NO oversized H1s** that just scream. Control hierarchy with weight + color, not raw scale.\\n* **Serif constraints:** Serif for editorial / luxury / publication. Not for dashboards.\\n\\n### 9.C Layout & Spacing\\n* **Mathematically perfect** padding and margins. No floating elements with awkward gaps.\\n* **NO 3-column equal feature cards.** The generic \\\"three identical cards horizontally\\\" feature row is banned. Use 2-column zig-zag, asymmetric grid, scroll-pinned, or horizontal-scroll alternative.\\n\\n### 9.D Content & Data (\\\"Jane Doe\\\" Effect)\\n* **NO generic names.** \\\"John Doe\\\", \\\"Sarah Chan\\\", \\\"Jack Su\\\" → use creative, realistic, locale-appropriate names.\\n* **NO generic avatars.** No SVG \\\"egg\\\" or Lucide user icons → use believable photo placeholders or specific styling.\\n* **NO fake-perfect numbers.** Avoid `99.99%`, `50%`, `1234567`. Use organic, messy data (`47.2%`, `+1 (312) 847-1928`).\\n* **NO startup-slop brand names.** \\\"Acme\\\", \\\"Nexus\\\", \\\"SmartFlow\\\", \\\"Cloudly\\\" → invent contextual, premium names that sound real.\\n* **NO filler verbs.** \\\"Elevate\\\", \\\"Seamless\\\", \\\"Unleash\\\", \\\"Next-Gen\\\", \\\"Revolutionize\\\" → concrete verbs only.\\n\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-519ce139-286b-4374-85d0-9d88012177f6"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-f97891b2-d631-440d-86ae-37cf21363afc","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-f97891b2-d631-440d-86ae-37cf21363afc","delta":"{\n \"command\": \"/bin/zsh -lc 'find . -maxdepth 5 -type f -print'\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-f97891b2-d631-440d-86ae-37cf21363afc-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-f97891b2-d631-440d-86ae-37cf21363afc","content":"{\n \"status\": \"completed\",\n \"exitCode\": 0,\n \"output\": \"\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-f97891b2-d631-440d-86ae-37cf21363afc"} - -data: {"type":"CUSTOM","name":"token_usage","value":{"total":{"totalTokens":52718,"inputTokens":51855,"cachedInputTokens":32000,"outputTokens":863,"reasoningOutputTokens":97},"last":{"totalTokens":23689,"inputTokens":23378,"cachedInputTokens":14080,"outputTokens":311,"reasoningOutputTokens":33},"modelContextWindow":353400}} - -data: {"type":"REASONING_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0a2305c34b3beb10016a53a2c27fb48193b3c518cc71dbeacd"} - -data: {"type":"REASONING_MESSAGE_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0a2305c34b3beb10016a53a2c27fb48193b3c518cc71dbeacd","role":"reasoning"} - -data: {"type":"REASONING_MESSAGE_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0a2305c34b3beb10016a53a2c27fb48193b3c518cc71dbeacd"} - -data: {"type":"REASONING_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_0a2305c34b3beb10016a53a2c27fb48193b3c518cc71dbeacd"} - -data: {"type":"TOOL_CALL_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-65633abe-d720-4b05-8e77-faf2dc16b467","toolCallName":"command_execution"} - -data: {"type":"TOOL_CALL_ARGS","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-65633abe-d720-4b05-8e77-faf2dc16b467","delta":"{\n \"command\": \"/bin/zsh -lc \\\"sed -n '621,980p' /Users/mew/.agents/skills/taste-skill/SKILL.md\\\"\",\n \"cwd\": \"/Users/mew/.agework/workspaces/admin/019f522d-29cc-743f-8289-4850632cf569\"\n}"} - -data: {"type":"TOOL_CALL_RESULT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-65633abe-d720-4b05-8e77-faf2dc16b467-result","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-65633abe-d720-4b05-8e77-faf2dc16b467","content":"{\n \"status\": \"completed\",\n \"exitCode\": 0,\n \"output\": \"\\n### 9.E External Resources & Components\\n* **NO hand-rolled SVG icons.** Use Phosphor / HugeIcons / Radix / Tabler. Lucide on explicit request only.\\n* **Hand-rolled decorative SVGs strongly discouraged** as default (see Section 4.8).\\n* **NO div-based fake screenshots.** Never build a fake product UI out of `
` rectangles to simulate a screenshot. Use real images, generated images, or skip the preview.\\n* **NO broken Unsplash links.** Use `https://picsum.photos/seed/{descriptive-string}/{w}/{h}`, or generated photo placeholders, or actual assets.\\n* **shadcn/ui customization:** Allowed, but NEVER in default state. Customize radii, colors, shadows, typography to the project aesthetic.\\n* **Production-Ready Cleanliness:** Code visually clean, memorable, meticulously refined.\\n\\n### 9.F Production-Test Tells (banned outright)\\n\\nThese patterns came out of real LLM-generated landing-page tests. They are the signatures the model defaults to when it tries to \\\"look designed.\\\" Treat them as hard bans unless the brief explicitly calls for one.\\n\\n**Hero & top-of-page**\\n* **NO version labels in the hero.** `V0.6`, `v2.0`, `BETA`, `INVITE-ONLY PREVIEW`, `EARLY ACCESS`, `ALPHA` - banned as default eyebrows. Only acceptable when the brief is explicitly about a product launch / preview status.\\n* **NO \\\"Brand · No. 01\\\"-style sub-eyebrows.** \\\"Marrow · No. 01 · The 6-quart\\\" type micro-meta lines. Skip them.\\n\\n**Section numbering & micro-labels**\\n* **NO section-number eyebrows.** `00 / INDEX`, `001 · Capabilities`, `002 · Featured commission`, `06 · how it works`, `05 · The honest table` - banned. Eyebrows should name the topic in plain language, not enumerate.\\n* **NO `01 / 4`-style pagination on images or bento tiles.** If the user can count, they don't need the label.\\n* **NO `Scroll · 001 Capabilities`-style scroll cues.** A simple arrow or \\\"Scroll\\\" is enough; no section-number prefix.\\n* **NO \\\"Index of Work, 2018 - 2026\\\"-style range labels** as eyebrows. Just say what the section is.\\n\\n**Separators & dots**\\n* **The middle-dot (`·`) is rationed.** Maximum 1 per line in metadata strips. Do NOT use it as the default separator for everything (\\\"foo · bar · baz · qux · quux\\\"). If you need a separator family, prefer line breaks, hairlines, or columns.\\n* **NO decorative colored status dots on every list/nav/badge.** A colored dot before \\\"ONE Q4 SLOT OPEN\\\" or before every nav link, or every task row - banned by default. Acceptable only when the dot conveys actual semantic state (a server status, an availability flag) and is used sparingly.\\n\\n**Em-dashes & typography flourishes**\\n* **NO em-dash (`—`) as a design element OR anywhere else.** See Section 9.G below for the complete, non-negotiable ban. The em-dash character is forbidden in headlines, eyebrows, pills, body copy, quotes, attribution, captions, button text, and alt text. Use the regular hyphen (`-`).\\n* **NO `
`-broken-and-italicized headlines** as a default \\\"design move.\\\" \\\"for thirty\\\\*years.*\\\" type splits. Headlines should read naturally first, get clever only when the brief demands it.\\n* **NO vertical rotated text** (\\\"INDEX OF WORK, 2018 - 2026\\\" rotated 90°). Agency-portfolio cliché. Use it only when the brief is explicitly agency / Awwwards / experimental AND it serves a real composition purpose.\\n* **NO crosshair / hairline grid lines as decoration.** Vertical and horizontal lines drawn just to make the page \\\"feel designed\\\" - banned. Use them only when they organize real content.\\n\\n**Fake product previews**\\n* **NO div-based fake product UI in the hero** (fake task list, fake terminal, fake dashboard built from styled divs). It is the #1 LLM-design Tell. Use a real screenshot, a generated image, a real component preview, or none at all.\\n* **NO fake version footers** (\\\"v0.6.2-rc.1\\\", \\\"last sync 4s ago · main\\\") inside fake screenshots. Adds nothing, screams AI.\\n\\n**Marketing-copy Tells**\\n* **NO \\\"Quietly in use at\\\" / \\\"Quietly trusted by\\\"** social-proof headers. Use natural language: \\\"Trusted by\\\", \\\"Used at\\\", \\\"Customers include\\\", or skip the heading entirely if the logos speak.\\n* **NO \\\"From the field\\\" / \\\"Field notes\\\" / \\\"Currently on the bench\\\" / \\\"On our desks\\\" / \\\"Loose plates\\\" style poetic labels** on quote, blog, or sidebar sections. Reads as performative-craftsman. Use plain functional labels (\\\"Testimonials\\\", \\\"Latest writing\\\", \\\"Now working on\\\") or skip the label.\\n* **NO \\\"We respect the French ones\\\"-style** mock-humble industry-references in body copy. Cute and AI-y.\\n* **NO weather / locale strips** (\\\"LIS 14:23 · 18°C\\\") in headers/footers unless the brief is explicitly about a place / time-zone-distributed studio.\\n* **NO micro-meta-sentences under eyebrows.** Sentences like *\\\"Each of these is a feature we ship today, not a roadmap promise. The list will stay short on purpose.\\\"* sitting under a section heading are clutter. Eyebrow + Headline + Body is enough.\\n* **NO generic step labels.** \\\"Stage 1 / Stage 2 / Stage 3\\\", \\\"Step 1 / Step 2 / Step 3\\\", \\\"Phase 01 / Phase 02 / Phase 03\\\", \\\"Pass One / Pass Two / Pass Three\\\". Banned. The actual step content is the label. If you must show progression, use the verb-noun directly (\\\"Install\\\", \\\"Configure\\\", \\\"Ship\\\") not \\\"Stage 1: Install\\\".\\n\\n**Pills, labels and version stamps**\\n* **NO pills/labels/tags overlaid on images.** No `` overlays on photos with tags like `Brand · 02`, `PLATE · BRAND`, `Field notes - journal`. Either let the image speak alone, or add a caption directly below (outside the image).\\n* **NO photo-credit captions as decoration.** Strings like `Field study no. 12 · Ines Caetano`, `Plate 03 · House archive`, `Frame XII · 35mm` under stock/picsum images are pretentious. Photo credit is allowed ONLY when there is a real photographer being credited for a real photo (with permission). Otherwise: skip the caption or use a one-line functional caption (\\\"The 6-quart, in Sage.\\\").\\n* **NO version footers on marketing pages.** Footer strings like `v1.4.2`, `Build 0048`, `last sync 4s ago · main` are CLI / devtool fixtures, not landing-page content. Banned on marketing/landing/portfolio pages.\\n* **NO \\\"Reservation 412 of 800\\\"-style live-stock counters** as decoration. Only if the brief is explicitly a limited-run waitlist with real data.\\n\\n**Decoration text strips**\\n* **NO decoration text strip at hero bottom.** Patterns like `BRAND. MOTION. SPATIAL.`, `TYPE / FORM / MOTION`, `DESIGN · BUILD · SHIP`, `ESTD. 2018 · LISBON · BRAND. MOTION. SPATIAL.` as a small mono-caps strip across the bottom of the hero are an agency-portfolio cliché. Banned by default. Only acceptable when the strip carries real, navigable links (sticky bottom nav) or real status info (cookie banner, build info on a docs site).\\n* **NO floating top-right sub-text in section headings.** Pattern: section has a giant left-aligned headline; in the top-right corner of the same section header there is a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell. Either put the sub-text directly under the headline, or build a clean 2-column header (left: headline, right: aligned body), but not a tiny corner paragraph.\\n\\n**Lists, dividers and scoring**\\n* **NO `border-t` + `border-b` on every row of a long list / spec table.** Pick one (bottom-border between rows OR top-border above the group) and use it sparsely. A 10-row spec table with hairlines under each row is the laziest layout - see Section 4.9 for alternative UI components.\\n* **NO scoring/progress bars with filled background tracks** as comparison visuals. If you need to show \\\"X out of Y\\\" comparisons, prefer a number + small icon, or a tiny inline bar WITHOUT a background track. Big filled `bg-zinc-200` tracks with a partial fill on top are dashboard-UI clutter on a landing page.\\n\\n**Locale, time, scroll cues**\\n* **Locale / city-name / time / weather strips are banned for 99% of briefs.** \\\"Lisbon, working with founders\\\" in the hero, \\\"1200-690 Lisbon, Portugal\\\" in the footer, \\\"Lisbon 14:23 · 18°C\\\" in the nav. These are agency-portfolio decoration tells. Allowed ONLY when: the brief explicitly describes a globally-distributed studio with timezone-relevant work, OR a travel-focused brand, OR a real-world physical venue. A single contact-address mention in the footer is fine; an atmospheric locale strip is not.\\n* **Scroll cues are banned.** `Scroll`, `↓ scroll`, `Scroll to explore`, `Scroll to walk through it`, animated mouse-wheel icons. If the user has not scrolled yet, they are looking at the hero. They know what scroll is. The bottom of the viewport does not need a label.\\n* **ZERO decorative status dots by default.** A coloured dot before nav items, before list rows, before badges, before status labels is a Tell. Only acceptable when conveying real semantic state (a live indicator on actual server status, a live availability flag) and limited to one per page section.\\n\\n### 9.G EM-DASH BAN (the single most-violated Tell)\\n\\n**Em-dash (`—`) is COMPLETELY banned.** It is the LLM's signature stylistic crutch and it is the #1 visual Tell in production tests. There is no \\\"limited use\\\" allowance, no \\\"natural language frequency\\\" allowance, no \\\"in body copy is fine\\\" allowance. None.\\n\\n* **Banned in headlines.** Use a period or a comma.\\n* **Banned in eyebrows / labels / pills / button text / image captions / nav items.** Replace with line breaks, columns, or hairlines.\\n* **Banned in body copy.** Restructure the sentence: two sentences with a period, OR a comma, OR parentheses, OR a colon.\\n* **Banned in quote attribution.** Use a normal hyphen with spaces (` - `) or a line break + smaller-weight name.\\n* **Banned in en-dash form too (`–`) when used as a separator.** Date ranges (`2018-2026`) use a hyphen. Number ranges (`€40-80k`) use a hyphen.\\n\\nThe ONLY permitted dash characters on the page are:\\n* Regular hyphen `-` (for compound words, ranges, line dividers in markup)\\n* Minus sign in math (`-5°C`)\\n\\nIf your output contains a single `—` or `–` anywhere visible to the user, the output fails the Pre-Flight Check and must be rewritten.\\n\\nThis rule is non-negotiable. The agent has historically ignored em-dash limits when phrased as \\\"use sparingly.\\\" The phrasing here is binary: zero em-dashes.\\n\\n---\\n\\n## 10. REFERENCE VOCABULARY (Pattern Names the Agent Should Know)\\n\\nThis is a vocabulary, not a library. The agent should KNOW these pattern names to communicate about them, design with them in mind, and reach for them when the design read calls for them. **Implementations and code sketches live in the Block Library (Section 12), which is populated iteratively.**\\n\\n### Hero Paradigms\\n* **Asymmetric Split Hero** - Text on one side, asset on the other, generous white space.\\n* **Editorial Manifesto Hero** - Large type, no asset, almost-poster.\\n* **Video / Media Mask Hero** - Type cut out as mask over video background.\\n* **Kinetic-Type Hero** - Animated typography as the primary visual.\\n* **Curtain-Reveal Hero** - Hero parts on scroll like a curtain.\\n* **Scroll-Pinned Hero** - Hero stays pinned while content scrolls behind.\\n\\n### Navigation & Menus\\n* **Mac OS Dock Magnification** - Edge nav, icons scale fluidly on hover.\\n* **Magnetic Button** - Pulls toward cursor.\\n* **Gooey Menu** - Sub-items detach like viscous liquid.\\n* **Dynamic Island** - Morphing pill for status / alerts.\\n* **Contextual Radial Menu** - Circular menu expanding at click point.\\n* **Floating Speed Dial** - FAB springing into curved secondary actions.\\n* **Mega Menu Reveal** - Full-screen dropdown, stagger-fade content.\\n\\n### Layout & Grids\\n* **Bento Grid** - Asymmetric tile grouping (Apple Control Center).\\n* **Masonry Layout** - Staggered grid, no fixed row height.\\n* **Chroma Grid** - Borders / tiles with subtle animating gradients.\\n* **Split-Screen Scroll** - Two halves sliding in opposite directions.\\n* **Sticky-Stack Sections** - Sections that pin and stack on scroll.\\n\\n### Cards & Containers\\n* **Parallax Tilt Card** - 3D tilt tracking mouse coordinates.\\n* **Spotlight Border Card** - Borders illuminate under cursor.\\n* **Glassmorphism Panel** - Frosted glass with inner refraction.\\n* **Holographic Foil Card** - Iridescent rainbow shift on hover.\\n* **Tinder Swipe Stack** - Physical card stack, swipe-away.\\n* **Morphing Modal** - Button expands into its own dialog.\\n\\n### Scroll Animations\\n* **Sticky Scroll Stack** - Cards stick and physically stack.\\n* **Horizontal Scroll Hijack** - Vertical scroll → horizontal pan.\\n* **Locomotive / Sequence Scroll** - Video / 3D sequence tied to scrollbar.\\n* **Zoom Parallax** - Central background image zooming on scroll.\\n* **Scroll Progress Path** - SVG line drawing along scroll.\\n* **Liquid Swipe Transition** - Page transition like viscous liquid.\\n\\n### Galleries & Media\\n* **Dome Gallery** - 3D panoramic gallery.\\n* **Coverflow Carousel** - 3D carousel with angled edges.\\n* **Drag-to-Pan Grid** - Boundless draggable canvas.\\n* **Accordion Image Slider** - Narrow strips expanding on hover.\\n* **Hover Image Trail** - Mouse leaves popping image trail.\\n* **Glitch Effect Image** - RGB-channel shift on hover.\\n\\n### Typography & Text\\n* **Kinetic Marquee** - Endless text bands reversing on scroll.\\n* **Text Mask Reveal** - Massive type as transparent window to video.\\n* **Text Scramble Effect** - Matrix-style decoding on load / hover.\\n* **Circular Text Path** - Text curving along spinning circle.\\n* **Gradient Stroke Animation** - Outlined text with running gradient.\\n* **Kinetic Typography Grid** - Letters dodging the cursor.\\n\\n### Micro-Interactions & Effects\\n* **Particle Explosion Button** - CTA shatters into particles on success.\\n* **Liquid Pull-to-Refresh** - Reload indicator like detaching droplets.\\n* **Skeleton Shimmer** - Shifting light reflection across placeholders.\\n* **Directional Hover-Aware Button** - Fill enters from cursor's exact side.\\n* **Ripple Click Effect** - Wave from click coordinates.\\n* **Animated SVG Line Drawing** - Vectors drawing themselves in real time.\\n* **Mesh Gradient Background** - Organic lava-lamp blobs.\\n* **Lens Blur Depth** - Background UI blurred to focus foreground action.\\n\\n### Animation Library Choice\\n* **Motion (`motion/react`)** - default for UI / Bento / state-change motion.\\n* **GSAP + ScrollTrigger** - for full-page scrolltelling and scroll hijacks. Isolate in dedicated leaf components with `useEffect` cleanup.\\n* **Three.js / WebGL** - for canvas backgrounds and 3D scenes. Same isolation rule.\\n* **NEVER mix GSAP / Three.js with Motion in the same component tree.** They fight over the same frames.\\n\\n---\\n\\n## 11. REDESIGN PROTOCOL\\n\\nThis skill handles **greenfield builds AND redesigns**. Misclassifying the mode is the single biggest source of bad redesign output.\\n\\n### 11.A Detect the Mode (first action)\\n* **Greenfield** - no existing site, or full overhaul approved. Dial baseline from Section 1.\\n* **Redesign - Preserve** - modernise without breaking the brand. Audit first, extract brand tokens, evolve gradually.\\n* **Redesign - Overhaul** - new visual language on top of existing content. Treat as greenfield for visuals; preserve content and IA.\\n\\nIf ambiguous, ask **once**: *\\\"Should this redesign preserve the existing brand, or are we starting visually from scratch?\\\"*\\n\\n### 11.B Audit Before Touching\\nDocument the current state before proposing changes:\\n* **Brand tokens** - primary / accent colors, type stack, logo treatment, radii.\\n* **Information architecture** - page tree, primary nav, key conversion paths.\\n* **Content blocks** - what exists, what's doing work, what's filler.\\n* **Patterns to preserve** - signature interactions, recognisable hero, copy voice.\\n* **Patterns to retire** - AI-slop tells, broken layouts, dead links, generic stock imagery, perf traps.\\n* **Dial reading of the existing site** - infer current `DESIGN_VARIANCE` / `MOTION_INTENSITY` / `VISUAL_DENSITY`. That's your starting point, not the baseline.\\n* **SEO baseline** - current ranking pages, meta titles, structured data, OG cards. **SEO migration is the #1 redesign risk.**\\n\\n### 11.C Preservation Rules\\n* **Do not change information architecture** unless asked. Keep page slugs, anchor IDs, primary nav labels stable for SEO and muscle memory.\\n* **Extract brand colors before applying Section 4.2.** A brand that is already purple stays purple - apply the LILA RULE's override.\\n* **Preserve copy voice** unless asked for a rewrite. Visual modernisation ≠ content rewrite.\\n* **Honor existing accessibility wins.** Do not regress focus states, alt text, keyboard nav, contrast.\\n* **Respect existing analytics events.** Do not rename buttons, form fields, section IDs that downstream tracking depends on.\\n\\n### 11.D Modernisation Levers (priority order)\\nApply in order - stop when the brief is satisfied:\\n1. **Typography refresh** - biggest visual lift per unit of risk.\\n2. **Spacing & rhythm** - increase section padding, fix vertical rhythm.\\n3. **Color recalibration** - desaturate, unify neutrals, keep brand accent.\\n4. **Motion layer** - add `MOTION_INTENSITY`-appropriate micro-interactions to existing components.\\n5. **Hero & key-section recomposition** - restructure top-of-funnel using Section 10 vocabulary.\\n6. **Full block replacement** - only when the existing block is unsalvageable.\\n\\n### 11.E Decision Tree: Targeted Evolution vs Full Redesign\\n* IA, content, and SEO sound → **targeted evolution** (Levers 1-4). ~70% of value at ~40% of risk.\\n* Visual debt is structural (broken IA, no design system, broken mobile) → **full redesign** with strict content preservation.\\n* Brand itself is changing → **greenfield**.\\n\\n### 11.F What Never Changes Silently\\nNever modify without explicit user approval:\\n* URL structure / route slugs.\\n* Primary nav labels.\\n* Form field names or order (breaks analytics + autofill).\\n* Brand logo or wordmark.\\n* Existing legal / consent / cookie copy.\\n\\n---\\n\\n## 12. THE BLOCK LIBRARY (Contract - Implementations Land Here Iteratively)\\n\\nThe Reference Vocabulary (Section 10) names patterns. The Block Library implements them with real props, real motion specs, and real code sketches.\\n\\n**Status:** schema defined here. Blocks will be added iteratively. Do not freelance new blocks without following this schema.\\n\\n### 12.A File Location\\n```\\nskills/taste-skill/blocks/\\n hero/\\n asymmetric-split.md\\n editorial-manifesto.md\\n kinetic-type.md\\n ...\\n feature/\\n bento-grid.md\\n sticky-scroll-stack.md\\n zig-zag.md\\n ...\\n social-proof/\\n pricing/\\n cta/\\n footer/\\n navigation/\\n portfolio/\\n transition/\\n```\\n\\n### 12.B Required Frontmatter\\n```yaml\\n---\\nname: asymmetric-split-hero\\ncategory: hero\\ndial_compatibility:\\n variance: [6, 10]\\n motion: [3, 10]\\n density: [2, 5]\\nwhen_to_use: \\\"Landing pages with one strong asset and one strong message. Default hero for SaaS, agency, premium consumer.\\\"\\nnot_for: \\\"Editorial / manifesto launches where the message IS the design.\\\"\\nstack: [\\\"react\\\", \\\"next\\\", \\\"tailwind\\\", \\\"motion\\\"]\\n---\\n```\\n\\n### 12.C Required Body Sections\\n1. **Visual sketch** - short ASCII or description of the layout.\\n2. **Props API** - the component's interface.\\n3. **Code sketch** - minimal working implementation (Server Component default, Client island for motion).\\n4. **Mobile fallback** - explicit collapse rules for `< 768px`.\\n5. **Motion variants** - one variant per `MOTION_INTENSITY` band (1-3, 4-7, 8-10). Reduced-motion fallback explicit.\\n6. **Dark-mode notes** - token strategy specific to this block.\\n7. **Anti-patterns** - common ways this block goes wrong.\\n8. **References** - links to real examples in production.\\n\\n### 12.D Block-Library Discipline\\n* One block per file. No multi-block files.\\n* Every block must work standalone (drop it into a page, it renders).\\n* Every block must pass the Pre-Flight Check (Section 14).\\n* Blocks that depend on a design system from Section 2.A live under `blocks//--.md` (e.g. `feature/bento-grid--material.md`).\\n\\n---\\n\\n## 13. OUT OF SCOPE\\n\\nThis skill is NOT for:\\n* Dashboards / dense product UI / admin panels (use Fluent, Carbon, Atlassian, or Polaris from Section 2.A).\\n* Data tables (use TanStack Table or AG Grid).\\n* Multi-step forms / wizards (use Form-specific patterns; this skill won't make them better).\\n* Code editors (use Monaco / CodeMirror with their official skinning).\\n* Native mobile (use Apple HIG / Material directly).\\n* Realtime collab UIs (presence, cursors, OT-aware - different problem class).\\n\\nIf the brief is one of the above, **say so explicitly**, point to the right tool, and only apply this skill's marketing-page / about-page / landing-page parts to the surfaces where they apply.\\n\\n---\\n\\n## 14. FINAL PRE-FLIGHT CHECK\\n\\nRun this matrix before outputting code. This is the last filter.\\n\\n**THIS IS NOT OPTIONAL. Run every box. If any box fails, the output is not done.**\\n\\n- [ ] **Brief inference** declared (Section 0.B one-liner)?\\n- [ ] **Dial values** explicit and reasoned from the brief, not silently using baseline?\\n- [ ] **Design system** chosen from Section 2 if applicable, or aesthetic labeled honestly?\\n- [ ] **Redesign mode** detected and audit performed (if applicable, Section 11)?\\n- [ ] **ZERO em-dashes (`—`) anywhere on the page.** Headlines, eyebrows, pills, body, quotes, attribution, captions, buttons, alt text. Zero. (Section 9.G - non-negotiable.)\\n- [ ] **Page Theme Lock**: ONE theme (light, dark, or auto) for the whole page. No section flips to inverted mode mid-page (Section 4.11)?\\n- [ ] **Color Consistency Lock**: one accent color used identically across all sections (Section 4.2)?\\n- [ ] **Shape Consistency Lock**: one corner-radius system applied consistently (Section 4.4)?\\n- [ ] **Button Contrast Check**: every CTA text is readable against its background (no white-on-white, WCAG AA 4.5:1)?\\n- [ ] **CTA Button Wrap**: no CTA label wraps to 2+ lines at desktop?\\n- [ ] **Form Contrast Check**: form inputs, placeholders, focus rings, labels all pass WCAG AA against the section background?\\n- [ ] **Serif discipline**: if a serif is used, it is NOT Fraunces or Instrument_Serif (or it is, with explicit brand justification)? Different serif from your previous project?\\n- [ ] **Premium-consumer palette check**: if the brief is premium-consumer (cookware / wellness / artisan / luxury), the palette is NOT the AI-default beige+brass+oxblood+espresso family? Different family from your previous premium-consumer project?\\n- [ ] **Italic descender clearance**: every italic word with `y g j p q` has `leading-[1.1]` min + `pb-1` reserve?\\n- [ ] **Hero fits the viewport**: headline ≤ 2 lines, subtext ≤ 20 words AND ≤ 4 lines, CTA visible without scroll, font scale planned around image?\\n- [ ] **Hero top padding**: max `pt-24` at desktop, hero content does not float halfway down the viewport?\\n- [ ] **Hero stack discipline**: max 4 text elements in hero (eyebrow OR brand strip, headline, subtext, CTAs)? No tiny tagline below CTAs, no trust micro-strip in hero?\\n- [ ] **EYEBROW COUNT (mechanical)**: count instances of `uppercase tracking` micro-labels above section headlines across all components. Count ≤ ceil(sectionCount / 3)? Hero counts as 1.\\n- [ ] **Split-Header Ban**: no \\\"left big headline + right small explainer paragraph\\\" pattern as a section header (vertical stack instead)?\\n- [ ] **Zigzag Alternation Cap**: no 3+ consecutive sections with the same image+text-split layout?\\n- [ ] **No Duplicate CTA Intent**: no two CTAs with the same intent (\\\"Get in touch\\\" + \\\"Let's talk\\\" both on page = Fail)?\\n- [ ] **Logo wall = logo only**: no industry / category labels printed below logos?\\n- [ ] **Bento Background Diversity**: at least 2-3 bento cells have real visual variation (image, gradient, pattern), not all white-on-white text cards?\\n- [ ] **\\\"Used by / Trusted by\\\" logo wall** lives UNDER the hero, not inside it, uses REAL SVG logos (Simple Icons / devicon) or generated SVG marks, NOT plain text wordmarks?\\n- [ ] **Copy Self-Audit**: every visible string re-read, no grammatically-broken or AI-hallucinated phrases (\\\"free on its past\\\" type) shipped?\\n- [ ] **Motion motivated**: every animation can be justified in one sentence (hierarchy / storytelling / feedback / state transition), no GSAP-for-show?\\n- [ ] **Marquee max-one-per-page**: no two horizontal marquees on the same page?\\n- [ ] **Navigation on ONE line** at desktop, height ≤ 80px?\\n- [ ] **Section-Layout-Repetition** check: no two sections share the same layout family (at least 4 different families across 8 sections)?\\n- [ ] **Bento has rhythm AND exact cell count** (N items → N cells, no empty cells in middle or at end)?\\n- [ ] **Long lists use the right UI component** (not default `
    ` with `divide-y` for > 5 items - see Section 4.9 alternatives)?\\n- [ ] **Real images used** (gen-tool first, then Picsum-seed, then explicit placeholder slots) - NO div-based fake screenshots, NO hand-rolled decorative SVGs, NO pure-text minimalism?\\n- [ ] **No pills/labels overlaid on images** (no `Plate · Brand`, no `Field notes - journal`)?\\n- [ ] **No photo-credit captions as decoration** (`Field study no. 12 · Ines Caetano`)?\\n- [ ] **No version footers** (`v1.4.2`, `Build 0048`) on marketing pages?\\n- [ ] **No micro-meta-sentences** under eyebrows (\\\"Each of these is a feature we ship today...\\\")?\\n- [ ] **No decoration text strip at hero bottom** (`BRAND. MOTION. SPATIAL.`)?\\n- [ ] **No floating top-right sub-text** in section headings?\\n- [ ] **No scoring/progress bars with filled background tracks** as comparison visuals?\\n- [ ] **No locale / city-name / time / weather strips** unless brief is genuinely globally-distributed or place-focused?\\n- [ ] **No scroll cues** (`Scroll`, `↓ scroll`, `Scroll to explore`)?\\n- [ ] **No version labels in hero** (V0.6, BETA, INVITE-ONLY) unless the brief is a launch?\\n- [ ] **No section-numbering eyebrows** (`00 / INDEX`, `001 · Capabilities`, `06 · how it works`)?\\n- [ ] **No decorative dots** (zero by default, only for real semantic state)?\\n- [ ] **No `border-t` + `border-b` on every row** of long lists / spec tables?\\n- [ ] **Content density** sane: no 20-row data tables, no fake-precise specs without justification, ≤ 25-word sub-paragraphs by default?\\n- [ ] **Quotes ≤ 3 lines** of body, attribution clean (no em-dash)?\\n- [ ] **Motion claimed = motion shown**: if `MOTION_INTENSITY > 4`, page actually animates, not just claimed?\\n- [ ] **GSAP sticky-stack / horizontal-pan** implemented per Section 5.A / 5.B canonical skeleton (`start: \\\"top top\\\"`, `pin: true`, correct scrub)?\\n- [ ] **No `window.addEventListener('scroll')`** - using Motion `useScroll()` / ScrollTrigger / IntersectionObserver / CSS scroll-driven animations only?\\n- [ ] **Reduced motion** wrapped for everything `MOTION_INTENSITY > 3`?\\n- [ ] **Dark mode** tokens defined and tested in both modes?\\n- [ ] **Mobile collapse** explicit (`w-full`, `px-4`, `max-w-7xl mx-auto`) for high-variance layouts?\\n- [ ] **Viewport stability**: `min-h-[100dvh]`, never `h-screen`?\\n- [ ] **`useEffect` animations** have strict cleanup functions?\\n- [ ] **Empty / loading / error** states provided?\\n- [ ] **Cards omitted** in favor of spacing where possible?\\n- [ ] **Icons** from an allowed library only (Phosphor / HugeIcons / Radix / Tabler), no hand-rolled SVG paths?\\n- [ ] **Motion** isolated in client-leaf components with `'use client'` at the top, memoized?\\n- [ ] **No AI Tells** from Section 9 (Inter as default, AI-purple, three-equal cards, Jane Doe, Acme, \\\"Quietly in use at\\\")?\\n- [ ] **Core Web Vitals** plausibly hit (LCP < 2.5s, INP < 200ms, CLS < 0.1)?\\n- [ ] **One design system** per project (no Material + shadcn mixed)?\\n\\nIf a single checkbox cannot be honestly ticked, the page is not done. Fix it before delivering.\\n\\n\",\n \"durationMs\": 0\n}","role":"tool"} - -data: {"type":"TOOL_CALL_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","toolCallId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-exec-65633abe-d720-4b05-8e77-faf2dc16b467"} - -data: {"type":"CUSTOM","name":"token_usage","value":{"total":{"totalTokens":82670,"inputTokens":81700,"cachedInputTokens":54272,"outputTokens":970,"reasoningOutputTokens":104},"last":{"totalTokens":29952,"inputTokens":29845,"cachedInputTokens":22272,"outputTokens":107,"reasoningOutputTokens":7},"modelContextWindow":353400}} - -data: {"type":"REASONING_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00484b3b1281434c016a53a2c927f4819081c940d955a31afb"} - -data: {"type":"REASONING_MESSAGE_START","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00484b3b1281434c016a53a2c927f4819081c940d955a31afb","role":"reasoning"} - -data: {"type":"REASONING_MESSAGE_CONTENT","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00484b3b1281434c016a53a2c927f4819081c940d955a31afb","delta":"**Requesting original HTML file**"} - -data: {"type":"REASONING_MESSAGE_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00484b3b1281434c016a53a2c927f4819081c940d955a31afb"} - -data: {"type":"REASONING_END","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-rs_00484b3b1281434c016a53a2c927f4819081c940d955a31afb"} - -data: {"type":"TEXT_MESSAGE_START","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","role":"assistant"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"工作"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"区"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"里"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"目前"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"没有"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" HTML"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" 或"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"前"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"端"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"源码"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":","} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"只有"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"空"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"的"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" `."} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"cla"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"ude"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"`"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" "} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"目录"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"。\n\n"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"请"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"把"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"需要"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"优化"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"的"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" HTML"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" 文件"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"上传"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"或"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"同步"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"到"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"工作"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"区"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":",也"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"可以"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"直接"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"粘"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"贴"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"代码"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"。我"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"拿"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"到"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"后"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"按"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"现"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"有"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"内容"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"做"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"视觉"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"层"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"级"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"响应"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"式"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"交"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"互"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"状态"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"和"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"可"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"访问"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"性"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"优化"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":",不"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"会"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"自动"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" build"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"、"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"lint"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":" 或"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"打开"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"浏览"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"器"} - -data: {"type":"TEXT_MESSAGE_CONTENT","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1","delta":"。"} - -data: {"type":"TEXT_MESSAGE_END","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b","messageId":"019f56b3-34d7-749b-9cda-62b6cbb4245b-msg_00484b3b1281434c016a53a2cbb71081909e8f10492b85e2a1"} - -data: {"type":"CUSTOM","name":"token_usage","value":{"total":{"totalTokens":119692,"inputTokens":118533,"cachedInputTokens":83712,"outputTokens":1159,"reasoningOutputTokens":210},"last":{"totalTokens":37022,"inputTokens":36833,"cachedInputTokens":29440,"outputTokens":189,"reasoningOutputTokens":106},"modelContextWindow":353400}} - -data: {"type":"RUN_FINISHED","threadId":"019f56b3-3541-766e-bd23-4a88a482785d","runId":"019f56b3-34d7-749b-9cda-62b6cbb4245b"} - diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 534df7b0..67fd0262 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -13,16 +13,18 @@ Runtime manager (registered daemon entry + builtin Host library). `src/host/` - ADRs: [`apps/runtime/docs/adr/`](apps/runtime/docs/adr/) - `0001-sdk-external-plus-real-npm-install.md` — SDK 保持 external,靠真实 npm install 提供二进制,不靠 bundle inline -### `apps/server` → runtime module — RuntimeHost 注册表 +### `apps/server` → runtime-host module — 注册表、契约路由与管理门面 -RuntimeHost 的注册、软删除、EnvConfig 两层分离、CLI 检测归属。Phase 3:`Runtime` 模型改名 `RuntimeHost`,`runtimeType` 列删除(能力矩阵在 `capabilities` JSON),builtin 假行合并为一行 `"builtin"`。 +RuntimeHost 的注册/软删除、EnvConfig、CLI 检测持久化,以及 `RuntimeHostContract` 的 builtin/registered 路由。业务模块经 `RuntimeHostService` 消费环境、文件、诊断与资源对账;run 执行和启动期反向接线保留窄 token。worker 池、信箱、握手与 fence 归 `@agework/runtime/host`。 + +- 当前边界定案:[`docs/design/runtime-owner-boundary.md`](docs/design/runtime-owner-boundary.md) - ADRs: [`apps/server/src/runtime-host/docs/adr/`](apps/server/src/runtime-host/docs/adr/) - `0001-runtime-soft-delete-required-fk.md` — RuntimeHost 只软删除,Workspace.runtimeHostId 必填 - `0002-envconfig-two-layer-detected-vs-override.md` — EnvConfig 两层分离:detected 与 override 独立存储 - `0003-cli-resolver-in-runtime-not-server.md` — CliResolver 放 apps/runtime,server 不做 CLI 检测 - `0004-container-cli-path-via-env-not-envconfig.md` — Container CLI 路径不经 envConfig,直接 env 注入 - - `0005-managed-container-runtime-process.md` — Managed container runtime 作为独立进程 + - `0005` ~ `0011` — **SUPERSEDED** 的旧执行栈记录,仅作历史参考 ### `apps/server` → run module — run 生命周期编排 @@ -32,13 +34,6 @@ RuntimeHost 的注册、软删除、EnvConfig 两层分离、CLI 检测归属。 - `0001-question-interrupt-terminal-model.md` — 问答走 AG-UI interrupt terminal model,worker/SDK 保持 pause model - `0002-resume-payload-generalization.md` — resume 契约泛化为 provider 无关 payload + 接受 cancelled(为 Codex 审批 decision/decline,顺带解锁 Claude 权限拒绝) -### `apps/server` → runtime-host module — contract 实现 + admin 观测面 - -Phase 3 清尾后只剩 `RuntimeHostContract` 实现(`RuntimeHostAdapter`,路由 builtin/registered)+ `AdminWorkerController`(现场查询)+ `WorkspaceHostListener`(workspace 删除时调 `releaseResources`)。旧执行栈(connection/instance/registry)已全部删除。 - -- ⚠ ADRs: [`apps/server/src/runtime-host/docs/adr/`](apps/server/src/runtime-host/docs/adr/) — **全部 SUPERSEDED**,仅保留为旧执行栈的历史记录。worker 池/信箱/握手/fence 已移入 `@agework/runtime/host` 的 `RuntimeHost` 库。 - - `0001` ~ `0006` — 旧 worker-manager 执行栈设计,已删除 - ### `apps/web` — 前端 RunSession(run 生命周期前端唯一归属,不碰 aui)、resume 数据流、runStatus 唯一写入面、aui 接线层。 diff --git a/apps/server/src/runtime-host/docs/adr/0008-worker-concurrency-key-stays-owner-id.md b/apps/server/src/runtime-host/docs/adr/0008-worker-concurrency-key-stays-owner-id.md index 05b1f78b..2ab275bf 100644 --- a/apps/server/src/runtime-host/docs/adr/0008-worker-concurrency-key-stays-owner-id.md +++ b/apps/server/src/runtime-host/docs/adr/0008-worker-concurrency-key-stays-owner-id.md @@ -1,4 +1,4 @@ -> **⚠ SUPERSEDED**: 本 ADR 已被 server-runtime-worker 目标架构推翻。worker-manager 执行栈在 Phase 3 全部删除,worker 池/信箱/握手/fence 移入 `@agework/runtime/host`。OwnerKey/WorkerKey 公共协议概念已被 [SPEC](../../../../../.scratch/runtime-owner-boundary/SPEC.md) 进一步推翻,复用身份由 Runtime 内部派生。 +> **⚠ SUPERSEDED**: 本 ADR 已被 server-runtime-worker 目标架构推翻。worker-manager 执行栈在 Phase 3 全部删除,worker 池/信箱/握手/fence 移入 `@agework/runtime/host`。OwnerKey/WorkerKey 公共协议概念已被 [Runtime owner boundary](../../../../../../docs/design/runtime-owner-boundary.md) 进一步推翻,复用身份由 Runtime 内部派生。 # Worker 防重键维持裸 ownerId,不升级成 (ownerId, isolationScope, runtimeId) diff --git a/apps/server/src/runtime-host/docs/adr/0009-workspace-file-commands-independent-channel.md b/apps/server/src/runtime-host/docs/adr/0009-workspace-file-commands-independent-channel.md index cc09fc3e..1481c69e 100644 --- a/apps/server/src/runtime-host/docs/adr/0009-workspace-file-commands-independent-channel.md +++ b/apps/server/src/runtime-host/docs/adr/0009-workspace-file-commands-independent-channel.md @@ -1,4 +1,4 @@ -> **⚠ SUPERSEDED**: 本 ADR 已被 server-runtime-worker 目标架构推翻。worker-manager 执行栈在 Phase 3 全部删除,worker 池/信箱/握手/fence 移入 `@agework/runtime/host`。OwnerKey/WorkerKey 公共协议概念已被 [SPEC](../../../../../.scratch/runtime-owner-boundary/SPEC.md) 进一步推翻,复用身份由 Runtime 内部派生。 +> **⚠ SUPERSEDED**: 本 ADR 已被 server-runtime-worker 目标架构推翻。worker-manager 执行栈在 Phase 3 全部删除,worker 池/信箱/握手/fence 移入 `@agework/runtime/host`。OwnerKey/WorkerKey 公共协议概念已被 [Runtime owner boundary](../../../../../../docs/design/runtime-owner-boundary.md) 进一步推翻,复用身份由 Runtime 内部派生。 # 工作空间文件命令(list_files/read_file)走独立通道,不进 command.result / RunEvent diff --git a/apps/server/src/runtime-host/docs/adr/0011-composite-unique-key-overturns-0008.md b/apps/server/src/runtime-host/docs/adr/0011-composite-unique-key-overturns-0008.md index a97823f6..a06d1485 100644 --- a/apps/server/src/runtime-host/docs/adr/0011-composite-unique-key-overturns-0008.md +++ b/apps/server/src/runtime-host/docs/adr/0011-composite-unique-key-overturns-0008.md @@ -1,4 +1,4 @@ -> **⚠ SUPERSEDED**: 本 ADR 已被 server-runtime-worker 目标架构推翻。worker-manager 执行栈在 Phase 3 全部删除,worker 池/信箱/握手/fence 移入 `@agework/runtime/host`。OwnerKey/WorkerKey 公共协议概念已被 [SPEC](../../../../../.scratch/runtime-owner-boundary/SPEC.md) 进一步推翻,复用身份由 Runtime 内部派生。 +> **⚠ SUPERSEDED**: 本 ADR 已被 server-runtime-worker 目标架构推翻。worker-manager 执行栈在 Phase 3 全部删除,worker 池/信箱/握手/fence 移入 `@agework/runtime/host`。OwnerKey/WorkerKey 公共协议概念已被 [Runtime owner boundary](../../../../../../docs/design/runtime-owner-boundary.md) 进一步推翻,复用身份由 Runtime 内部派生。 # Worker 防重键升级为 (ownerId, runtimeId, isolationScope) 复合唯一约束 diff --git a/docs/README.md b/docs/README.md index f8be57e7..637ce0bf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,6 +42,7 @@ | 文档 | 内容 | |---|---| | [design/server-runtime-worker-target-architecture.md](design/server-runtime-worker-target-architecture.md) | Server · Runtime Host · Worker 三层目标架构(已落地) | +| [design/runtime-owner-boundary.md](design/runtime-owner-boundary.md) | Server 业务事实与 Runtime 隔离、复用、生命周期权威边界(已落地) | ## 实验性能力 diff --git a/.scratch/runtime-owner-boundary/SPEC.md b/docs/design/runtime-owner-boundary.md similarity index 99% rename from .scratch/runtime-owner-boundary/SPEC.md rename to docs/design/runtime-owner-boundary.md index 69763c4a..de6ae72d 100644 --- a/.scratch/runtime-owner-boundary/SPEC.md +++ b/docs/design/runtime-owner-boundary.md @@ -1,14 +1,12 @@ # Server / Runtime 隔离与复用边界重构 -Status: ready-for-implementation +Status: implemented Date: 2026-07-19 Related: -- [`docs/design/server-runtime-worker-target-architecture.md`](../../docs/design/server-runtime-worker-target-architecture.md) -- [`research-plan.md`](research-plan.md) -- [`working.md`](working.md) +- [`server-runtime-worker-target-architecture.md`](server-runtime-worker-target-architecture.md) ## 1. 结论 diff --git a/docs/design/server-runtime-worker-target-architecture.md b/docs/design/server-runtime-worker-target-architecture.md index 8230c0c3..607662f8 100644 --- a/docs/design/server-runtime-worker-target-architecture.md +++ b/docs/design/server-runtime-worker-target-architecture.md @@ -2,14 +2,14 @@ > 状态:已落地(2026-07-14):三期迁移全部完成并通过出口判据验收(词汇 grep 清零、run/runtime 模块无 P0/P1);registered Host 多 runtimeType 能力已收口(daemon `--runtime native,docker` 多值 + 启动时按类型探测可用性上报)。 > -> **⚠️ 更新(2026-07-19)**:本文档中关于 `OwnerKey`/`WorkerKey`、`releaseOwner`、`listOwners` 等 owner 边界设计已被 `.scratch/runtime-owner-boundary/SPEC.md` 全面取代。SPEC 移除了公共协议中的 `OwnerKey`/`WorkerKey`,改由 Server 下发业务事实(`scope + userId + workspaceId + userLifecycleVersion`),Runtime 独立派生 `ReuseIdentity` 并决定复用策略。本文档的以下部分与 SPEC 冲突,以 SPEC 为准: +> **⚠️ 更新(2026-07-19)**:本文档中关于 `OwnerKey`/`WorkerKey`、`releaseOwner`、`listOwners` 等 owner 边界设计已被 [`runtime-owner-boundary.md`](runtime-owner-boundary.md) 全面取代。该定案移除了公共协议中的 `OwnerKey`/`WorkerKey`,改由 Server 下发业务事实(`scope + userId + workspaceId + userLifecycleVersion`),Runtime 独立派生 `ReuseIdentity` 并决定复用策略。本文档的以下部分与该定案冲突,以后者为准: > - §3 中 `OwnerKey`/`WorkerKey` 类型定义和构造规则 > - §3 中 `releaseOwner(owner: OwnerKey)` 和 `stopWorker(key: WorkerKey)` 契约 > - §4 中 worker 池用 `WorkerKey` 索引的描述 > - §5 中 `placement.owner` 字段 > - §7 中涉及 owner 级 RPC 的迁移步骤 > -> 本文仍是三层关系(Server → Runtime Host → Worker)和执行面隔离原则的权威参考;owner 边界细节请参阅 SPEC。 +> 本文仍是三层关系(Server → Runtime Host → Worker)和执行面隔离原则的权威参考;owner 边界细节请参阅 [`runtime-owner-boundary.md`](runtime-owner-boundary.md)。 > 本文是三层关系的重新设计:**server 只管业务事实,Runtime Host 独占执行面,worker 是唯一的执行代理概念**。 > 它有意推翻若干既有定案(见 §6 翻案清单),实施按 §7 三期迁移推进。 diff --git a/packages/shared/src/protocol/runtime-host.ts b/packages/shared/src/protocol/runtime-host.ts index fb84f023..fca50c0a 100644 --- a/packages/shared/src/protocol/runtime-host.ts +++ b/packages/shared/src/protocol/runtime-host.ts @@ -13,7 +13,7 @@ import type { // ── Server ↔ Runtime Host 契约 ────────────────────────────────────── // -// 设计定案见 .scratch/runtime-owner-boundary/SPEC.md。 +// 设计定案见 docs/design/runtime-owner-boundary.md。 // Server 只下发 scope 与业务身份事实,Runtime 是复用身份与复用策略的唯一权威。 /** Runtime SDK 插件声明的开放标识,如 native / docker / opensandbox。 */ From 70d51c877c402da42351b2639cd8b90ac52d99d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:12:39 +0800 Subject: [PATCH 06/18] fix(runtime): keep dependency sync build-free --- .../scripts/sync-bundled-agent-runtime.ts | 2 +- .../worker/agent/bundled-plugin-manifest.ts | 15 ++++++++++++ .../src/worker/agent/bundled-plugins.ts | 24 ++++++++++--------- 3 files changed, 29 insertions(+), 12 deletions(-) create mode 100644 apps/runtime/src/worker/agent/bundled-plugin-manifest.ts diff --git a/apps/runtime/scripts/sync-bundled-agent-runtime.ts b/apps/runtime/scripts/sync-bundled-agent-runtime.ts index c550a055..e8312c40 100644 --- a/apps/runtime/scripts/sync-bundled-agent-runtime.ts +++ b/apps/runtime/scripts/sync-bundled-agent-runtime.ts @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { listBundledAgentPluginIds } from "../src/worker/agent/bundled-plugins.ts"; +import { listBundledAgentPluginIds } from "../src/worker/agent/bundled-plugin-manifest.ts"; import { BUILTIN_AGENT_RUNTIME_REQUIREMENTS } from "../../../packages/adapters/src/runtime-requirements.ts"; import { listAcpProfiles } from "../../../packages/agent-acp/src/agents/registry.ts"; import type { diff --git a/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts b/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts new file mode 100644 index 00000000..4f11f084 --- /dev/null +++ b/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts @@ -0,0 +1,15 @@ +/** + * Bundled plugin metadata shared by build-time checks and Worker startup. + * + * Keep this module data-only: dependency synchronization runs before workspace + * packages are built, so importing plugin factories here would require their + * generated dist entrypoints too early. + */ +export const BUNDLED_AGENT_PLUGIN_IDS = ["builtin-agents", "acp"] as const; + +export type BundledAgentPluginId = + (typeof BUNDLED_AGENT_PLUGIN_IDS)[number]; + +export function listBundledAgentPluginIds(): BundledAgentPluginId[] { + return [...BUNDLED_AGENT_PLUGIN_IDS]; +} diff --git a/apps/runtime/src/worker/agent/bundled-plugins.ts b/apps/runtime/src/worker/agent/bundled-plugins.ts index c7c47587..4cce48d1 100644 --- a/apps/runtime/src/worker/agent/bundled-plugins.ts +++ b/apps/runtime/src/worker/agent/bundled-plugins.ts @@ -1,16 +1,22 @@ import { createAgentPlugin as createBuiltinAgentPlugin } from "@agework/adapters/plugin"; import { createAgentPlugin as createAcpAgentPlugin } from "@agework/agent-acp"; import type { AgentPlugin } from "@agework/agent-sdk"; +import { + BUNDLED_AGENT_PLUGIN_IDS, + type BundledAgentPluginId, +} from "./bundled-plugin-manifest"; -/** Single registry used by both Worker execution and managed Runtime packaging. */ -const BUNDLED_AGENT_PLUGINS = [ - { id: "builtin-agents", create: createBuiltinAgentPlugin }, - { id: "acp", create: createAcpAgentPlugin }, -] as const; +const BUNDLED_AGENT_PLUGIN_FACTORIES: Record< + BundledAgentPluginId, + () => AgentPlugin +> = { + "builtin-agents": createBuiltinAgentPlugin, + acp: createAcpAgentPlugin, +}; export function createBundledAgentPlugins(): AgentPlugin[] { - return BUNDLED_AGENT_PLUGINS.map(({ id, create }) => { - const plugin = create(); + return BUNDLED_AGENT_PLUGIN_IDS.map((id) => { + const plugin = BUNDLED_AGENT_PLUGIN_FACTORIES[id](); if (plugin.id !== id) { throw new Error( `Bundled agent plugin registry expected ${id}, received ${plugin.id}` @@ -19,7 +25,3 @@ export function createBundledAgentPlugins(): AgentPlugin[] { return plugin; }); } - -export function listBundledAgentPluginIds(): string[] { - return BUNDLED_AGENT_PLUGINS.map(({ id }) => id); -} From f9af05d12cfbdff2aee00e15990d085cb505e9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:39:21 +0800 Subject: [PATCH 07/18] refactor(run): route launch failures through status owner --- .../src/run/launch/run.launcher.spec.ts | 24 +++++++---- apps/server/src/run/launch/run.launcher.ts | 40 ++++++------------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/apps/server/src/run/launch/run.launcher.spec.ts b/apps/server/src/run/launch/run.launcher.spec.ts index ffd6ca18..582cf1b1 100644 --- a/apps/server/src/run/launch/run.launcher.spec.ts +++ b/apps/server/src/run/launch/run.launcher.spec.ts @@ -9,6 +9,7 @@ import { RunEventService } from "../../run-event/run-event.service"; import { ConfigService } from "../../config/config.service"; import type { StartRunInput } from "../run.types"; import type { WorkspaceRunContext } from "../../workspace/workspace.types"; +import type { RunStatusService } from "../status/run-status.service"; function makeWorkspaceView( overrides: Partial = {} @@ -35,6 +36,7 @@ describe("RunLauncher", () => { let mockConversationEffects: Partial; let mockRunEvents: RunEventService; let mockConfigService: Partial; + let mockRunStatusService: Partial; let stopActiveRun: ReturnType; function makeRes() { @@ -109,13 +111,17 @@ describe("RunLauncher", () => { isWorkerScopeAllowed: (s: string): s is "user" | "workspace" => s === "user" || s === "workspace", }; + mockRunStatusService = { + failRun: vi.fn().mockResolvedValue(undefined), + }; launcher = new RunLauncher( mockRunRepository as RunRepository, mockLiveRunRegistry as LiveRunRegistry, mockRuntimeHost as RuntimeHostContract, mockConversationEffects as ConversationService, mockRunEvents, - mockConfigService as ConfigService + mockConfigService as ConfigService, + mockRunStatusService as RunStatusService ); }); @@ -291,7 +297,7 @@ describe("RunLauncher", () => { expect(res.write).not.toHaveBeenCalled(); }); - it("rolls back on submit failure", async () => { + it("routes submit failure through the run status owner", async () => { mockRuntimeHost.submitRun = vi .fn() .mockRejectedValue(new Error("spawn failed")); @@ -303,13 +309,15 @@ describe("RunLauncher", () => { "run-1", expect.any(Object) ); - expect(mockLiveRunRegistry.unregister).toHaveBeenCalledWith("run-1"); - expect(mockRunRepository.markError).toHaveBeenCalledWith( - "run-1", - "Failed to start worker" - ); + expect(mockRunStatusService.failRun).toHaveBeenCalledWith({ + runId: "run-1", + conversationId: "conversation-1", + error: "启动 worker 失败: spawn failed", + }); + expect(mockRunRepository.markError).not.toHaveBeenCalled(); expect( mockConversationEffects.setConversationRunState - ).toHaveBeenCalledWith("conversation-1", { runStatus: "error" }); + ).not.toHaveBeenCalled(); + expect(mockLiveRunRegistry.unregister).not.toHaveBeenCalled(); }); }); diff --git a/apps/server/src/run/launch/run.launcher.ts b/apps/server/src/run/launch/run.launcher.ts index d7009db1..83c8ca50 100644 --- a/apps/server/src/run/launch/run.launcher.ts +++ b/apps/server/src/run/launch/run.launcher.ts @@ -31,6 +31,7 @@ import { RunEventService } from "../../run-event/run-event.service"; import type { StartRunInput } from "../run.types"; import type { WorkspaceRunContext } from "../../workspace/workspace.types"; import { RunStream } from "../streaming/run.stream"; +import { RunStatusService } from "../status/run-status.service"; type SaveRun = ( complete: boolean, @@ -63,7 +64,8 @@ export class RunLauncher { private readonly runtimeHost: RuntimeHostExecution, private readonly conversationService: ConversationService, private readonly runEvents: RunEventService, - private readonly configService: ConfigService + private readonly configService: ConfigService, + private readonly runStatusService: RunStatusService ) {} /** 启动一次 run:从 placement 解析到 live handle 注册的完整出站准备链路。 */ @@ -151,15 +153,13 @@ export class RunLauncher { res, }); - const submitted = await this.submitToHost({ + await this.submitToHost({ runId, conversationId, placement, agentProviderConfig, runInput, - stream, }); - if (!submitted) this.liveRuns.unregister(runId); } /** @@ -392,15 +392,13 @@ export class RunLauncher { placement: RunPlacement; agentProviderConfig: AgentProviderConfig; runInput: unknown; - stream: RunStream; - }): Promise { + }): Promise { const { runId, conversationId, placement, agentProviderConfig, runInput, - stream, } = input; const runtimeType = placement.runtimeType; @@ -421,7 +419,7 @@ export class RunLauncher { agentProviderConfig, input: runInput, }); - return true; + return; } catch (err) { this.logger.error("start worker failed", { runId, @@ -429,40 +427,26 @@ export class RunLauncher { runtimeType, ...errorLogFields(err), }); - await this.runRepository - .markError(runId, "Failed to start worker") - .catch(swallow(this.logger, `mark run ${runId} start failure`)); - this.runEvents + const errorMsg = err instanceof Error ? err.message : String(err); + await this.runEvents .append( this.runEvents.runtimeStatusChanged({ runId, status: "start_failed", runtimeType, scope: placement.scope, - error: err instanceof Error ? err.message : String(err), + error: errorMsg, data: errorLogFields(err), }) ) .catch( swallow(this.logger, `record runtime start failure for run ${runId}`) - ) - .finally(() => this.runEvents.forgetRun(runId)); - await this.conversationService - .setConversationRunState(conversationId, { runStatus: "error" }) - .catch( - swallow( - this.logger, - `set conversation active run status to error for run ${runId}` - ) ); - const errorMsg = err instanceof Error ? err.message : String(err); - stream.writeError({ - threadId: conversationId, + await this.runStatusService.failRun({ runId, - message: "启动 worker 失败: " + errorMsg, + conversationId, + error: "启动 worker 失败: " + errorMsg, }); - stream.end(); - return false; } } From 3b2224015146ff2d7354a80214d63cf006cbfdfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:40:16 +0800 Subject: [PATCH 08/18] refactor(run): converge stale cancellation centrally --- apps/server/src/agent/agent.service.spec.ts | 27 ++++--------------- apps/server/src/agent/agent.service.ts | 9 +++---- .../src/conversation/conversation.service.ts | 11 +++----- apps/server/src/run/run.service.spec.ts | 5 +++- apps/server/src/run/run.service.ts | 10 +++---- .../src/run/status/run-status.service.spec.ts | 12 +++++++-- .../src/run/status/run-status.service.ts | 27 ++++++++++++------- 7 files changed, 49 insertions(+), 52 deletions(-) diff --git a/apps/server/src/agent/agent.service.spec.ts b/apps/server/src/agent/agent.service.spec.ts index 26d0b568..7b7ade8f 100644 --- a/apps/server/src/agent/agent.service.spec.ts +++ b/apps/server/src/agent/agent.service.spec.ts @@ -289,36 +289,19 @@ describe("AgentService", () => { }); describe("stop()", () => { - it("resets a stale running conversation to idle when no in-memory handle existed", async () => { + it("verifies ownership and delegates status convergence to RunService", async () => { mockConversationService.findById = vi .fn() .mockResolvedValue({ runStatus: "running" }); - mockConversationService.setRunStatus = vi - .fn() - .mockResolvedValue({ count: 1 }); mockRunService.stop = vi.fn().mockResolvedValue(false); await service.stop("conversation-1", user); - expect(mockRunService.stop).toHaveBeenCalledWith("conversation-1"); - expect(mockConversationService.setRunStatus).toHaveBeenCalledWith( - "conversation-1", - "idle" + expect(mockConversationService.findById).toHaveBeenCalledWith( + "user-1", + "conversation-1" ); - }); - - it("does not reset status when an active handle was stopped", async () => { - mockConversationService.findById = vi - .fn() - .mockResolvedValue({ runStatus: "running" }); - mockConversationService.setRunStatus = vi - .fn() - .mockResolvedValue({ count: 1 }); - mockRunService.stop = vi.fn().mockResolvedValue(true); - - await service.stop("conversation-1", user); - - expect(mockConversationService.setRunStatus).not.toHaveBeenCalled(); + expect(mockRunService.stop).toHaveBeenCalledWith("conversation-1"); }); }); diff --git a/apps/server/src/agent/agent.service.ts b/apps/server/src/agent/agent.service.ts index 685f8f13..f1f58e77 100644 --- a/apps/server/src/agent/agent.service.ts +++ b/apps/server/src/agent/agent.service.ts @@ -204,16 +204,13 @@ export class AgentService { await this.runService.resume(conversationId, res); } - /** 停止 conversation 的活跃 run;若无内存 handle 但状态仍为 running 则重置为 idle。 */ + /** 校验 conversation 归属后停止活跃 run;状态收敛统一归 RunStatusService。 */ async stop(conversationId: string, user: JwtUser): Promise { - const conversation = await this.conversationService.findById( + await this.conversationService.findById( user.userId, conversationId ); - const hadHandle = await this.runService.stop(conversationId); - if (!hadHandle && conversation.runStatus === "running") { - await this.conversationService.setRunStatus(conversationId, "idle"); - } + await this.runService.stop(conversationId); } private getLastUserMessage( diff --git a/apps/server/src/conversation/conversation.service.ts b/apps/server/src/conversation/conversation.service.ts index e35c0e61..1fa3fc6d 100644 --- a/apps/server/src/conversation/conversation.service.ts +++ b/apps/server/src/conversation/conversation.service.ts @@ -342,11 +342,9 @@ export class ConversationService { /** * 原子切换会话运行状态;切到 `running` 时要求当前状态为 `idle`/`error`,防止重复启动 run。 - * 返回是否命中并发生变更。 - * 调用方:`AgentService`(停止后置 idle)、`RunLauncher`(激活前置 running)、 - * `RunStatusService`(终态回写)、`RunRecoveryService`(重启恢复时置 error)。 + * 仅供本 Service 的激活/状态投影入口复用,返回是否命中并发生变更。 */ - async setRunStatus( + private async setRunStatus( conversationId: string, runStatus: "idle" | "running" | "error" ): Promise { @@ -356,7 +354,7 @@ export class ConversationService { /** * ConversationEffectsPort 实现:原子激活会话(setRunStatus("running")), * 激活失败时校验归属(findById 抛 NotFoundException),返回是否成功激活。 - * 调用方:`RunLauncher.claimRun`,经 Port 回流,run 模块不直接依赖本 Service。 + * 调用方:`RunLauncher.claimRun`。 */ async activateConversation( conversationId: string, @@ -370,8 +368,7 @@ export class ConversationService { /** * ConversationEffectsPort 实现:设置会话运行终态 / 中间态(idle / error / pendingUserAction)。 - * 调用方:`RunStatusService`(终态回写)、`RunRecoveryService`(重启恢复时置 error)、 - * `RunLauncher`(startWorker 失败时置 error),经 Port 回流。 + * run 状态投影调用方统一为 `RunStatusService`。 */ async setConversationRunState( conversationId: string, diff --git a/apps/server/src/run/run.service.spec.ts b/apps/server/src/run/run.service.spec.ts index 3b8bc00a..0499c257 100644 --- a/apps/server/src/run/run.service.spec.ts +++ b/apps/server/src/run/run.service.spec.ts @@ -472,7 +472,10 @@ describe("RunService", () => { expect( mockRunStatusService.markCancelledWithoutHandle - ).toHaveBeenCalledWith("run-1"); + ).toHaveBeenCalledWith({ + runId: "run-1", + conversationId: "conversation-1", + }); expect(hadHandle).toBe(false); }); diff --git a/apps/server/src/run/run.service.ts b/apps/server/src/run/run.service.ts index 5e8dc7c5..acea7298 100644 --- a/apps/server/src/run/run.service.ts +++ b/apps/server/src/run/run.service.ts @@ -265,8 +265,7 @@ export class RunService implements OnApplicationBootstrap { /** * 停止指定 conversation 的活跃 run。 - * @returns 是否存在活跃的 in-memory run handle。 - * conversation agent 路由用此判断是否需要重置 conversation status。 + * @returns 是否存在活跃的 in-memory run handle 并已下发取消命令。 */ async stop( conversationId: string, @@ -280,9 +279,10 @@ export class RunService implements OnApplicationBootstrap { if (!handle) { // No in-memory handle — clean up stale state if (activeRunRecord) { - await this.runStatusService.markCancelledWithoutHandle( - activeRunRecord.id - ); + await this.runStatusService.markCancelledWithoutHandle({ + runId: activeRunRecord.id, + conversationId, + }); } return false; } diff --git a/apps/server/src/run/status/run-status.service.spec.ts b/apps/server/src/run/status/run-status.service.spec.ts index d7d9d57a..1f5c8aa9 100644 --- a/apps/server/src/run/status/run-status.service.spec.ts +++ b/apps/server/src/run/status/run-status.service.spec.ts @@ -330,11 +330,19 @@ describe("RunStatusService", () => { }); it("marks cancelled and records a platform status event when no handle exists", async () => { - const { handler, runRepository, runEvents } = makeSubject(); + const { handler, runRepository, runConversation, runEvents } = + makeSubject(); - await handler.markCancelledWithoutHandle("run-1"); + await handler.markCancelledWithoutHandle({ + runId: "run-1", + conversationId: "conversation-1", + }); expect(runRepository.markCancelled).toHaveBeenCalledWith("run-1"); + expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + "conversation-1", + { runStatus: "idle" } + ); expect(runEvents.runStatusChanged).toHaveBeenCalledWith({ runId: "run-1", origin: "platform", diff --git a/apps/server/src/run/status/run-status.service.ts b/apps/server/src/run/status/run-status.service.ts index 40db5d6c..c113eed2 100644 --- a/apps/server/src/run/status/run-status.service.ts +++ b/apps/server/src/run/status/run-status.service.ts @@ -179,15 +179,24 @@ export class RunStatusService { ); } - /** 平台侧取消:DB 有活跃行但无内存 handle(重启遗留等),直接落 cancelled 终态并记账。 */ - async markCancelledWithoutHandle(runId: string): Promise { - await this.runRepository.markCancelled(runId); - this.recordPlatformStatusChanged( - runId, - "cancelled", - "cancelled_without_handle", - `record cancel without handle for run ${runId}` - ); + /** 平台侧取消:DB 有活跃行但无内存 handle(重启遗留等),仍复用完整终态序列。 */ + async markCancelledWithoutHandle(input: { + runId: string; + conversationId: string; + }): Promise { + const payload: RunStatusPayload = { + status: "cancelled", + error: "cancelled_without_handle", + }; + const decision = this.decide(input.runId, payload); + if (decision.action === "ignore") return; + await this.apply({ + runId: input.runId, + payload, + effect: decision.effect, + conversationId: input.conversationId, + origin: "platform", + }); } private recordStatusEvent(runId: string, payload: RunStatusPayload): void { From 9a7fe977cdd0206d7b2021a5b76b21b91aae2817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:40:56 +0800 Subject: [PATCH 09/18] fix(run): release failed launch claims --- .../src/run/launch/run.launcher.spec.ts | 39 +++++ apps/server/src/run/launch/run.launcher.ts | 147 ++++++++++-------- .../src/run/status/run-status.service.spec.ts | 21 +++ .../src/run/status/run-status.service.ts | 15 ++ 4 files changed, 158 insertions(+), 64 deletions(-) diff --git a/apps/server/src/run/launch/run.launcher.spec.ts b/apps/server/src/run/launch/run.launcher.spec.ts index 582cf1b1..6b997ef3 100644 --- a/apps/server/src/run/launch/run.launcher.spec.ts +++ b/apps/server/src/run/launch/run.launcher.spec.ts @@ -113,6 +113,7 @@ describe("RunLauncher", () => { }; mockRunStatusService = { failRun: vi.fn().mockResolvedValue(undefined), + failLaunchClaim: vi.fn().mockResolvedValue(undefined), }; launcher = new RunLauncher( mockRunRepository as RunRepository, @@ -297,6 +298,44 @@ describe("RunLauncher", () => { expect(res.write).not.toHaveBeenCalled(); }); + it("releases the conversation claim when run creation fails", async () => { + mockRunRepository.create = vi + .fn() + .mockRejectedValue(new Error("database unavailable")); + const res = makeRes(); + + await launch(makeStartInput({ res })); + + expect(mockRunStatusService.failLaunchClaim).toHaveBeenCalledWith({ + conversationId: "conversation-1", + }); + expect(mockRuntimeHost.submitRun).not.toHaveBeenCalled(); + expect(res.write).toHaveBeenCalledWith( + expect.stringContaining("database unavailable") + ); + expect(res.end).toHaveBeenCalled(); + }); + + it("releases the conversation claim when saving the user turn fails", async () => { + mockConversationEffects.saveUserMessage = vi + .fn() + .mockRejectedValue(new Error("message write failed")); + const res = makeRes(); + + await launch( + makeStartInput({ + res, + userMessage: { id: "msg-1", content: "hi" }, + }) + ); + + expect(mockRunRepository.create).not.toHaveBeenCalled(); + expect(mockRunStatusService.failLaunchClaim).toHaveBeenCalledWith({ + conversationId: "conversation-1", + }); + expect(res.end).toHaveBeenCalled(); + }); + it("routes submit failure through the run status owner", async () => { mockRuntimeHost.submitRun = vi .fn() diff --git a/apps/server/src/run/launch/run.launcher.ts b/apps/server/src/run/launch/run.launcher.ts index 83c8ca50..dfde2138 100644 --- a/apps/server/src/run/launch/run.launcher.ts +++ b/apps/server/src/run/launch/run.launcher.ts @@ -92,19 +92,41 @@ export class RunLauncher { const scope = placement.scope; const stream = new RunStream(res); - await this.claimRun({ + const claimedConversation = await this.claimRun({ conversationId, userId, runId, interruptReason, stopActiveRun: ports.stopActiveRun, }); - await this.saveUserTurn({ - conversationId, - userMessage, - agentType, - modelProviderId, - }); + + try { + await this.saveUserTurn({ + conversationId, + userMessage, + agentType, + modelProviderId, + }); + await this.createRun({ + runId, + conversationId, + workspaceId: placement.workspaceId, + agentType, + runtimeType, + scope, + userMessageId, + userId, + }); + } catch (err) { + await this.handlePreRunFailure({ + runId, + conversationId, + claimedConversation, + stream, + error: err, + }); + return; + } const aggregator = new AssistantMessageAggregator(); const saveRun = this.makeSaveRun({ conversationId, runId, aggregator }); @@ -120,19 +142,6 @@ export class RunLauncher { scope, }); - const runCreated = await this.createRun({ - runId, - conversationId, - workspaceId: placement.workspaceId, - agentType, - runtimeType, - scope, - userMessageId, - userId, - stream, - }); - if (!runCreated) return; - const runtimeHandle: RunExecutionHandle = { runId, runtimeHostId: placement.runtimeHostId, @@ -226,14 +235,14 @@ export class RunLauncher { runId: string; interruptReason?: "user_steered"; stopActiveRun: StopActiveRun; - }): Promise { + }): Promise { const { conversationId, userId, runId, interruptReason, stopActiveRun } = input; const activated = await this.conversationService.activateConversation( conversationId, userId ); - if (activated) return; + if (activated) return true; if (interruptReason === "user_steered") { await stopActiveRun(conversationId, { reason: "user_steered", @@ -243,7 +252,7 @@ export class RunLauncher { conversationId, runId, }); - return; + return false; } throw new ConflictException( @@ -317,8 +326,7 @@ export class RunLauncher { scope: string; userMessageId?: string; userId: string; - stream: RunStream; - }): Promise { + }): Promise { const { runId, conversationId, @@ -328,57 +336,68 @@ export class RunLauncher { scope, userMessageId, userId, - stream, } = input; - try { - await this.runRepository.create({ - id: runId, + await this.runRepository.create({ + id: runId, + conversationId, + agentType, + runtimeType, + }); + this.recordRunEvent( + this.runEvents.runCreated({ + runId, conversationId, + workspaceId, agentType, runtimeType, - }); + scope, + }), + `record run created for run ${runId}` + ); + if (userMessageId) { + await this.conversationService + .attachMessageToRun(conversationId, userMessageId, runId) + .catch( + swallow( + this.logger, + `attach user message ${userMessageId} to run ${runId}` + ) + ); this.recordRunEvent( - this.runEvents.runCreated({ + this.runEvents.messageAccepted({ runId, conversationId, - workspaceId, - agentType, - runtimeType, - scope, + messageId: userMessageId, + userId, }), - `record run created for run ${runId}` + `record message accepted for run ${runId}` ); - if (userMessageId) { - await this.conversationService - .attachMessageToRun(conversationId, userMessageId, runId) - .catch( - swallow( - this.logger, - `attach user message ${userMessageId} to run ${runId}` - ) - ); - this.recordRunEvent( - this.runEvents.messageAccepted({ - runId, - conversationId, - messageId: userMessageId, - userId, - }), - `record message accepted for run ${runId}` - ); + } + } + + /** Run 行尚未创建时失败:释放本次会话 claim,并结束当前响应。 */ + private async handlePreRunFailure(input: { + runId: string; + conversationId: string; + claimedConversation: boolean; + stream: RunStream; + error: unknown; + }): Promise { + const { runId, conversationId, claimedConversation, stream, error } = input; + this.logger.warn("prepare run failed", { + runId, + conversationId, + ...errorLogFields(error), + }); + const message = error instanceof Error ? error.message : String(error); + try { + if (claimedConversation) { + await this.runStatusService.failLaunchClaim({ conversationId }); } - return true; - } catch (err) { - this.logger.warn("create run record failed", { - runId, - conversationId, - ...errorLogFields(err), - }); - const errorMsg = err instanceof Error ? err.message : String(err); - stream.writeError({ threadId: conversationId, runId, message: errorMsg }); + } finally { + stream.writeError({ threadId: conversationId, runId, message }); stream.end(); - return false; } } diff --git a/apps/server/src/run/status/run-status.service.spec.ts b/apps/server/src/run/status/run-status.service.spec.ts index 1f5c8aa9..22551eeb 100644 --- a/apps/server/src/run/status/run-status.service.spec.ts +++ b/apps/server/src/run/status/run-status.service.spec.ts @@ -314,6 +314,27 @@ describe("RunStatusService", () => { expect(unregister).toHaveBeenCalledWith("run-1"); }); + it("releases a failed launch claim when no run row became active", async () => { + const { handler, runConversation } = makeSubject(); + + await handler.failLaunchClaim({ conversationId: "conversation-1" }); + + expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + "conversation-1", + { runStatus: "error" } + ); + }); + + it("does not release a launch claim after another run became active", async () => { + const { handler, runConversation } = makeSubject({ + activeRun: { id: "run-2" }, + }); + + await handler.failLaunchClaim({ conversationId: "conversation-1" }); + + expect(runConversation.setConversationRunState).not.toHaveBeenCalled(); + }); + it("marks cancelling and records a platform status event on cancel request", async () => { const { handler, runRepository, runEvents } = makeSubject(); diff --git a/apps/server/src/run/status/run-status.service.ts b/apps/server/src/run/status/run-status.service.ts index c113eed2..18234a6f 100644 --- a/apps/server/src/run/status/run-status.service.ts +++ b/apps/server/src/run/status/run-status.service.ts @@ -168,6 +168,21 @@ export class RunStatusService { }); } + /** + * Run 行创建前失败时释放 Conversation claim。若已有别的活跃 Run 接管会话, + * 不覆盖它的 running 投影。 + */ + async failLaunchClaim(input: { conversationId: string }): Promise { + const activeRun = await this.runRepository.findActiveByConversationId( + input.conversationId + ); + if (activeRun) return; + await this.conversationService.setConversationRunState( + input.conversationId, + { runStatus: "error" } + ); + } + /** 平台侧取消请求:活跃 run 标记 cancelling 并记账;终态等 worker 上报 cancelled 收敛。 */ async markCancelRequested(runId: string, reason?: string): Promise { await this.runRepository.markCancelling(runId); From fb9d4ab454f4dcaeb6dc72d5c3555bbe238f2e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:41:14 +0800 Subject: [PATCH 10/18] test(run): guard status ownership boundaries --- .../run/status/run-status-ownership.spec.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 apps/server/src/run/status/run-status-ownership.spec.ts diff --git a/apps/server/src/run/status/run-status-ownership.spec.ts b/apps/server/src/run/status/run-status-ownership.spec.ts new file mode 100644 index 00000000..95b8d54e --- /dev/null +++ b/apps/server/src/run/status/run-status-ownership.spec.ts @@ -0,0 +1,44 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = existsSync(resolve(process.cwd(), "src/run")) + ? resolve(process.cwd(), "src") + : resolve(process.cwd(), "apps/server/src"); + +function productionTypeScriptFiles(): string[] { + return readdirSync(SOURCE_ROOT, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) + .map((entry) => resolve(entry.parentPath, entry.name)) + .filter((path) => !path.endsWith(".spec.ts")); +} + +function violations( + pattern: RegExp, + allowedRelativePaths: ReadonlySet +): string[] { + return productionTypeScriptFiles() + .filter((path) => pattern.test(readFileSync(path, "utf8"))) + .map((path) => relative(SOURCE_ROOT, path).replaceAll("\\", "/")) + .filter((path) => !allowedRelativePaths.has(path)); +} + +describe("run status ownership", () => { + it("keeps Run persistence mutations inside RunStatusService", () => { + expect( + violations( + /runRepository\.(?:markRunning|markRequiresAction|markFinished|markError|markCancelled|markCancelling)\s*\(/, + new Set(["run/status/run-status.service.ts"]) + ) + ).toEqual([]); + }); + + it("keeps the Conversation run-status projection inside RunStatusService", () => { + expect( + violations( + /conversationService\.setConversationRunState\s*\(/, + new Set(["run/status/run-status.service.ts"]) + ) + ).toEqual([]); + }); +}); From 43b473517cffb7b08adf28d5dd6625ca7cbe2e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:41:53 +0800 Subject: [PATCH 11/18] fix(run): reconcile stale conversation projections --- .../run/recovery/run-recovery.service.spec.ts | 42 +++++++++++++++++++ .../src/run/recovery/run-recovery.service.ts | 20 +++++++++ apps/server/src/run/run.repository.spec.ts | 39 +++++++++++++++++ apps/server/src/run/run.repository.ts | 22 ++++++++++ .../src/run/status/run-status.service.ts | 11 +++++ 5 files changed, 134 insertions(+) diff --git a/apps/server/src/run/recovery/run-recovery.service.spec.ts b/apps/server/src/run/recovery/run-recovery.service.spec.ts index 757eea88..b3208683 100644 --- a/apps/server/src/run/recovery/run-recovery.service.spec.ts +++ b/apps/server/src/run/recovery/run-recovery.service.spec.ts @@ -30,9 +30,11 @@ function makeDeps(activeRuns: unknown[]) { const runRepository: Partial = { listActive: vi.fn().mockResolvedValue(activeRuns), findRuntimeReconciliationRows: vi.fn().mockResolvedValue([]), + findRunningConversationsWithoutActiveRun: vi.fn().mockResolvedValue([]), }; const runStatusService: Partial = { failRun: vi.fn().mockResolvedValue(undefined), + reconcileConversationRunStatus: vi.fn().mockResolvedValue(undefined), }; const runtimeHostService: Partial = { getRuntimeHostRow: vi.fn().mockResolvedValue(null), @@ -121,6 +123,46 @@ describe("RunRecoveryService.failInterruptedRuns", () => { }); }); + it("repairs stale Conversation projections before recovering active runs", async () => { + const deps = makeDeps([]); + deps.runRepository.findRunningConversationsWithoutActiveRun = vi + .fn() + .mockResolvedValue([ + { id: "conversation-finished", runs: [{ status: "finished" }] }, + { id: "conversation-cancelled", runs: [{ status: "cancelled" }] }, + { id: "conversation-error", runs: [{ status: "error" }] }, + { id: "conversation-no-run", runs: [] }, + ]); + service = makeService(deps, makeRuntimeHost()); + + await service.failInterruptedRuns(); + + expect( + deps.runStatusService.reconcileConversationRunStatus + ).toHaveBeenCalledWith({ + conversationId: "conversation-finished", + runStatus: "idle", + }); + expect( + deps.runStatusService.reconcileConversationRunStatus + ).toHaveBeenCalledWith({ + conversationId: "conversation-cancelled", + runStatus: "idle", + }); + expect( + deps.runStatusService.reconcileConversationRunStatus + ).toHaveBeenCalledWith({ + conversationId: "conversation-error", + runStatus: "error", + }); + expect( + deps.runStatusService.reconcileConversationRunStatus + ).toHaveBeenCalledWith({ + conversationId: "conversation-no-run", + runStatus: "error", + }); + }); + it("retries remote cleanup when the coordinator runs reconciliation", async () => { const deps = makeDeps([ makeActiveRun({ runtimeHostId: "rt-registered-1" }), diff --git a/apps/server/src/run/recovery/run-recovery.service.ts b/apps/server/src/run/recovery/run-recovery.service.ts index ecce4062..3d849045 100644 --- a/apps/server/src/run/recovery/run-recovery.service.ts +++ b/apps/server/src/run/recovery/run-recovery.service.ts @@ -80,6 +80,7 @@ export class RunRecoveryService } async failInterruptedRuns(): Promise { + await this.reconcileConversationRunStatuses(); const activeRuns = await this.runRepository.listActive(); if (activeRuns.length === 0) { this.logger.log("No interrupted active runs found."); @@ -107,6 +108,25 @@ export class RunRecoveryService this.startAbandonedSweep(); } + private async reconcileConversationRunStatuses(): Promise { + const staleConversations = + await this.runRepository.findRunningConversationsWithoutActiveRun(); + for (const conversation of staleConversations) { + const latestStatus = conversation.runs[0]?.status; + const runStatus = + latestStatus === "finished" || latestStatus === "cancelled" + ? "idle" + : "error"; + await this.runStatusService.reconcileConversationRunStatus({ + conversationId: conversation.id, + runStatus, + }); + this.logger.warn( + `Reconciled stale conversation ${conversation.id} run status to ${runStatus}` + ); + } + } + private async retryHostReconciliation(runtimeHostId: string): Promise { try { await this.reconcileRuntimeHost(runtimeHostId); diff --git a/apps/server/src/run/run.repository.spec.ts b/apps/server/src/run/run.repository.spec.ts index ec3b55a4..0b15ed80 100644 --- a/apps/server/src/run/run.repository.spec.ts +++ b/apps/server/src/run/run.repository.spec.ts @@ -215,6 +215,45 @@ describe("RunRepository", () => { expect(run?.id).toBe("run-1"); }); + it("finds running Conversation projections without an active Run", async () => { + const rows = [{ id: "conversation-1", runs: [{ status: "finished" }] }]; + const findMany = vi.fn().mockResolvedValue(rows); + const service = new RunRepository({ + conversation: { findMany }, + } as never); + + await expect( + service.findRunningConversationsWithoutActiveRun() + ).resolves.toEqual(rows); + expect(findMany).toHaveBeenCalledWith({ + where: { + deletedAt: null, + runStatus: "running", + runs: { + none: { + status: { + in: [ + "queued", + "preparing", + "running", + "cancelling", + "requires_action", + ], + }, + }, + }, + }, + select: { + id: true, + runs: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { status: true }, + }, + }, + }); + }); + it("finds the conversationId for a run", async () => { const findUnique = vi .fn() diff --git a/apps/server/src/run/run.repository.ts b/apps/server/src/run/run.repository.ts index 2be4b80f..dccdbdf6 100644 --- a/apps/server/src/run/run.repository.ts +++ b/apps/server/src/run/run.repository.ts @@ -107,6 +107,28 @@ export class RunRepository { }); } + /** + * 查找仍投影为 running、但已经没有活跃 Run 的会话。用于重启后修复 + * Run 终态已落库而 Conversation 投影未落库的崩溃窗口。 + */ + async findRunningConversationsWithoutActiveRun() { + return this.prisma.conversation.findMany({ + where: { + deletedAt: null, + runStatus: "running", + runs: { none: { status: { in: ACTIVE_RUN_STATUSES } } }, + }, + select: { + id: true, + runs: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { status: true }, + }, + }, + }); + } + /** Host 重连对账:批量读取其现场 run 的数据库状态与取消命令所需会话 id。 */ async findRuntimeReconciliationRows(runIds: string[]) { if (runIds.length === 0) return []; diff --git a/apps/server/src/run/status/run-status.service.ts b/apps/server/src/run/status/run-status.service.ts index 18234a6f..bb4b351f 100644 --- a/apps/server/src/run/status/run-status.service.ts +++ b/apps/server/src/run/status/run-status.service.ts @@ -183,6 +183,17 @@ export class RunStatusService { ); } + /** 重启对账专用:修复没有活跃 Run 时遗留的 Conversation 状态投影。 */ + async reconcileConversationRunStatus(input: { + conversationId: string; + runStatus: "idle" | "error"; + }): Promise { + await this.conversationService.setConversationRunState( + input.conversationId, + { runStatus: input.runStatus } + ); + } + /** 平台侧取消请求:活跃 run 标记 cancelling 并记账;终态等 worker 上报 cancelled 收敛。 */ async markCancelRequested(runId: string, reason?: string): Promise { await this.runRepository.markCancelling(runId); From 9e2c8d43d775880c8ce0b51c743487a6ee924e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:42:16 +0800 Subject: [PATCH 12/18] refactor(runtime): encapsulate worker pool lifecycle --- apps/runtime/src/host/worker-registry.spec.ts | 14 +++- apps/runtime/src/host/worker-registry.ts | 73 +++++++++++++++++-- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/apps/runtime/src/host/worker-registry.spec.ts b/apps/runtime/src/host/worker-registry.spec.ts index f3ed9d4f..44731ad7 100644 --- a/apps/runtime/src/host/worker-registry.spec.ts +++ b/apps/runtime/src/host/worker-registry.spec.ts @@ -20,6 +20,14 @@ function makeEntry(workerId = "worker-1"): WorkerEntry { } describe("WorkerRegistry", () => { + it("does not expose WorkerPool.remove outside lifecycle eviction", () => { + const registry = new WorkerRegistry(); + + expect( + (registry as unknown as { remove?: unknown }).remove + ).toBeUndefined(); + }); + it("evicts the entry, pending handshake and command queue together", async () => { const registry = new WorkerRegistry(); const entry = makeEntry(); @@ -39,9 +47,9 @@ describe("WorkerRegistry", () => { await rejected; expect(registry.getById(entry.workerId)).toBeUndefined(); - await expect( - registry.pollCommands(entry.workerId, 0, 0) - ).resolves.toEqual([]); + await expect(registry.pollCommands(entry.workerId, 0, 0)).resolves.toEqual( + [] + ); }); it("cleans control state when a worker generation is superseded", async () => { diff --git a/apps/runtime/src/host/worker-registry.ts b/apps/runtime/src/host/worker-registry.ts index 454357fa..bc7e7e40 100644 --- a/apps/runtime/src/host/worker-registry.ts +++ b/apps/runtime/src/host/worker-registry.ts @@ -17,21 +17,22 @@ import { * 入口,确保 RuntimeHost 不再跨三个状态容器手工清理。后续内部存储如何演进 * 不影响 RuntimeHost 契约。 */ -export class WorkerRegistry extends WorkerPool { +export class WorkerRegistry { + private readonly workers = new WorkerPool(); private readonly mailbox = new CommandMailbox(); private readonly handshakes = new HandshakeStore(); - override put(entry: WorkerEntry): void { + put(entry: WorkerEntry): void { const identity: ReuseIdentity = { scope: entry.isolation.scope, subjectId: entry.isolation.subjectId, runtimeType: entry.runtimeType, }; - const previous = this.getByIdentity( + const previous = this.workers.getByIdentity( identity, entry.userLifecycleVersion ); - super.put(entry); + this.workers.put(entry); if (previous && previous.workerId !== entry.workerId) { this.cleanupControlState(previous.workerId, "worker superseded"); } @@ -42,7 +43,7 @@ export class WorkerRegistry extends WorkerPool { * 让重复 stop/release 保持幂等。 */ evict(workerId: string, reason: string): WorkerEntry | undefined { - const entry = super.remove(workerId); + const entry = this.workers.remove(workerId); this.cleanupControlState(workerId, reason); return entry; } @@ -86,6 +87,68 @@ export class WorkerRegistry extends WorkerPool { return this.mailbox.epochFor(workerId); } + getById(...args: Parameters) { + return this.workers.getById(...args); + } + + getByIdentity(...args: Parameters) { + return this.workers.getByIdentity(...args); + } + + getByRunId(...args: Parameters) { + return this.workers.getByRunId(...args); + } + + ownsRun(...args: Parameters) { + return this.workers.ownsRun(...args); + } + + acquireOnce(...args: Parameters) { + return this.workers.acquireOnce(...args); + } + + drainAcquisitions(...args: Parameters) { + return this.workers.drainAcquisitions(...args); + } + + detachWorkspaceAcquisitions( + ...args: Parameters + ) { + return this.workers.detachWorkspaceAcquisitions(...args); + } + + markReady(...args: Parameters): void { + this.workers.markReady(...args); + } + + associateRun(...args: Parameters): void { + this.workers.associateRun(...args); + } + + dissociateRun(...args: Parameters): void { + this.workers.dissociateRun(...args); + } + + touch(...args: Parameters): void { + this.workers.touch(...args); + } + + markCancelled(...args: Parameters): void { + this.workers.markCancelled(...args); + } + + list(): WorkerEntry[] { + return this.workers.list(); + } + + listByWorkspace(...args: Parameters) { + return this.workers.listByWorkspace(...args); + } + + listByUser(...args: Parameters) { + return this.workers.listByUser(...args); + } + drainControlPlane(): void { this.mailbox.drain(); } From 7ac9b7b1637b0171004b3e54570bc70ff0c5844b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:43:06 +0800 Subject: [PATCH 13/18] chore: align run refactor quality gates --- apps/runtime/scripts/sync-bundled-agent-runtime.ts | 13 +++++++------ .../src/worker/agent/bundled-plugin-manifest.ts | 3 +-- apps/server/src/agent/agent.service.ts | 5 +---- apps/server/src/run/launch/run.launcher.ts | 11 +++-------- apps/server/src/run/run.repository.ts | 4 +--- 5 files changed, 13 insertions(+), 23 deletions(-) diff --git a/apps/runtime/scripts/sync-bundled-agent-runtime.ts b/apps/runtime/scripts/sync-bundled-agent-runtime.ts index e8312c40..5916b879 100644 --- a/apps/runtime/scripts/sync-bundled-agent-runtime.ts +++ b/apps/runtime/scripts/sync-bundled-agent-runtime.ts @@ -15,10 +15,7 @@ const runtimeRoot = dirname(dirname(fileURLToPath(import.meta.url))); const sdkDepsDir = join(runtimeRoot, "sdk-deps"); const packagePath = join(sdkDepsDir, "package.json"); const lockPath = join(sdkDepsDir, "package-lock.json"); -const runtimeManifestPath = join( - sdkDepsDir, - "bundled-agent-runtime.json" -); +const runtimeManifestPath = join(sdkDepsDir, "bundled-agent-runtime.json"); const write = process.argv.includes("--write"); type BundledAgentEntry = AgentRuntimeRequirement & { @@ -155,10 +152,14 @@ function addPluginRequirements( ): void { for (const [agentType, requirement] of Object.entries(requirements)) { if (agents[agentType]) { - throw new Error(`Duplicate bundled agent runtime declaration: ${agentType}`); + throw new Error( + `Duplicate bundled agent runtime declaration: ${agentType}` + ); } if (Object.keys(requirement.npmPackages).length === 0) { - throw new Error(`Bundled agent ${agentType} requires at least one npm package`); + throw new Error( + `Bundled agent ${agentType} requires at least one npm package` + ); } agents[agentType] = { pluginId, ...requirement }; } diff --git a/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts b/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts index 4f11f084..d55f8f49 100644 --- a/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts +++ b/apps/runtime/src/worker/agent/bundled-plugin-manifest.ts @@ -7,8 +7,7 @@ */ export const BUNDLED_AGENT_PLUGIN_IDS = ["builtin-agents", "acp"] as const; -export type BundledAgentPluginId = - (typeof BUNDLED_AGENT_PLUGIN_IDS)[number]; +export type BundledAgentPluginId = (typeof BUNDLED_AGENT_PLUGIN_IDS)[number]; export function listBundledAgentPluginIds(): BundledAgentPluginId[] { return [...BUNDLED_AGENT_PLUGIN_IDS]; diff --git a/apps/server/src/agent/agent.service.ts b/apps/server/src/agent/agent.service.ts index f1f58e77..5f6058a0 100644 --- a/apps/server/src/agent/agent.service.ts +++ b/apps/server/src/agent/agent.service.ts @@ -206,10 +206,7 @@ export class AgentService { /** 校验 conversation 归属后停止活跃 run;状态收敛统一归 RunStatusService。 */ async stop(conversationId: string, user: JwtUser): Promise { - await this.conversationService.findById( - user.userId, - conversationId - ); + await this.conversationService.findById(user.userId, conversationId); await this.runService.stop(conversationId); } diff --git a/apps/server/src/run/launch/run.launcher.ts b/apps/server/src/run/launch/run.launcher.ts index dfde2138..838a321c 100644 --- a/apps/server/src/run/launch/run.launcher.ts +++ b/apps/server/src/run/launch/run.launcher.ts @@ -192,7 +192,7 @@ export class RunLauncher { } if (!isRuntimeType(runtimeType)) { throw new BadRequestException( - `工作空间的运行环境类型无效: ${runtimeType}` + `工作空间的运行环境类型无效: ${String(runtimeType)}` ); } @@ -412,13 +412,8 @@ export class RunLauncher { agentProviderConfig: AgentProviderConfig; runInput: unknown; }): Promise { - const { - runId, - conversationId, - placement, - agentProviderConfig, - runInput, - } = input; + const { runId, conversationId, placement, agentProviderConfig, runInput } = + input; const runtimeType = placement.runtimeType; try { diff --git a/apps/server/src/run/run.repository.ts b/apps/server/src/run/run.repository.ts index dccdbdf6..395ba8d7 100644 --- a/apps/server/src/run/run.repository.ts +++ b/apps/server/src/run/run.repository.ts @@ -154,9 +154,7 @@ export class RunRepository { } /** user 停用/删除级联用:该 user 名下所有活跃 run 的会话 id(去重)。 */ - async findActiveConversationIdsForUser( - userId: string - ): Promise { + async findActiveConversationIdsForUser(userId: string): Promise { const rows = await this.prisma.run.findMany({ where: { conversation: { workspace: { userId } }, From 0e979abd4f7dbf4dfa00c4344e1b54ae4c17090a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:51:07 +0800 Subject: [PATCH 14/18] fix(ci): satisfy server lint gates --- apps/server/src/config/config.service.ts | 2 +- .../contract/builtin-runtime-host.ts | 164 +++++++++--------- apps/server/src/user/user-security.spec.ts | 19 +- apps/server/src/user/user.events.ts | 10 +- apps/server/src/user/user.module.ts | 6 +- apps/server/src/user/user.service.ts | 5 +- .../placement/workspace-runtime.policy.ts | 2 +- .../server/src/workspace/workspace.service.ts | 7 +- 8 files changed, 113 insertions(+), 102 deletions(-) diff --git a/apps/server/src/config/config.service.ts b/apps/server/src/config/config.service.ts index f50f279b..564a1050 100644 --- a/apps/server/src/config/config.service.ts +++ b/apps/server/src/config/config.service.ts @@ -221,7 +221,7 @@ export class ConfigService implements OnModuleInit { ); } - return Array.from(new Set(values)) as RuntimeType[]; + return Array.from(new Set(values)); } /** builtin Host 显式允许加载的 runtime 插件包;不做隐式发现。 */ diff --git a/apps/server/src/runtime-host/contract/builtin-runtime-host.ts b/apps/server/src/runtime-host/contract/builtin-runtime-host.ts index 5ab58904..acaca8a8 100644 --- a/apps/server/src/runtime-host/contract/builtin-runtime-host.ts +++ b/apps/server/src/runtime-host/contract/builtin-runtime-host.ts @@ -65,91 +65,93 @@ export class BuiltinRuntimeHostLifecycle implements OnApplicationShutdown { } } -export const builtinRuntimeHostLifecycleProvider: FactoryProvider = { - provide: BUILTIN_RUNTIME_HOST_LIFECYCLE, - inject: [ConfigService, RuntimeHostRepository, RunEventService], - useFactory: async ( - configService: ConfigService, - repository: RuntimeHostRepository, - runEvents: RunEventService - ): Promise => { - const workerPort = configService.getBuiltinWorkerHttpPort(); - const apiBasePath = configService.getApiBasePath(); - const workerApiBaseUrl = `http://127.0.0.1:${workerPort}${apiBasePath}`; +export const builtinRuntimeHostLifecycleProvider: FactoryProvider = + { + provide: BUILTIN_RUNTIME_HOST_LIFECYCLE, + inject: [ConfigService, RuntimeHostRepository, RunEventService], + useFactory: async ( + configService: ConfigService, + repository: RuntimeHostRepository, + runEvents: RunEventService + ): Promise => { + const workerPort = configService.getBuiltinWorkerHttpPort(); + const apiBasePath = configService.getApiBasePath(); + const workerApiBaseUrl = `http://127.0.0.1:${workerPort}${apiBasePath}`; - const providerConfig = toRuntimeConfig(configService, workerApiBaseUrl); - const providerPlugins = [ - createDockerRuntimePlugin(), - ...(await loadRuntimePlugins(configService.getRuntimePluginPackages())), - ]; - const pluginsByType = new Map( - providerPlugins.map((plugin) => [plugin.type, plugin]) - ); - // native 进程内恒可用;其余 provider 由各自插件声明探测能力。 - const detectCapabilities = async (): Promise => { - const entries = await Promise.all( - configService.getAllowedRuntimeTypes().map(async (runtimeType) => { - const plugin = pluginsByType.get(runtimeType); - const availability = plugin?.probe - ? await plugin.probe() - : { available: true }; - return [ - runtimeType, - { - ...availability, - displayName: - runtimeType === "native" - ? "Native" - : plugin?.displayName ?? runtimeType, - scopes: - runtimeType === "native" - ? ["workspace"] - : plugin?.scopes ?? ["user", "workspace"], - }, - ] as const; - }) + const providerConfig = toRuntimeConfig(configService, workerApiBaseUrl); + const providerPlugins = [ + createDockerRuntimePlugin(), + ...(await loadRuntimePlugins(configService.getRuntimePluginPackages())), + ]; + const pluginsByType = new Map( + providerPlugins.map((plugin) => [plugin.type, plugin]) ); - return Object.fromEntries(entries) as HostCapabilityStatus; - }; - const capabilities = await detectCapabilities(); + // native 进程内恒可用;其余 provider 由各自插件声明探测能力。 + const detectCapabilities = async (): Promise => { + const entries = await Promise.all( + configService.getAllowedRuntimeTypes().map(async (runtimeType) => { + const plugin = pluginsByType.get(runtimeType); + const availability = plugin?.probe + ? await plugin.probe() + : { available: true }; + return [ + runtimeType, + { + ...availability, + displayName: + runtimeType === "native" + ? "Native" + : (plugin?.displayName ?? runtimeType), + scopes: + runtimeType === "native" + ? ["workspace"] + : (plugin?.scopes ?? ["user", "workspace"]), + }, + ] as const; + }) + ); + return Object.fromEntries(entries) as HostCapabilityStatus; + }; + const capabilities = await detectCapabilities(); - const host = new RuntimeHost({ - runtimeLogDir: configService.getRuntimeLogDir(), - getUserWorkspace: (username) => configService.getUserWorkspace(username), - launchTimeoutMs: configService.getLaunchTimeoutSeconds() * 1000, - heartbeatTimeoutMs: configService.getHeartbeatTimeoutSeconds() * 1000, - agentEventTrace: configService.getAgentEventTraceConfig(), - cliInstallDir: configService.getHostCliDir(), - capabilities, - // provider 依赖中途挂掉/恢复反映到放置准入,只拦新 run - refreshCapabilities: detectCapabilities, - providerConfig, - providerPlugins, - agentPluginPackages: configService.getAgentPluginPackages(), - resolveCliPaths: async () => { - const row = await repository.findById(BUILTIN_HOST_ID); - return row - ? resolveRuntimeHostCliPaths(row) - : { claude: null, codex: null, opencode: null, pi: null }; - }, - // 「命令已下发」是记账不是执行回流,直接落 run-event 账本(best-effort) - onCommandDispatched: ({ runId, commandId, commandType }) => { - void runEvents - .append(runEvents.commandSent({ runId, commandId, commandType })) - .catch(() => {}); - }, - }); + const host = new RuntimeHost({ + runtimeLogDir: configService.getRuntimeLogDir(), + getUserWorkspace: (username) => + configService.getUserWorkspace(username), + launchTimeoutMs: configService.getLaunchTimeoutSeconds() * 1000, + heartbeatTimeoutMs: configService.getHeartbeatTimeoutSeconds() * 1000, + agentEventTrace: configService.getAgentEventTraceConfig(), + cliInstallDir: configService.getHostCliDir(), + capabilities, + // provider 依赖中途挂掉/恢复反映到放置准入,只拦新 run + refreshCapabilities: detectCapabilities, + providerConfig, + providerPlugins, + agentPluginPackages: configService.getAgentPluginPackages(), + resolveCliPaths: async () => { + const row = await repository.findById(BUILTIN_HOST_ID); + return row + ? resolveRuntimeHostCliPaths(row) + : { claude: null, codex: null, opencode: null, pi: null }; + }, + // 「命令已下发」是记账不是执行回流,直接落 run-event 账本(best-effort) + onCommandDispatched: ({ runId, commandId, commandType }) => { + void runEvents + .append(runEvents.commandSent({ runId, commandId, commandType })) + .catch(() => {}); + }, + }); - // builtin Host 自管 worker HTTP 服务器——worker 数据面对端不再连 server - const httpServer = new WorkerHttpServer(host, workerPort, apiBasePath); - const lifecycle = new BuiltinRuntimeHostLifecycle(host, httpServer); - await lifecycle.initialize(); - logger.log( - `builtin Host worker HTTP server listening on port ${workerPort}` - ); - return lifecycle; - }, -}; + // builtin Host 自管 worker HTTP 服务器——worker 数据面对端不再连 server + const httpServer = new WorkerHttpServer(host, workerPort, apiBasePath); + const lifecycle = new BuiltinRuntimeHostLifecycle(host, httpServer); + await lifecycle.initialize(); + logger.log( + `builtin Host worker HTTP server listening on port ${workerPort}` + ); + return lifecycle; + }, + }; export const builtinRuntimeHostProvider: FactoryProvider = { provide: BUILTIN_RUNTIME_HOST, diff --git a/apps/server/src/user/user-security.spec.ts b/apps/server/src/user/user-security.spec.ts index 95c2a67e..bde8888f 100644 --- a/apps/server/src/user/user-security.spec.ts +++ b/apps/server/src/user/user-security.spec.ts @@ -195,13 +195,18 @@ function makeServices() { const prisma = new MemoryPrisma(); const passwordHasher = new PasswordHasherService(); const repository = new UserRepository(prisma as unknown as PrismaService); - const users = new UserService(repository, passwordHasher, { - emit: vi.fn(), - } as never, { - listLifecycleClaims: vi.fn().mockResolvedValue([]), - listConnectedHostIds: vi.fn().mockReturnValue([]), - releaseResources: vi.fn().mockResolvedValue(undefined), - } as never); + const users = new UserService( + repository, + passwordHasher, + { + emit: vi.fn(), + } as never, + { + listLifecycleClaims: vi.fn().mockResolvedValue([]), + listConnectedHostIds: vi.fn().mockReturnValue([]), + releaseResources: vi.fn().mockResolvedValue(undefined), + } as never + ); return { users, repository, passwordHasher, prisma }; } diff --git a/apps/server/src/user/user.events.ts b/apps/server/src/user/user.events.ts index 8a80a616..5c373aa5 100644 --- a/apps/server/src/user/user.events.ts +++ b/apps/server/src/user/user.events.ts @@ -5,13 +5,19 @@ export const USER_PASSWORD_RESET_EVENT = "user.password-reset"; /** User 已被删除(软删)。下游据此清理该用户名下的资源。 */ export class UserDeletedEvent { /** 状态写入后返回的 User.sessionVersion,用于 Runtime generation fencing。 */ - constructor(readonly userId: string, readonly sessionVersion: number) {} + constructor( + readonly userId: string, + readonly sessionVersion: number + ) {} } /** User 已被禁用。下游据此清理该用户名下的资源。 */ export class UserDisabledEvent { /** 状态写入后返回的 User.sessionVersion,用于 Runtime generation fencing。 */ - constructor(readonly userId: string, readonly sessionVersion: number) {} + constructor( + readonly userId: string, + readonly sessionVersion: number + ) {} } /** 管理员已重置该 User 的密码。下游据此撤销其登录会话。 */ diff --git a/apps/server/src/user/user.module.ts b/apps/server/src/user/user.module.ts index 8da4b20f..13a105aa 100644 --- a/apps/server/src/user/user.module.ts +++ b/apps/server/src/user/user.module.ts @@ -9,11 +9,7 @@ import { RuntimeHostModule } from "../runtime-host/runtime-host.module"; @Module({ imports: [PrismaModule, RuntimeHostModule], controllers: [AdminUserController], - providers: [ - UserService, - UserRepository, - PasswordHasherService, - ], + providers: [UserService, UserRepository, PasswordHasherService], exports: [UserService], }) export class UserModule {} diff --git a/apps/server/src/user/user.service.ts b/apps/server/src/user/user.service.ts index dc232357..29ca767b 100644 --- a/apps/server/src/user/user.service.ts +++ b/apps/server/src/user/user.service.ts @@ -82,7 +82,7 @@ export class UserService { } } - let firstError: unknown; + let firstError: Error | undefined; for (const { userId, version } of targets.values()) { try { await this.runtimeHosts.releaseResources({ @@ -94,7 +94,8 @@ export class UserService { }, }); } catch (error) { - firstError ??= error; + firstError ??= + error instanceof Error ? error : new Error(String(error)); } } if (firstError) throw firstError; diff --git a/apps/server/src/workspace/placement/workspace-runtime.policy.ts b/apps/server/src/workspace/placement/workspace-runtime.policy.ts index 1f59533a..4115688b 100644 --- a/apps/server/src/workspace/placement/workspace-runtime.policy.ts +++ b/apps/server/src/workspace/placement/workspace-runtime.policy.ts @@ -67,7 +67,7 @@ export class WorkspaceRuntimePolicy { const value = runtimeType?.trim() || this.config.getDefaultRuntimeType(); if (!this.config.isRuntimeTypeAllowed(value)) { throw new BadRequestException( - `当前部署不支持该工作空间运行环境: ${value}` + `当前部署不支持该工作空间运行环境: ${String(value)}` ); } return value; diff --git a/apps/server/src/workspace/workspace.service.ts b/apps/server/src/workspace/workspace.service.ts index fa71889e..106b3057 100644 --- a/apps/server/src/workspace/workspace.service.ts +++ b/apps/server/src/workspace/workspace.service.ts @@ -156,7 +156,7 @@ export class WorkspaceService { } } - let firstError: unknown; + let firstError: Error | undefined; for (const workspaceId of targets) { try { await this.runtimeHostService.releaseResources({ @@ -164,7 +164,8 @@ export class WorkspaceService { target: { type: "workspace", workspaceId }, }); } catch (error) { - firstError ??= error; + firstError ??= + error instanceof Error ? error : new Error(String(error)); } } if (firstError) throw firstError; @@ -468,7 +469,7 @@ export class WorkspaceService { `该 Runtime Host 不支持运行方式: ${selectedRuntimeType}` ); } - const runtimeType = selectedRuntimeType as RuntimeType; + const runtimeType = selectedRuntimeType; const capability = capabilities[runtimeType]; if (runtimeType === "native") { if (requestedWorkerScope && requestedWorkerScope !== "workspace") { From f5b9dfaf803dd66d1511f1d69773d91f690de139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 09:55:40 +0800 Subject: [PATCH 15/18] fix(test): validate runtime plugin identifiers --- apps/server/src/workspace/dto/workspace.dto.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/workspace/dto/workspace.dto.spec.ts b/apps/server/src/workspace/dto/workspace.dto.spec.ts index e4f89fd8..b354b4ae 100644 --- a/apps/server/src/workspace/dto/workspace.dto.spec.ts +++ b/apps/server/src/workspace/dto/workspace.dto.spec.ts @@ -51,11 +51,11 @@ describe("CreateWorkspaceDto", () => { ).rejects.toThrow(BadRequestException); }); - it("rejects invalid runtime type in create payloads", async () => { + it("rejects malformed runtime type identifiers in create payloads", async () => { await expect( transformBody(CreateWorkspaceDto, { name: "My Workspace", - runtimeType: "vm", + runtimeType: "VM!", }) ).rejects.toThrow(BadRequestException); }); From 0f7cc900aaa003af1af0fae883858d7ad6e37431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 13:36:32 +0800 Subject: [PATCH 16/18] fix(run): guard conversation projection ownership --- apps/server/prisma/schema.prisma | 3 + .../conversation.repository.spec.ts | 75 ++++++++++++++++- .../conversation/conversation.repository.ts | 81 +++++++++++++++---- .../conversation/conversation.service.spec.ts | 51 +++++++++++- .../src/conversation/conversation.service.ts | 69 ++++++++-------- .../src/run/launch/run.launcher.spec.ts | 52 +++++++++++- apps/server/src/run/launch/run.launcher.ts | 44 +++++++--- .../run/recovery/run-recovery.service.spec.ts | 2 +- .../src/run/recovery/run-recovery.service.ts | 3 +- apps/server/src/run/run.service.ts | 14 +++- .../run/status/run-status-ownership.spec.ts | 4 +- .../src/run/status/run-status.service.spec.ts | 47 +++++++---- .../src/run/status/run-status.service.ts | 33 ++++---- 13 files changed, 368 insertions(+), 110 deletions(-) diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 1c58c8fd..bbd52af7 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -106,6 +106,9 @@ model Conversation { title String? status String @default("regular") runStatus String @default("idle") + // 当前拥有 Conversation 运行状态投影的 Run。启动/steering/终态均以此做 CAS, + // 防止旧 Run 的迟到终态覆盖新 Run 的 running 投影。 + activeRunId String? pendingUserAction String? workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Restrict) diff --git a/apps/server/src/conversation/conversation.repository.spec.ts b/apps/server/src/conversation/conversation.repository.spec.ts index 9856c68c..a12a07cb 100644 --- a/apps/server/src/conversation/conversation.repository.spec.ts +++ b/apps/server/src/conversation/conversation.repository.spec.ts @@ -1,4 +1,5 @@ import { ConversationRepository } from "./conversation.repository"; +import { TERMINAL_RUN_STATUSES } from "@agework/shared"; const ownerFilter = { userId: "user-1", deletedAt: null }; @@ -86,18 +87,86 @@ describe("ConversationRepository", () => { } ); - it("setRunStatus guards the running transition and reports whether a row changed", async () => { + it("claims an idle/error conversation for one run", async () => { const updateMany = vi.fn().mockResolvedValue({ count: 1 }); const repo = new ConversationRepository( makePrisma({ conversation: { updateMany } }) as never ); - const changed = await repo.setRunStatus("conv-1", "running"); + const changed = await repo.claimRun("conv-1", "run-1"); expect(changed).toBe(true); expect(updateMany).toHaveBeenCalledWith({ where: { id: "conv-1", runStatus: { in: ["idle", "error"] } }, - data: { runStatus: "running", pendingUserAction: null }, + data: { + runStatus: "running", + activeRunId: "run-1", + pendingUserAction: null, + }, + }); + }); + + it("hands conversation ownership from the steered run to the next run", async () => { + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const repo = new ConversationRepository( + makePrisma({ conversation: { updateMany } }) as never + ); + + await expect(repo.handoffRun("conv-1", "run-old", "run-new")).resolves.toBe( + true + ); + expect(updateMany).toHaveBeenCalledWith({ + where: { + id: "conv-1", + runStatus: "running", + activeRunId: "run-old", + }, + data: { activeRunId: "run-new", pendingUserAction: null }, + }); + }); + + it("settles only the run that still owns the conversation projection", async () => { + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const repo = new ConversationRepository( + makePrisma({ conversation: { updateMany } }) as never + ); + + await expect( + repo.setRunStateForOwner("conv-1", "run-1", { runStatus: "idle" }) + ).resolves.toBe(true); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "conv-1", activeRunId: "run-1" }, + data: { + runStatus: "idle", + activeRunId: null, + pendingUserAction: null, + }, + }); + }); + + it("reconciles stale projections only when every Run is terminal", async () => { + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + const repo = new ConversationRepository( + makePrisma({ conversation: { updateMany } }) as never + ); + + await expect( + repo.reconcileRunStateWithoutActiveRun("conv-1", "error") + ).resolves.toBe(true); + expect(updateMany).toHaveBeenCalledWith({ + where: { + id: "conv-1", + runs: { + none: { + status: { notIn: [...TERMINAL_RUN_STATUSES] }, + }, + }, + }, + data: { + runStatus: "error", + activeRunId: null, + pendingUserAction: null, + }, }); }); diff --git a/apps/server/src/conversation/conversation.repository.ts b/apps/server/src/conversation/conversation.repository.ts index 69d42144..da729427 100644 --- a/apps/server/src/conversation/conversation.repository.ts +++ b/apps/server/src/conversation/conversation.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import type { AgentModeState } from "@agework/shared"; +import { TERMINAL_RUN_STATUSES, type AgentModeState } from "@agework/shared"; import type { Prisma } from "../../generated/prisma/client.js"; import { PrismaService } from "../prisma/prisma.service"; @@ -159,29 +159,80 @@ export class ConversationRepository { }); } - async setRunStatus( + /** 空闲/错误会话由 runId 原子认领,后续投影写入必须携带同一 owner。 */ + async claimRun(conversationId: string, runId: string): Promise { + const result = await this.prisma.conversation.updateMany({ + where: { id: conversationId, runStatus: { in: ["idle", "error"] } }, + data: { + runStatus: "running", + activeRunId: runId, + pendingUserAction: null, + }, + }); + return result.count > 0; + } + + /** steering 时把投影所有权从旧 Run CAS 交给新 Run。 */ + async handoffRun( conversationId: string, - runStatus: "idle" | "running" | "error" + currentRunId: string, + nextRunId: string ): Promise { - const where = - runStatus === "running" - ? { id: conversationId, runStatus: { in: ["idle", "error"] } } - : { id: conversationId }; const result = await this.prisma.conversation.updateMany({ - where, - data: { runStatus, pendingUserAction: null }, + where: { + id: conversationId, + runStatus: "running", + activeRunId: currentRunId, + }, + data: { activeRunId: nextRunId, pendingUserAction: null }, }); return result.count > 0; } - async setPendingUserAction( + /** 仅当前 owner Run 可更新 Conversation 的运行投影。终态同时释放 owner。 */ + async setRunStateForOwner( conversationId: string, - pendingUserAction: string | null - ) { - await this.prisma.conversation.updateMany({ - where: { id: conversationId, deletedAt: null }, - data: { pendingUserAction }, + runId: string, + state: { + runStatus?: "idle" | "error"; + pendingUserAction?: string | null; + } + ): Promise { + const result = await this.prisma.conversation.updateMany({ + where: { id: conversationId, activeRunId: runId }, + data: { + ...(state.pendingUserAction !== undefined + ? { pendingUserAction: state.pendingUserAction } + : {}), + ...(state.runStatus !== undefined + ? { + runStatus: state.runStatus, + activeRunId: null, + pendingUserAction: null, + } + : {}), + }, }); + return result.count > 0; + } + + /** 启动恢复:没有任何非终态 Run 时才清理遗留投影与 owner。 */ + async reconcileRunStateWithoutActiveRun( + conversationId: string, + runStatus: "idle" | "error" + ): Promise { + const result = await this.prisma.conversation.updateMany({ + where: { + id: conversationId, + runs: { + none: { + status: { notIn: [...TERMINAL_RUN_STATUSES] }, + }, + }, + }, + data: { runStatus, activeRunId: null, pendingUserAction: null }, + }); + return result.count > 0; } attachMessageToRun(conversationId: string, messageId: string, runId: string) { diff --git a/apps/server/src/conversation/conversation.service.spec.ts b/apps/server/src/conversation/conversation.service.spec.ts index 34d439ba..9eb9a151 100644 --- a/apps/server/src/conversation/conversation.service.spec.ts +++ b/apps/server/src/conversation/conversation.service.spec.ts @@ -16,8 +16,10 @@ function makeRepo(overrides: Record = {}) { updateTitle: vi.fn().mockResolvedValue(undefined), renameOwned: vi.fn().mockResolvedValue(undefined), updateOwned: vi.fn().mockResolvedValue(undefined), - setRunStatus: vi.fn().mockResolvedValue(true), - setPendingUserAction: vi.fn().mockResolvedValue(undefined), + claimRun: vi.fn().mockResolvedValue(true), + handoffRun: vi.fn().mockResolvedValue(true), + setRunStateForOwner: vi.fn().mockResolvedValue(true), + reconcileRunStateWithoutActiveRun: vi.fn().mockResolvedValue(true), attachMessageToRun: vi.fn().mockResolvedValue({ count: 1 }), archiveOwned: vi.fn().mockResolvedValue(undefined), unarchiveOwned: vi.fn().mockResolvedValue(undefined), @@ -31,6 +33,51 @@ function makeRepo(overrides: Record = {}) { } describe("ConversationService", () => { + it("claims the Conversation projection for the starting run", async () => { + const repo = makeRepo(); + const service = new ConversationService(repo as never); + + await expect( + service.activateConversation("conversation-1", "user-1", "run-1") + ).resolves.toBe(true); + expect(repo.claimRun).toHaveBeenCalledWith("conversation-1", "run-1"); + }); + + it("hands the Conversation projection to the steering run", async () => { + const repo = makeRepo(); + const service = new ConversationService(repo as never); + + await expect( + service.handoffConversationRun( + "conversation-1", + "user-1", + "run-old", + "run-new" + ) + ).resolves.toBe(true); + expect(repo.handoffRun).toHaveBeenCalledWith( + "conversation-1", + "run-old", + "run-new" + ); + }); + + it("updates run state through the activeRunId owner guard", async () => { + const repo = makeRepo(); + const service = new ConversationService(repo as never); + + await expect( + service.setConversationRunStateForRun("conversation-1", "run-1", { + runStatus: "idle", + }) + ).resolves.toBe(true); + expect(repo.setRunStateForOwner).toHaveBeenCalledWith( + "conversation-1", + "run-1", + { runStatus: "idle" } + ); + }); + it("lists conversations by newest update time and maps to dto", async () => { const createdAt = new Date("2026-05-30T10:00:00.000Z"); const updatedAt = new Date("2026-05-31T10:00:00.000Z"); diff --git a/apps/server/src/conversation/conversation.service.ts b/apps/server/src/conversation/conversation.service.ts index 1fa3fc6d..76e48526 100644 --- a/apps/server/src/conversation/conversation.service.ts +++ b/apps/server/src/conversation/conversation.service.ts @@ -341,59 +341,62 @@ export class ConversationService { } /** - * 原子切换会话运行状态;切到 `running` 时要求当前状态为 `idle`/`error`,防止重复启动 run。 - * 仅供本 Service 的激活/状态投影入口复用,返回是否命中并发生变更。 - */ - private async setRunStatus( - conversationId: string, - runStatus: "idle" | "running" | "error" - ): Promise { - return this.repo.setRunStatus(conversationId, runStatus); - } - - /** - * ConversationEffectsPort 实现:原子激活会话(setRunStatus("running")), + * ConversationEffectsPort 实现:原子激活会话并记录投影 owner runId, * 激活失败时校验归属(findById 抛 NotFoundException),返回是否成功激活。 * 调用方:`RunLauncher.claimRun`。 */ async activateConversation( conversationId: string, - userId: string + userId: string, + runId: string ): Promise { - const activated = await this.setRunStatus(conversationId, "running"); + const activated = await this.repo.claimRun(conversationId, runId); if (activated) return true; await this.findById(userId, conversationId); return false; } /** - * ConversationEffectsPort 实现:设置会话运行终态 / 中间态(idle / error / pendingUserAction)。 - * run 状态投影调用方统一为 `RunStatusService`。 + * steering 所有权交接:只有仍由 currentRunId 持有的 running 会话可转交。 + * 失败时校验归属并返回 false,让调用方拒绝无 owner 的新 Run。 */ - async setConversationRunState( + async handoffConversationRun( + conversationId: string, + userId: string, + currentRunId: string, + nextRunId: string + ): Promise { + const handedOff = await this.repo.handoffRun( + conversationId, + currentRunId, + nextRunId + ); + if (handedOff) return true; + await this.findById(userId, conversationId); + return false; + } + + /** Run 状态投影只能由 activeRunId 对应的 owner Run 写入。 */ + async setConversationRunStateForRun( conversationId: string, + runId: string, state: { - runStatus?: "idle" | "running" | "error"; + runStatus?: "idle" | "error"; pendingUserAction?: ConversationPendingUserAction | null; } - ): Promise { - if (state.pendingUserAction !== undefined) { - await this.setPendingUserAction(conversationId, state.pendingUserAction); - } - if (state.runStatus !== undefined) { - await this.setRunStatus(conversationId, state.runStatus); - } + ): Promise { + return this.repo.setRunStateForOwner(conversationId, runId, state); } - /** - * 记录待用户操作(如 `question`),用于 requires-action 场景标记会话需要用户输入。 - * 调用方:`RunStatusService`。 - */ - async setPendingUserAction( + /** 启动恢复路径:仅在不存在非终态 Run 时修复遗留投影。 */ + async reconcileConversationRunState( conversationId: string, - pendingUserAction: ConversationPendingUserAction - ) { - await this.repo.setPendingUserAction(conversationId, pendingUserAction); + runStatus: "idle" | "error" + ): Promise { + return this.repo.reconcileRunStateWithoutActiveRun( + conversationId, + runStatus + ); } /** diff --git a/apps/server/src/run/launch/run.launcher.spec.ts b/apps/server/src/run/launch/run.launcher.spec.ts index 6b997ef3..70eccc21 100644 --- a/apps/server/src/run/launch/run.launcher.spec.ts +++ b/apps/server/src/run/launch/run.launcher.spec.ts @@ -75,7 +75,10 @@ describe("RunLauncher", () => { } beforeEach(() => { - stopActiveRun = vi.fn().mockResolvedValue(true); + stopActiveRun = vi.fn().mockResolvedValue({ + runId: "run-old", + hadHandle: true, + }); mockRunRepository = { create: vi.fn().mockResolvedValue({ id: "run-1" }), findActiveByConversationId: vi.fn().mockResolvedValue(null), @@ -94,7 +97,8 @@ describe("RunLauncher", () => { }; mockConversationEffects = { activateConversation: vi.fn().mockResolvedValue(true), - setConversationRunState: vi.fn().mockResolvedValue(undefined), + handoffConversationRun: vi.fn().mockResolvedValue(true), + setConversationRunStateForRun: vi.fn().mockResolvedValue(true), saveUserMessage: vi.fn().mockResolvedValue(undefined), saveAssistantMessage: vi.fn().mockResolvedValue(undefined), attachMessageToRun: vi.fn().mockResolvedValue(undefined), @@ -217,8 +221,46 @@ describe("RunLauncher", () => { await launch(); expect(mockConversationEffects.activateConversation).toHaveBeenCalledWith( "conversation-1", - "user-1" + "user-1", + "run-1" + ); + }); + + it("hands the Conversation projection from the steered run to the new run", async () => { + mockConversationEffects.activateConversation = vi + .fn() + .mockResolvedValue(false); + + await launch(makeStartInput({ interruptReason: "user_steered" })); + + expect(stopActiveRun).toHaveBeenCalledWith("conversation-1", { + reason: "user_steered", + endResponse: true, + }); + expect(mockConversationEffects.handoffConversationRun).toHaveBeenCalledWith( + "conversation-1", + "user-1", + "run-old", + "run-1" ); + expect(mockRuntimeHost.submitRun).toHaveBeenCalled(); + }); + + it("reclaims the Conversation when the old run settles before handoff", async () => { + mockConversationEffects.activateConversation = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + mockConversationEffects.handoffConversationRun = vi + .fn() + .mockResolvedValue(false); + + await launch(makeStartInput({ interruptReason: "user_steered" })); + + expect( + mockConversationEffects.activateConversation + ).toHaveBeenNthCalledWith(2, "conversation-1", "user-1", "run-1"); + expect(mockRuntimeHost.submitRun).toHaveBeenCalled(); }); it("saves the user message and triggers title generation", async () => { @@ -307,6 +349,7 @@ describe("RunLauncher", () => { await launch(makeStartInput({ res })); expect(mockRunStatusService.failLaunchClaim).toHaveBeenCalledWith({ + runId: "run-1", conversationId: "conversation-1", }); expect(mockRuntimeHost.submitRun).not.toHaveBeenCalled(); @@ -331,6 +374,7 @@ describe("RunLauncher", () => { expect(mockRunRepository.create).not.toHaveBeenCalled(); expect(mockRunStatusService.failLaunchClaim).toHaveBeenCalledWith({ + runId: "run-1", conversationId: "conversation-1", }); expect(res.end).toHaveBeenCalled(); @@ -355,7 +399,7 @@ describe("RunLauncher", () => { }); expect(mockRunRepository.markError).not.toHaveBeenCalled(); expect( - mockConversationEffects.setConversationRunState + mockConversationEffects.setConversationRunStateForRun ).not.toHaveBeenCalled(); expect(mockLiveRunRegistry.unregister).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/run/launch/run.launcher.ts b/apps/server/src/run/launch/run.launcher.ts index 838a321c..54a535d7 100644 --- a/apps/server/src/run/launch/run.launcher.ts +++ b/apps/server/src/run/launch/run.launcher.ts @@ -45,7 +45,7 @@ type SaveRun = ( export type StopActiveRun = ( conversationId: string, options?: { reason?: IncompleteMessageReason; endResponse?: boolean } -) => Promise; +) => Promise<{ runId?: string; hadHandle: boolean }>; /** * 一次 run 的启动准备能力:业务放置校验、并发守卫、落库 run 记录、提交执行面 @@ -92,7 +92,7 @@ export class RunLauncher { const scope = placement.scope; const stream = new RunStream(res); - const claimedConversation = await this.claimRun({ + await this.claimRun({ conversationId, userId, runId, @@ -121,7 +121,6 @@ export class RunLauncher { await this.handlePreRunFailure({ runId, conversationId, - claimedConversation, stream, error: err, }); @@ -235,24 +234,46 @@ export class RunLauncher { runId: string; interruptReason?: "user_steered"; stopActiveRun: StopActiveRun; - }): Promise { + }): Promise { const { conversationId, userId, runId, interruptReason, stopActiveRun } = input; const activated = await this.conversationService.activateConversation( conversationId, - userId + userId, + runId ); - if (activated) return true; + if (activated) return; if (interruptReason === "user_steered") { - await stopActiveRun(conversationId, { + const stopped = await stopActiveRun(conversationId, { reason: "user_steered", endResponse: true, }); + const handedOff = stopped.runId + ? await this.conversationService.handoffConversationRun( + conversationId, + userId, + stopped.runId, + runId + ) + : false; + // 无内存 handle 的旧 Run 会在 stop 内同步收敛并释放 owner;此时重新认领。 + const reclaimed = + handedOff || + (await this.conversationService.activateConversation( + conversationId, + userId, + runId + )); + if (!reclaimed) { + throw new ConflictException( + "Conversation run ownership changed while steering" + ); + } this.logger.log("active run interrupted by user steering", { conversationId, runId, }); - return false; + return; } throw new ConflictException( @@ -380,11 +401,10 @@ export class RunLauncher { private async handlePreRunFailure(input: { runId: string; conversationId: string; - claimedConversation: boolean; stream: RunStream; error: unknown; }): Promise { - const { runId, conversationId, claimedConversation, stream, error } = input; + const { runId, conversationId, stream, error } = input; this.logger.warn("prepare run failed", { runId, conversationId, @@ -392,9 +412,7 @@ export class RunLauncher { }); const message = error instanceof Error ? error.message : String(error); try { - if (claimedConversation) { - await this.runStatusService.failLaunchClaim({ conversationId }); - } + await this.runStatusService.failLaunchClaim({ runId, conversationId }); } finally { stream.writeError({ threadId: conversationId, runId, message }); stream.end(); diff --git a/apps/server/src/run/recovery/run-recovery.service.spec.ts b/apps/server/src/run/recovery/run-recovery.service.spec.ts index b3208683..4453d3eb 100644 --- a/apps/server/src/run/recovery/run-recovery.service.spec.ts +++ b/apps/server/src/run/recovery/run-recovery.service.spec.ts @@ -123,7 +123,7 @@ describe("RunRecoveryService.failInterruptedRuns", () => { }); }); - it("repairs stale Conversation projections before recovering active runs", async () => { + it("repairs stale Conversation projections after recovering active runs", async () => { const deps = makeDeps([]); deps.runRepository.findRunningConversationsWithoutActiveRun = vi .fn() diff --git a/apps/server/src/run/recovery/run-recovery.service.ts b/apps/server/src/run/recovery/run-recovery.service.ts index 3d849045..a7686701 100644 --- a/apps/server/src/run/recovery/run-recovery.service.ts +++ b/apps/server/src/run/recovery/run-recovery.service.ts @@ -80,7 +80,6 @@ export class RunRecoveryService } async failInterruptedRuns(): Promise { - await this.reconcileConversationRunStatuses(); const activeRuns = await this.runRepository.listActive(); if (activeRuns.length === 0) { this.logger.log("No interrupted active runs found."); @@ -105,6 +104,8 @@ export class RunRecoveryService }); } } + // active Run 全部判死后再统一修复投影,也覆盖旧数据尚无 activeRunId 的情况。 + await this.reconcileConversationRunStatuses(); this.startAbandonedSweep(); } diff --git a/apps/server/src/run/run.service.ts b/apps/server/src/run/run.service.ts index acea7298..487b876a 100644 --- a/apps/server/src/run/run.service.ts +++ b/apps/server/src/run/run.service.ts @@ -151,7 +151,7 @@ export class RunService implements OnApplicationBootstrap { async start(input: StartRunInput): Promise { await this.runLauncher.launch(input, { stopActiveRun: (conversationId, options) => - this.stop(conversationId, options), + this.stopWithResult(conversationId, options), }); } @@ -271,6 +271,14 @@ export class RunService implements OnApplicationBootstrap { conversationId: string, options?: { reason?: IncompleteMessageReason; endResponse?: boolean } ): Promise { + return (await this.stopWithResult(conversationId, options)).hadHandle; + } + + /** steering 额外需要旧 runId,才能原子交接 Conversation 投影所有权。 */ + private async stopWithResult( + conversationId: string, + options?: { reason?: IncompleteMessageReason; endResponse?: boolean } + ): Promise<{ runId?: string; hadHandle: boolean }> { const activeRunRecord = await this.runRepository.findActiveByConversationId(conversationId); const handle = activeRunRecord @@ -284,7 +292,7 @@ export class RunService implements OnApplicationBootstrap { conversationId, }); } - return false; + return { runId: activeRunRecord?.id, hadHandle: false }; } handle.stopRequested = true; handle.stopReason = options?.reason; @@ -308,7 +316,7 @@ export class RunService implements OnApplicationBootstrap { handle.stream.end(); handle.stream.detach(); } - return true; + return { runId: handle.runId, hadHandle: true }; } } diff --git a/apps/server/src/run/status/run-status-ownership.spec.ts b/apps/server/src/run/status/run-status-ownership.spec.ts index 95b8d54e..9d11d7e5 100644 --- a/apps/server/src/run/status/run-status-ownership.spec.ts +++ b/apps/server/src/run/status/run-status-ownership.spec.ts @@ -33,10 +33,10 @@ describe("run status ownership", () => { ).toEqual([]); }); - it("keeps the Conversation run-status projection inside RunStatusService", () => { + it("keeps Conversation settlement/recovery writes inside RunStatusService", () => { expect( violations( - /conversationService\.setConversationRunState\s*\(/, + /conversationService\.(?:setConversationRunStateForRun|reconcileConversationRunState)\s*\(/, new Set(["run/status/run-status.service.ts"]) ) ).toEqual([]); diff --git a/apps/server/src/run/status/run-status.service.spec.ts b/apps/server/src/run/status/run-status.service.spec.ts index 22551eeb..9aee0ebb 100644 --- a/apps/server/src/run/status/run-status.service.spec.ts +++ b/apps/server/src/run/status/run-status.service.spec.ts @@ -64,12 +64,12 @@ function makeSubject(input?: { markError: vi.fn().mockResolvedValue(undefined), markCancelled: vi.fn().mockResolvedValue(undefined), markCancelling: vi.fn().mockResolvedValue(undefined), - findActiveByConversationId: vi - .fn() - .mockResolvedValue(input?.activeRun ?? null), }; const runConversation = { - setConversationRunState: vi.fn().mockResolvedValue(undefined), + setConversationRunStateForRun: vi + .fn() + .mockResolvedValue(input?.activeRun?.id !== "run-2"), + reconcileConversationRunState: vi.fn().mockResolvedValue(true), } satisfies Partial; const registry = input?.registry ?? new LiveRunRegistry(makeConfig()); const finalization = new RunFinalizationStore(); @@ -130,8 +130,9 @@ describe("RunStatusService", () => { expect(runRepository.markRequiresAction).toHaveBeenCalledWith("run-1"); expect(handle.saveRun).toHaveBeenCalledWith(false); - expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + expect(runConversation.setConversationRunStateForRun).toHaveBeenCalledWith( "conversation-1", + "run-1", { pendingUserAction: "question" } ); }); @@ -175,8 +176,9 @@ describe("RunStatusService", () => { }); expect(runRepository.markError).toHaveBeenCalledWith("run-1", "boom"); - expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + expect(runConversation.setConversationRunStateForRun).toHaveBeenCalledWith( "conversation-1", + "run-1", { runStatus: "error" } ); expect(handle.saveRun).toHaveBeenCalledWith(false, "error"); @@ -192,7 +194,7 @@ describe("RunStatusService", () => { }); }); - it("does not overwrite conversation status when a newer run is active", async () => { + it("settles through the activeRunId CAS when a newer run owns the projection", async () => { const { handler, runConversation, registry } = makeSubject({ activeRun: { id: "run-2" }, }); @@ -205,7 +207,11 @@ describe("RunStatusService", () => { effect: runStatusEffect("finished"), }); - expect(runConversation.setConversationRunState).not.toHaveBeenCalled(); + expect(runConversation.setConversationRunStateForRun).toHaveBeenCalledWith( + "conversation-1", + "run-1", + { runStatus: "idle" } + ); expect(handle.saveRun).toHaveBeenCalledWith(true, undefined); }); @@ -282,8 +288,9 @@ describe("RunStatusService", () => { "run-1", "host offline" ); - expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + expect(runConversation.setConversationRunStateForRun).toHaveBeenCalledWith( "conversation-1", + "run-1", { runStatus: "error" } ); expect(runEvents.runStatusChanged).toHaveBeenCalledWith({ @@ -317,10 +324,16 @@ describe("RunStatusService", () => { it("releases a failed launch claim when no run row became active", async () => { const { handler, runConversation } = makeSubject(); - await handler.failLaunchClaim({ conversationId: "conversation-1" }); + await expect( + handler.failLaunchClaim({ + runId: "run-1", + conversationId: "conversation-1", + }) + ).resolves.toBe(true); - expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + expect(runConversation.setConversationRunStateForRun).toHaveBeenCalledWith( "conversation-1", + "run-1", { runStatus: "error" } ); }); @@ -330,9 +343,12 @@ describe("RunStatusService", () => { activeRun: { id: "run-2" }, }); - await handler.failLaunchClaim({ conversationId: "conversation-1" }); - - expect(runConversation.setConversationRunState).not.toHaveBeenCalled(); + await expect( + handler.failLaunchClaim({ + runId: "run-1", + conversationId: "conversation-1", + }) + ).resolves.toBe(false); }); it("marks cancelling and records a platform status event on cancel request", async () => { @@ -360,8 +376,9 @@ describe("RunStatusService", () => { }); expect(runRepository.markCancelled).toHaveBeenCalledWith("run-1"); - expect(runConversation.setConversationRunState).toHaveBeenCalledWith( + expect(runConversation.setConversationRunStateForRun).toHaveBeenCalledWith( "conversation-1", + "run-1", { runStatus: "idle" } ); expect(runEvents.runStatusChanged).toHaveBeenCalledWith({ diff --git a/apps/server/src/run/status/run-status.service.ts b/apps/server/src/run/status/run-status.service.ts index bb4b351f..49840ad3 100644 --- a/apps/server/src/run/status/run-status.service.ts +++ b/apps/server/src/run/status/run-status.service.ts @@ -105,8 +105,9 @@ export class RunStatusService { } if (handle && payload.pendingAction !== undefined) { - await this.conversationService.setConversationRunState( + await this.conversationService.setConversationRunStateForRun( handle.conversationId, + runId, { pendingUserAction: payload.pendingAction } ); } @@ -172,13 +173,13 @@ export class RunStatusService { * Run 行创建前失败时释放 Conversation claim。若已有别的活跃 Run 接管会话, * 不覆盖它的 running 投影。 */ - async failLaunchClaim(input: { conversationId: string }): Promise { - const activeRun = await this.runRepository.findActiveByConversationId( - input.conversationId - ); - if (activeRun) return; - await this.conversationService.setConversationRunState( + async failLaunchClaim(input: { + runId: string; + conversationId: string; + }): Promise { + return this.conversationService.setConversationRunStateForRun( input.conversationId, + input.runId, { runStatus: "error" } ); } @@ -188,9 +189,9 @@ export class RunStatusService { conversationId: string; runStatus: "idle" | "error"; }): Promise { - await this.conversationService.setConversationRunState( + await this.conversationService.reconcileConversationRunState( input.conversationId, - { runStatus: input.runStatus } + input.runStatus ); } @@ -323,15 +324,11 @@ export class RunStatusService { ): Promise { if (!effect.terminalConversationStatus) return; - const newerActiveRun = - await this.runRepository.findActiveByConversationId(conversationId); - if (newerActiveRun && newerActiveRun.id !== runId) { - return; - } - - await this.conversationService.setConversationRunState(conversationId, { - runStatus: effect.terminalConversationStatus, - }); + await this.conversationService.setConversationRunStateForRun( + conversationId, + runId, + { runStatus: effect.terminalConversationStatus } + ); } private writeTerminalSse( From b7d0b50e0700b81f14d402527540cd8798b0cbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 13:39:31 +0800 Subject: [PATCH 17/18] fix(test): align conversation projection mock --- apps/server/src/run/upstream/host-upstream.handler.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/run/upstream/host-upstream.handler.spec.ts b/apps/server/src/run/upstream/host-upstream.handler.spec.ts index 21e26803..6f82ed11 100644 --- a/apps/server/src/run/upstream/host-upstream.handler.spec.ts +++ b/apps/server/src/run/upstream/host-upstream.handler.spec.ts @@ -61,7 +61,8 @@ describe("HostUpstreamHandler", () => { }; mockConversations = { - setConversationRunState: vi.fn().mockResolvedValue(undefined), + setConversationRunStateForRun: vi.fn().mockResolvedValue(true), + reconcileConversationRunState: vi.fn().mockResolvedValue(true), setAgentSessionId: vi.fn().mockResolvedValue(undefined), activateConversation: vi.fn().mockResolvedValue(true), }; From 92669c00f947f3e9f87d33efc62b1db2d2804334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BF=B7=E6=82=9F?= <1010953107@qq.com> Date: Mon, 20 Jul 2026 13:43:30 +0800 Subject: [PATCH 18/18] fix(test): satisfy run status lint --- apps/server/src/run/status/run-status.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/run/status/run-status.service.spec.ts b/apps/server/src/run/status/run-status.service.spec.ts index 9aee0ebb..33eeb467 100644 --- a/apps/server/src/run/status/run-status.service.spec.ts +++ b/apps/server/src/run/status/run-status.service.spec.ts @@ -339,7 +339,7 @@ describe("RunStatusService", () => { }); it("does not release a launch claim after another run became active", async () => { - const { handler, runConversation } = makeSubject({ + const { handler } = makeSubject({ activeRun: { id: "run-2" }, });