diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 72987599f48f6..6655bff2d8838 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -268,7 +268,7 @@ export function useComposerCommandMenu({ : []; const selectedProviderInstanceId = selectedProviderStatus?.instanceId; const refreshWorkspaceSnapshot = useAtomRefresh( - environmentId && projectCwd && selectedProviderInstanceId + environmentId && selectedProviderInstanceId ? providerWorkspaceSnapshotRefresh({ environmentId, instanceId: selectedProviderInstanceId, diff --git a/apps/server/src/delivery/DeliveryService.test.ts b/apps/server/src/delivery/DeliveryService.test.ts index df89c6c5677fd..43cc5bc7d31c4 100644 --- a/apps/server/src/delivery/DeliveryService.test.ts +++ b/apps/server/src/delivery/DeliveryService.test.ts @@ -172,6 +172,31 @@ const bind = ( const CLAUDE_HERE = { driver: "claudeAgent", cwd: REPO }; describe("DeliveryService", () => { + it.effect( + "refuses native completion through the public relay before contacting the engine", + () => { + const fake = engine(); + return Effect.gen(function* () { + const delivery = yield* DeliveryService.DeliveryService; + for (const path of [ + "/api/native-builds/att-one/complete", + "/api/tasks/../native-builds/att-one/complete", + "/api/tasks/%2e%2e/native-builds/att-one/complete", + "/api/tasks\\..\\native-builds\\att-one\\complete", + ]) { + const failure = yield* delivery + .act({ path, body: { state: "completed", text: "fabricated" } }) + .pipe(Effect.flip); + expect(failure.reason).toBe("refused"); + } + const invalid = yield* delivery + .nativeBuildAct({ path: "/api/tasks", body: {} }) + .pipe(Effect.flip); + expect(invalid.reason).toBe("invalid"); + expect(fake.requests).toEqual([]); + }).pipe(Effect.provide(layer(fake, { prefix: "native-relay-guard-" }))); + }, + ); it.effect("refuses reads and writes into another account profile's engine", () => { const fake = engine(); return Effect.gen(function* () { @@ -681,7 +706,8 @@ describe("DeliveryService", () => { }).pipe(Effect.provide(shared("t3-delivery-unreadable-"))); }); - it.effect("fails the binding when it cannot be saved, and asks nothing of the engine", () => { + // Atomic replacement retries transient Windows filesystem errors on the real clock. + it.live("fails the binding when it cannot be saved, and asks nothing of the engine", () => { const fake = engine(); return Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/delivery/DeliveryService.ts b/apps/server/src/delivery/DeliveryService.ts index 6e6b4cc6128ac..8c459e9d0454e 100644 --- a/apps/server/src/delivery/DeliveryService.ts +++ b/apps/server/src/delivery/DeliveryService.ts @@ -110,6 +110,10 @@ export class DeliveryService extends Context.Service< { readonly read: (input: DeliveryReadInput) => Effect.Effect; readonly act: (input: DeliveryActInput) => Effect.Effect; + /** Internal coordinator writes. Never expose this method as a generic client relay. */ + readonly nativeBuildAct: ( + input: DeliveryActInput, + ) => Effect.Effect; readonly bindThread: ( input: DeliveryBindThreadInput, ) => Effect.Effect; @@ -132,6 +136,28 @@ export class DeliveryService extends Context.Service< } >()("t3/delivery/DeliveryService") {} +const disabled = () => + Effect.fail(new DeliveryError({ reason: "disabled", detail: "Delivery is turned off." })); + +/** Inert service, for suites that only need the RPC surface to resolve. */ +export const layerTest = Layer.succeed( + DeliveryService, + DeliveryService.of({ + read: disabled, + act: disabled, + nativeBuildAct: disabled, + bindThread: disabled, + threadState: Effect.fn("DeliveryService.threadState")(() => + Effect.succeed({ binding: null, pending: null }), + ), + releaseThread: Effect.fn("DeliveryService.releaseThread")(() => + Effect.succeed({ binding: null, pending: null }), + ), + prepareThread: Effect.fn("DeliveryService.prepareThread")(() => Effect.succeed(false)), + terminalEntryScript: Effect.succeed(null), + }), +); + const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -673,7 +699,27 @@ const make = Effect.gen(function* () { return DeliveryService.of({ read: Effect.fn("DeliveryService.read")((input) => request("GET", input.path)), - act: Effect.fn("DeliveryService.act")((input) => request("POST", input.path, input.body)), + act: Effect.fn("DeliveryService.act")((input) => + new URL(input.path, "http://delivery.invalid").pathname.startsWith("/api/native-builds/") + ? Effect.fail( + new DeliveryError({ + reason: "refused", + detail: + "Native build receipts can only be written by the server's native coordinator.", + }), + ) + : request("POST", input.path, input.body), + ), + nativeBuildAct: Effect.fn("DeliveryService.nativeBuildAct")((input) => + /^\/api\/native-builds\/att-[A-Za-z0-9-]+\/(claim|attach|complete)$/.test(input.path) + ? request("POST", input.path, input.body) + : Effect.fail( + new DeliveryError({ + reason: "invalid", + detail: "Invalid native coordinator mutation path.", + }), + ), + ), bindThread, threadState: Effect.fn("DeliveryService.threadState")((threadId) => storeReadable.pipe(Effect.as(stateOf(threadId))), diff --git a/apps/server/src/delivery/NativeBuildExecution.test.ts b/apps/server/src/delivery/NativeBuildExecution.test.ts new file mode 100644 index 0000000000000..784e177a4384f --- /dev/null +++ b/apps/server/src/delivery/NativeBuildExecution.test.ts @@ -0,0 +1,227 @@ +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import { DeliveryService } from "./DeliveryService.ts"; +import { OrchestratorMcpService } from "../mcp/OrchestratorMcpService.ts"; +import type { McpInvocationScope } from "../mcp/McpInvocationContext.ts"; +import { make } from "./NativeBuildExecution.ts"; + +const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + capabilities: new Set(["orchestration"]), + issuedAt: 0, + requestNamespace: "session-one", + thread: { + threadId: ThreadId.make("parent"), + providerSessionId: "session", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, +}; + +function setup({ + mismatch = false, + attachFailure = false, + paged = false, + claimedCancellation = false, + cancelOnClaim = false, +} = {}) { + let build = { + version: 1, + id: "att-one", + worktree: "C:/build/worktree", + harness: "codex", + model: "model", + prompt: "Build and return JSON.", + access: "full", + state: claimedCancellation ? "claimed" : "queued", + owner: (claimedCancellation ? `${scope.environmentId}:${scope.thread?.threadId}` : null) as + | string + | null, + cancelRequested: claimedCancellation, + native: null as { taskId: string; childThreadId: string } | null, + }; + let nativeState = "running"; + const admissions = new Set(); + const readThread = vi.fn( + (_caller: McpInvocationScope, input: { threadId: string; afterPosition?: number }) => + Effect.succeed( + input.threadId === "parent" + ? { thread: { worktreePath: mismatch ? "C:/another/worktree" : build.worktree } } + : paged && input.afterPosition === undefined + ? { hasMore: true, nextPosition: 100, items: [] } + : { + hasMore: false, + items: [ + { + runId: "native-run", + type: "assistant_message", + textTruncated: false, + text: '{"summary":"done"}', + }, + ], + }, + ), + ); + const delegateTask = vi.fn((caller: McpInvocationScope, input: { clientRequestId: string }) => { + admissions.add(`${caller.requestNamespace}:${input.clientRequestId}`); + return Effect.succeed({ taskId: "native-task", childThreadId: "native-thread" }); + }); + const cancelTask = vi.fn(() => { + nativeState = "cancelled"; + return Effect.succeed({ taskId: "native-task", status: "cancelled" }); + }); + const orchestrator = { + readThread, + delegateTask, + cancelTask, + capabilities: () => + Effect.succeed({ + inheritedModel: "model", + runtimeMode: "full-access", + providers: [{ providerInstanceId: "codex", driverKind: "codex", canRunChildTask: true }], + }), + taskStatus: () => + Effect.succeed({ + workState: nativeState === "running" ? "working" : "result_available", + hasPendingChildRuns: false, + latestTerminalStatus: nativeState === "running" ? null : nativeState, + latestTerminalRunId: "native-run", + }), + } as unknown as OrchestratorMcpService["Service"]; + const completed: unknown[] = []; + const delivery = { + read: () => Effect.succeed({ body: build }), + nativeBuildAct: ({ + path, + body, + }: { + path: string; + body: { owner: string; taskId: string; childThreadId: string; state: string }; + }) => { + if (path.endsWith("/claim")) + build = cancelOnClaim + ? { ...build, cancelRequested: true, state: "cancelled" } + : { ...build, owner: body.owner, state: "claimed" }; + if (path.endsWith("/attach")) { + if (attachFailure) { + attachFailure = false; + return Effect.fail({ detail: "lost attach response" }); + } + build = { ...build, native: { taskId: body.taskId, childThreadId: body.childThreadId } }; + } + if (path.endsWith("/complete")) { + completed.push(body); + build = { ...build, state: body.state }; + } + return Effect.succeed({ body: build }); + }, + } as unknown as DeliveryService["Service"]; + const service = () => + make.pipe( + Effect.provideService(DeliveryService, delivery), + Effect.provideService(OrchestratorMcpService, orchestrator), + Effect.provideService(HostProcess.Platform, "win32"), + ); + return { + service, + delegateTask, + cancelTask, + admissions, + completed, + setNativeState: (state: string) => { + nativeState = state; + }, + requestCancel: () => { + build = { ...build, cancelRequested: true }; + }, + }; +} + +it.effect("rejects checkout mismatch before native admission", () => + Effect.gen(function* () { + const fixture = setup({ mismatch: true }); + const service = yield* fixture.service(); + const failure = yield* service.step(scope, { attemptId: "att-one" }).pipe(Effect.flip); + expect(failure.message).toMatch(/existing worktree/); + expect(fixture.delegateTask).not.toHaveBeenCalled(); + }), +); +it.effect( + "recovers uncertain dispatch with a stable native receipt across coordinator sessions", + () => + Effect.gen(function* () { + const fixture = setup({ attachFailure: true }); + const first = yield* fixture.service(); + const failure = yield* first.step(scope, { attemptId: "att-one" }).pipe(Effect.flip); + expect(failure.message).toMatch(/lost attach/); + const recovered = yield* fixture.service(); + const nextScope = { ...scope, requestNamespace: "session-two" }; + expect((yield* recovered.step(nextScope, { attemptId: "att-one" })).state).toBe("working"); + expect(fixture.admissions.size).toBe(1); + fixture.setNativeState("completed"); + expect((yield* recovered.step(nextScope, { attemptId: "att-one" })).state).toBe("completed"); + expect(fixture.completed).toEqual([ + expect.objectContaining({ + state: "completed", + text: '{"summary":"done"}', + taskId: "native-task", + }), + ]); + }), +); +it.effect( + "reconciles engine cancellation using native cancellation and native terminal state", + () => + Effect.gen(function* () { + const fixture = setup(); + const service = yield* fixture.service(); + yield* service.step(scope, { attemptId: "att-one" }); + fixture.requestCancel(); + expect((yield* service.step(scope, { attemptId: "att-one" })).state).toBe("cancelled"); + expect(fixture.cancelTask).toHaveBeenCalledOnce(); + expect(fixture.completed).toEqual([ + expect.objectContaining({ state: "cancelled", text: "" }), + ]); + }), +); + +it.effect("rejects an unsafe attempt id before relaying any native operation", () => + Effect.gen(function* () { + const fixture = setup(); + const service = yield* fixture.service(); + const failure = yield* service.step(scope, { attemptId: "../tasks" }).pipe(Effect.flip); + expect(failure.message).toMatch(/Invalid native attempt/); + expect(fixture.delegateTask).not.toHaveBeenCalled(); + }), +); +it.effect("pages the native transcript to obtain the authoritative final result", () => + Effect.gen(function* () { + const fixture = setup({ paged: true }); + const service = yield* fixture.service(); + yield* service.step(scope, { attemptId: "att-one" }); + fixture.setNativeState("completed"); + expect((yield* service.step(scope, { attemptId: "att-one" })).state).toBe("completed"); + expect(fixture.completed).toEqual([expect.objectContaining({ text: '{"summary":"done"}' })]); + }), +); + +it.effect("does not launch an uncertain claimed build after cancellation", () => + Effect.gen(function* () { + const fixture = setup({ claimedCancellation: true }); + const service = yield* fixture.service(); + const failure = yield* service.step(scope, { attemptId: "att-one" }).pipe(Effect.flip); + expect(failure.message).toMatch(/No new child will be launched/); + expect(fixture.delegateTask).not.toHaveBeenCalled(); + }), +); +it.effect("does not launch when cancellation wins between read and claim", () => + Effect.gen(function* () { + const fixture = setup({ cancelOnClaim: true }); + const service = yield* fixture.service(); + expect((yield* service.step(scope, { attemptId: "att-one" })).state).toBe("cancelled"); + expect(fixture.delegateTask).not.toHaveBeenCalled(); + }), +); diff --git a/apps/server/src/delivery/NativeBuildExecution.ts b/apps/server/src/delivery/NativeBuildExecution.ts new file mode 100644 index 0000000000000..506d428196ae7 --- /dev/null +++ b/apps/server/src/delivery/NativeBuildExecution.ts @@ -0,0 +1,225 @@ +import { + DELIVERY_HARNESS_BY_DRIVER, + DeliveryNativeBuildStepInput, + DeliveryNativeBuildStepResult, + NodeId, + OrchestratorMcpFailure, + ThreadId, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import type { McpInvocationScope } from "../mcp/McpInvocationContext.ts"; +import { OrchestratorMcpService } from "../mcp/OrchestratorMcpService.ts"; +import { DeliveryService, sameFolder } from "./DeliveryService.ts"; + +const NativeBuild = Schema.Struct({ + version: Schema.Literal(1), + id: Schema.String, + worktree: Schema.String, + harness: Schema.String, + model: Schema.NullOr(Schema.String), + prompt: Schema.String, + access: Schema.Literal("full"), + state: Schema.String, + owner: Schema.NullOr(Schema.String), + cancelRequested: Schema.Boolean, + native: Schema.NullOr(Schema.Struct({ taskId: Schema.String, childThreadId: Schema.String })), +}); + +const decodeNativeBuild = Schema.decodeUnknownEffect(NativeBuild); +const refused = (message: string) => + new OrchestratorMcpFailure({ code: "invalid_request", message }); + +export class NativeBuildExecution extends Context.Service< + NativeBuildExecution, + { + readonly step: ( + scope: McpInvocationScope, + input: typeof DeliveryNativeBuildStepInput.Type, + ) => Effect.Effect; + } +>()("t3/delivery/NativeBuildExecution") {} + +export const make = Effect.gen(function* () { + const platform = yield* HostProcess.Platform; + const delivery = yield* DeliveryService; + const orchestrator = yield* OrchestratorMcpService; + return NativeBuildExecution.of({ + step: Effect.fn("NativeBuildExecution.step")(function* (scope, input) { + if (!/^att-[A-Za-z0-9-]+$/.test(input.attemptId)) + return yield* refused("Invalid native attempt id."); + if (!scope.thread) + return yield* refused( + "Native builds require an active T3 coordinator thread attached to the build worktree.", + ); + const owner = `${scope.environmentId}:${scope.thread.threadId}`; + const endpoint = `/api/native-builds/${encodeURIComponent(input.attemptId)}`; + const recordOf = (body: unknown) => + decodeNativeBuild(body).pipe( + Effect.mapError(() => + refused("The engine did not return a compatible native build request."), + ), + ); + const read = delivery.read({ path: endpoint }).pipe( + Effect.mapError((error) => refused(error.detail)), + Effect.flatMap((response) => recordOf(response.body)), + ); + const post = (action: string, body: unknown) => + delivery.nativeBuildAct({ path: `${endpoint}/${action}`, body }).pipe( + Effect.mapError((error) => refused(error.detail)), + Effect.flatMap((response) => recordOf(response.body)), + ); + let build = yield* read; + if (build.id !== input.attemptId) + return yield* refused("The engine returned another attempt."); + if (build.owner !== null && build.owner !== owner) + return yield* refused("Another coordinator owns this native build."); + const parent = yield* orchestrator.readThread(scope, { + threadId: scope.thread.threadId, + limit: 1, + maxCharsPerItem: 1, + }); + if ( + !parent.thread.worktreePath || + !sameFolder(parent.thread.worktreePath, build.worktree, platform) + ) { + return yield* refused( + "Attach this coordinator to the engine build's existing worktree. Native builds cannot change or transfer authority to another checkout.", + ); + } + if (["completed", "failed", "cancelled", "interrupted"].includes(build.state)) + return { attemptId: build.id, state: build.state, taskId: build.native?.taskId ?? null }; + const checkCancelledAdmission = () => + build.cancelRequested && !build.native + ? Effect.fail( + refused( + "Cancellation arrived before a native receipt was recorded. No new child will be launched; reconcile the uncertain admission in this coordinator.", + ), + ) + : Effect.void; + yield* checkCancelledAdmission(); + // This namespace survives provider-session and engine restarts. Replaying an uncertain + // dispatch recovers its native receipt rather than admitting a second writable child. + const nativeScope = { ...scope, requestNamespace: `delivery-native:${owner}:${build.id}` }; + if (!build.native) { + const capabilities = yield* orchestrator.capabilities(scope); + const candidates = capabilities.providers.filter( + (provider) => + provider.canRunChildTask && + DELIVERY_HARNESS_BY_DRIVER[provider.driverKind] === build.harness, + ); + const provider = + candidates.find( + (provider) => provider.providerInstanceId === scope.thread?.providerInstanceId, + ) ?? (candidates.length === 1 ? candidates[0] : undefined); + if (!provider) + return yield* refused( + "The engine harness does not identify one enabled native provider instance.", + ); + const model = build.model; + if (!model) return yield* refused("A native build needs an explicit seat model."); + if ( + capabilities.runtimeMode !== "full-access" || + (provider.runtimeModes && !provider.runtimeModes.includes("full-access")) + ) + return yield* refused( + "Native builds require a full-access coordinator and provider; approval modes do not grant filesystem access.", + ); + build = yield* post("claim", { owner, worktree: build.worktree }); + if (["completed", "failed", "cancelled", "interrupted"].includes(build.state)) + return { attemptId: build.id, state: build.state, taskId: build.native?.taskId ?? null }; + yield* checkCancelledAdmission(); + if (!build.native) { + const task = yield* orchestrator.delegateTask(nativeScope, { + task: build.prompt, + title: `Team build ${build.id}`, + clientRequestId: "execute-v1", + isolation: "shared", + mode: "async", + runtimeMode: "full-access", + target: { providerInstanceId: provider.providerInstanceId, model, options: [] }, + }); + if (!task.childThreadId) + return yield* refused( + "Native admission returned no child thread. Retry this attempt to recover the receipt.", + ); + build = yield* post("attach", { + owner, + taskId: task.taskId, + childThreadId: task.childThreadId, + }); + } + } + if (!build.native) return yield* refused("The native task receipt is missing."); + const taskId = NodeId.make(build.native.taskId); + if (build.cancelRequested) + yield* orchestrator.cancelTask(nativeScope, { + taskId, + clientRequestId: "cancel-v1", + reason: "The delivery engine requested cancellation.", + }); + const status = yield* orchestrator.taskStatus(nativeScope, taskId); + if ( + status.workState !== "result_available" || + status.hasPendingChildRuns || + !status.latestTerminalStatus + ) { + return { + attemptId: build.id, + state: build.cancelRequested ? "cancelling" : "working", + taskId, + next: "Completion wakes this thread, so end your turn. Call delivery_native_build_step with this attempt id after the notification; do not poll it.", + }; + } + let text = ""; + if (status.latestTerminalStatus === "completed") { + let afterPosition: number | undefined; + let answer: + | Awaited>>["items"][number] + | undefined; + do { + const transcript = yield* orchestrator.readThread(nativeScope, { + threadId: ThreadId.make(build.native.childThreadId), + limit: 100, + maxCharsPerItem: 50000, + ...(afterPosition === undefined ? {} : { afterPosition }), + }); + answer = + transcript.items.findLast( + (item) => + item.runId === status.latestTerminalRunId && item.type === "assistant_message", + ) ?? answer; + if (!transcript.hasMore) break; + if ( + transcript.nextPosition === null || + (afterPosition !== undefined && transcript.nextPosition <= afterPosition) + ) + return yield* refused("The native transcript did not advance while paging."); + afterPosition = transcript.nextPosition; + } while (true); + if (!answer || answer.textTruncated || answer.text === null) + return yield* refused( + "The native final answer is not available in full. The attempt remains held for reconciliation.", + ); + text = answer.text; + } + build = yield* post("complete", { + owner, + taskId, + state: status.latestTerminalStatus, + text, + reason: + status.latestTerminalStatus === "completed" + ? null + : `Native task ended ${status.latestTerminalStatus}.`, + }); + return { attemptId: build.id, state: build.state, taskId }; + }), + }); +}); + +export const layer = Layer.effect(NativeBuildExecution, make); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 10a6f3c30c602..52f3740a0aebe 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1,5 +1,6 @@ import * as NodeURL from "node:url"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as DeliveryService from "../delivery/DeliveryService.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { @@ -55,6 +56,7 @@ import * as PeerLinkRequests from "../peer/PeerLinkRequests.ts"; import * as PeerLinks from "../peer/PeerLinks.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(DeliveryService.layerTest), Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), ); diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts index b8e3b3d2cadb1..4401414959322 100644 --- a/apps/server/src/mcp/linkOrigin.test.ts +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -1,3 +1,4 @@ +import * as DeliveryService from "../delivery/DeliveryService.ts"; import * as DocumentText from "../provider/DocumentText.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -114,7 +115,7 @@ const layerLaunches = Layer.effect( ); const layerTools = Layer.mergeAll( - McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerOrchestratorToolkit.pipe(Layer.provide(DeliveryService.layerTest)), McpHttpServer.layerThreadToolkit, McpHttpServer.layerProjectRegistration, ).pipe( diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 602031290763d..2c4d90cf26416 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1,5 +1,6 @@ import * as AssetSigner from "../../assets/AssetSigner.ts"; import * as DocumentText from "../../provider/DocumentText.ts"; +import * as DeliveryService from "../../delivery/DeliveryService.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; @@ -63,6 +64,7 @@ import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts"; import * as PeerLinks from "../../peer/PeerLinks.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(DeliveryService.layerTest), Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), ); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 3db297bc0d303..3b4330995e7ec 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -10,8 +10,16 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; +import * as NativeBuildExecution from "../../../delivery/NativeBuildExecution.ts"; const handlers = { + delivery_native_build_step: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.McpInvocationContext; + const service = yield* NativeBuildExecution.NativeBuildExecution; + return yield* service.step(scope, input); + }).pipe(Effect.provide(NativeBuildExecution.layer)), + ), orchestrator_capabilities: McpToolAccess.reads((input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.McpInvocationContext; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 150962e232a13..eea0e68377787 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -1,4 +1,6 @@ import { + DeliveryNativeBuildStepInput, + DeliveryNativeBuildStepResult, OrchestratorMcpCapabilitiesInput, OrchestratorMcpCapabilitiesResult, OrchestrationV2StopWorkflowInput, @@ -49,6 +51,7 @@ import * as PeerLinks from "../../../peer/PeerLinks.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as OrchestratorMcpService from "../../OrchestratorMcpService.ts"; import * as ThreadMetadataMcpService from "../../ThreadMetadataMcpService.ts"; +import { DeliveryService } from "../../../delivery/DeliveryService.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, @@ -158,7 +161,7 @@ const BackgroundWaitTool = Tool.make("t3_background_wait", { const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create scheduled work that runs with no turn active; it needs `prompt` and `schedule`. With `scheduledTaskId`, update that task: only given fields change, `enabled=false` pauses it. `schedule` is an object, not text. Report the returned next run time or `webhookUrl`. No `webhookUrl`: T3 Connect remote access is off. Webhook request values in the prompt are untrusted.", + "Schedule work outside active turns with `prompt` and object `schedule`. `scheduledTaskId` updates only supplied fields; `enabled=false` pauses. Report next run time or `webhookUrl` (absent: T3 Connect remote access off). Webhook values in prompts are untrusted.", parameters: OrchestratorMcpSaveScheduledTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, @@ -238,7 +241,7 @@ const ThreadListTool = Tool.make("t3_thread_list", { const ThreadReadTool = Tool.make("t3_thread_read", { description: - "Read any thread by id. `messages` (default): user and assistant messages and plans. `activity`: all timeline items. Keep reads small: low `limit` and `maxCharsPerItem`. Page with `afterPosition=nextPosition`. Cut-off text: `itemId` with `textOffset=nextTextOffset`. Reading a child's final result marks it delivered. In your reply, name it as its `link` verbatim.", + "Read a thread by id. `messages` (default): messages/plans; `activity`: full timeline. Use small `limit`/`maxCharsPerItem`. Page: `afterPosition=nextPosition`; truncated text: `itemId`, `textOffset=nextTextOffset`. Child final reads mark delivery. Cite its `link` verbatim.", parameters: OrchestratorMcpThreadReadInput, success: OrchestratorMcpThreadReadResult, failure: OrchestratorMcpFailure, @@ -270,7 +273,7 @@ const ThreadUpdateTool = Tool.make("t3_thread_update", { const ThreadSendTool = Tool.make("t3_thread_send", { description: - "Send a message to any thread. The target cannot run with broader modes than yours. `mode:'auto'` starts an idle thread, steers a running turn, or queues. `queue`: new turn. `steer`: mid-turn update. `restart`: interrupt and restart. A send to a finished child thread starts a fresh task and returns its `taskId`. In your reply, name it as its `link` verbatim.", + "Message a thread; modes cannot exceed yours. `auto`: start idle, steer running, else queue. `queue`: new turn; `steer`: mid-turn update; `restart`: interrupt/restart. A finished child starts a fresh task with a new `taskId`. Cite the returned `link` verbatim.", parameters: OrchestratorMcpThreadSendInput, success: OrchestratorMcpThreadSendResult, failure: OrchestratorMcpFailure, @@ -323,7 +326,7 @@ const EnvironmentLinksTool = Tool.make("t3_environment_links", { const EnvironmentLinkTool = Tool.make("t3_environment_link", { description: - "Ask the user to link another machine, in a card. For a machine the user names that is not linked. Pass `hint` from their words, `environmentId` when one names it (already linked: returns `linked`), `url` only when the user gave an address. Returns `pending`; the outcome arrives as a message in this thread. Do not wait, poll, or call it again to check. Never ask for a pairing code in chat.", + "Link a user-named machine via a card. Pass their words as `hint`, a known `environmentId`, or their supplied `url`. Already linked returns `linked`; otherwise `pending`, then a message. Never wait, poll, repeat to check, or ask for pairing codes in chat.", parameters: OrchestratorMcpEnvironmentLinkInput, success: OrchestratorMcpEnvironmentLinkResult, failure: OrchestratorMcpFailure, @@ -348,8 +351,20 @@ const EnvironmentUnlinkTool = Tool.make("t3_environment_unlink", { .annotate(Tool.Destructive, true) .annotate(Tool.Idempotent, true) .annotate(McpSchema.EnabledWhen, McpInvocationContext.hiddenWhileLinksOff); +const NativeBuildStepTool = Tool.make("delivery_native_build_step", { + description: + "Step an opted-in build from its exact engine worktree. Retries reuse one native child; the server reads its result. Step after completion or engine cancellation. Read-only flows stay legacy. No caller-supplied results.", + parameters: DeliveryNativeBuildStepInput, + success: DeliveryNativeBuildStepResult, + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies: [...dependencies, DeliveryService], +}) + .annotate(Tool.Title, "Advance a native team build") + .annotate(Tool.Destructive, true); export const OrchestratorToolkit = Toolkit.make( + NativeBuildStepTool, OrchestratorCapabilitiesTool, DelegateTaskTool, TaskStatusTool, diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 4e15b02d04e53..9973252767ed3 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -1,3 +1,4 @@ +import * as DeliveryService from "../delivery/DeliveryService.ts"; import * as DocumentText from "../provider/DocumentText.ts"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -205,7 +206,7 @@ const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => servePeer( box, Layer.mergeAll( - McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerOrchestratorToolkit.pipe(Layer.provide(DeliveryService.layerTest)), McpHttpServer.layerProjectRegistration, McpHttpServer.layerThreadToolkit, ).pipe( @@ -260,7 +261,7 @@ const mcpClient = McpSchema.McpServerClient.of({ const makeLaptop = Effect.gen(function* () { const linking = yield* layerLinkingEnvironment(laptop).pipe(Layer.build); const layerHere = Layer.mergeAll( - McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerOrchestratorToolkit.pipe(Layer.provide(DeliveryService.layerTest)), McpHttpServer.layerProjectRegistration, McpHttpServer.layerThreadToolkit, ).pipe( diff --git a/apps/server/src/peer/PeerLinkRequests.test.ts b/apps/server/src/peer/PeerLinkRequests.test.ts index c3ca78cbcb6a7..0561ec95dbe28 100644 --- a/apps/server/src/peer/PeerLinkRequests.test.ts +++ b/apps/server/src/peer/PeerLinkRequests.test.ts @@ -1,3 +1,4 @@ +import * as DeliveryService from "../delivery/DeliveryService.ts"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -152,6 +153,7 @@ const makeLaptop = Effect.gen(function* () { Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), ); const layerHere = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(DeliveryService.layerTest), Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge(PeerLinkRequests.layer), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 00b7b5d6f6963..5ddfe4453fccf 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -1,3 +1,4 @@ +import * as DeliveryService from "../delivery/DeliveryService.ts"; import * as DocumentText from "../provider/DocumentText.ts"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -462,7 +463,7 @@ const boxToolkitLayer = (thread: BoxThread) => { ), }); return Layer.merge( - McpHttpServer.layerOrchestratorToolkit, + McpHttpServer.layerOrchestratorToolkit.pipe(Layer.provide(DeliveryService.layerTest)), McpHttpServer.layerProjectRegistration, ).pipe( Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))), @@ -565,6 +566,7 @@ const makeLaptop = ( Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())), ); const layerHere = McpHttpServer.layerOrchestratorToolkit.pipe( + Layer.provide(DeliveryService.layerTest), Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge( RemoteDelegation.layer.pipe( diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 8cdd52d202f12..2b4378736534f 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -1,3 +1,4 @@ +import * as DeliveryService from "../delivery/DeliveryService.ts"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; @@ -48,6 +49,7 @@ import budget from "./agentContextBudget.json" with { type: "json" }; // Registration only stores tool definitions, so the handlers' services are never called. const toolHandlerServices = Layer.mergeAll( + DeliveryService.layerTest, Layer.mock(AssetSigner)({}), Layer.mock(DeviceService)({}), Layer.mock(DocumentText)({}), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index bb586d5b9f4b4..a5d4ed4092d50 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5133, "codexAdditionalContextBytes": 6863, - "mcpToolListJsonBytes": 47498 + "mcpToolListJsonBytes": 47437 } diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts index d24ddb727c7bc..01c41c7b5b0b2 100644 --- a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -29,10 +29,9 @@ export function useProviderWorkspaceSnapshotRefresh(input: { readonly instanceId: ProviderInstanceId | undefined; }): { readonly refresh: () => void; readonly scanning: boolean } { const { environmentId, gitCwd, instanceId } = input; - const atom = - gitCwd && instanceId - ? providerWorkspaceSnapshotRefresh({ environmentId, instanceId, cwd: gitCwd }) - : idleRefreshAtom; + const atom = instanceId + ? providerWorkspaceSnapshotRefresh({ environmentId, instanceId, cwd: gitCwd }) + : idleRefreshAtom; return { refresh: useAtomRefresh(atom), scanning: isProviderWorkspaceSnapshotScanning(useAtomValue(atom)), diff --git a/evals/mcp-tool-use/cases/native-builds.ndjson b/evals/mcp-tool-use/cases/native-builds.ndjson new file mode 100644 index 0000000000000..92d66f37893ba --- /dev/null +++ b/evals/mcp-tool-use/cases/native-builds.ndjson @@ -0,0 +1 @@ +{"id":"native-build-reconciles-existing-attempt","area":"orchestration","kind":"guard","pattern":"reconcile the engine attempt rather than launch another writer","experience":"The native team-build migration must keep one native child across engine and coordinator retries. A completed child must be reconciled by the server, not replaced by another delegated task or a caller-authored success receipt.","rule":"Use the native team-build coordinator tool for an existing engine attempt; let the server obtain its native terminal state.","privacy":"commit-safe","setup":{"role":"top","mocks":{"delivery_native_build_step":{"attemptId":"att-example","state":"completed","taskId":"native-task"}},"priorTurns":[{"role":"user","text":"This coordinator is attached to the prepared worktree for team build att-example. The engine has opted this build into native execution; keep its existing child and approval gates."},{"role":"assistant","text":"The build is running as the engine's native attempt att-example, backed by native-task. I will reconcile that attempt after completion."}]},"prompt":"The native child has completed. Reconcile team build att-example now so the engine can apply its existing review gates. Do not start another build or invent a completion result.","expect":{"calls":[{"tool":"delivery_native_build_step","args":{"attemptId":"att-example"}}],"notCalls":["delegate_task","create_threads","t3_thread_launch","task_cancel"],"maxCalls":{"delivery_native_build_step":1}}} diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index bd5729656c841..dbbc29704117e 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -162,7 +162,11 @@ describe("provider workspace snapshot refresh atom", () => { | null | Promise | null>; - function setup(initialProviders: ReadonlyArray, initialResponses?: Response[]) { + function setup( + initialProviders: ReadonlyArray, + initialResponses?: Response[], + targetCwd: string | null = cwd, + ) { const providersAtom = Atom.make | null>(initialProviders); const responses: Response[] = [...(initialResponses ?? [])]; const run = vi.fn(async () => { @@ -178,11 +182,61 @@ describe("provider workspace snapshot refresh atom", () => { refreshProviders: { label: "refresh", run }, }); const registry = AtomRegistry.make(); - const atom = family({ environmentId, instanceId: provider.instanceId, cwd }); + const atom = family({ environmentId, instanceId: provider.instanceId, cwd: targetCwd }); const unmount = registry.mount(atom); return { registry, atom, family, providersAtom, responses, run, unmount }; } + it("refreshes global skills without sending a cwd or reusing a project scope", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, family, run, unmount } = setup(noSnapshot, [() => noSnapshot], null); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenLastCalledWith(expect.anything(), { + environmentId, + input: { instanceId: provider.instanceId }, + }); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + const project = family({ environmentId, instanceId: provider.instanceId, cwd }); + const unmountProject = registry.mount(project); + expect(run).toHaveBeenCalledTimes(2); + expect(run).toHaveBeenLastCalledWith(expect.anything(), { + environmentId, + input: { instanceId: provider.instanceId, cwd }, + }); + unmountProject(); + unmount(); + }); + + it.each(["error", "disabled"] as const)( + "retries global %s probes with a bounded cooldown", + async (status) => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const failed = [{ ...provider, status, workspaceSnapshots: [] }]; + const { registry, atom, run, unmount } = setup( + failed, + [() => failed, () => failed, () => failed], + null, + ); + await vi.advanceTimersByTimeAsync(0); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(10_000); + registry.refresh(atom); + expect(run).toHaveBeenCalledTimes(3); + unmount(); + }, + ); + it("scans a missing snapshot once and skips a repeat inside the TTL", async () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); const { registry, atom, family, responses, run, unmount } = setup(noSnapshot); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 2598c895c5053..9064ec2605a0c 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -167,14 +167,17 @@ const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRef function planProviderWorkspaceSnapshotRefresh( previous: ProviderWorkspaceSnapshotRefreshState, provider: ServerProvider | null | undefined, - cwd: string, + cwd: string | null, now: number, ): { readonly state: ProviderWorkspaceSnapshotRefreshState; readonly refresh: boolean; readonly recheckAt: number | null; } { - const hasSnapshot = hasCompleteProviderWorkspaceSnapshot(provider, cwd); + const hasSnapshot = + cwd === null + ? provider?.status === "ready" || provider?.status === "warning" + : hasCompleteProviderWorkspaceSnapshot(provider, cwd); const pending = (provider && resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommandsPending) === true; // Losing a snapshot (the server dropped it) lifts both the TTL and the retry @@ -248,8 +251,11 @@ type WorkspaceSnapshotStatus = "absent" | "pending" | "complete"; function resolveWorkspaceSnapshotStatus( provider: ServerProvider | undefined, - cwd: string, + cwd: string | null, ): WorkspaceSnapshotStatus { + if (cwd === null) { + return provider?.status === "ready" || provider?.status === "warning" ? "complete" : "absent"; + } const snapshot = provider && resolveProviderWorkspaceSnapshot(provider, cwd); if (!snapshot) return "absent"; return snapshot.slashCommandsPending ? "pending" : "complete"; @@ -258,7 +264,7 @@ function resolveWorkspaceSnapshotStatus( interface ProviderWorkspaceSnapshotRefreshTarget { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; - readonly cwd: string; + readonly cwd: string | null; } /** @@ -274,7 +280,7 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { readonly refreshProviders: AtomCommand< { readonly environmentId: EnvironmentId; - readonly input: { readonly instanceId: ProviderInstanceId; readonly cwd: string }; + readonly input: { readonly instanceId: ProviderInstanceId; readonly cwd?: string }; }, { readonly providers: ReadonlyArray }, unknown @@ -283,7 +289,7 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { // Plain-object keys compare structurally (Effect `Equal`/`Hash`). const family = Atom.family((target: ProviderWorkspaceSnapshotRefreshTarget) => { const { environmentId, instanceId, cwd } = target; - const key = `${environmentId}:${instanceId}:${cwd}`; + const key = JSON.stringify([environmentId, instanceId, cwd]); const findProvider = (providers: ReadonlyArray | null) => providers?.find((provider) => provider.instanceId === instanceId); const statusAtom = Atom.make((get) => @@ -325,7 +331,7 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { void runAtomCommand( registry, input.refreshProviders, - { environmentId, input: { instanceId, cwd } }, + { environmentId, input: { instanceId, ...(cwd === null ? {} : { cwd }) } }, { reportFailure: false }, ).then((result) => { const refreshedProvider = @@ -335,11 +341,14 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { // A new server timestamp proves progress even when the client clock is ahead. // @effect-diagnostics-next-line globalDate:off const completedAt = Date.now(); + // Global discovery has no workspace timestamp in the wire contract. const refreshed = - hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && - (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || - snapshot?.checkedAt !== - (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); + cwd === null + ? refreshedProvider?.status === "ready" || refreshedProvider?.status === "warning" + : hasCompleteProviderWorkspaceSnapshot(refreshedProvider, cwd) && + (hasCurrentProviderWorkspaceSnapshot(refreshedProvider, cwd, completedAt) || + snapshot?.checkedAt !== + (provider && resolveProviderWorkspaceSnapshot(provider, cwd))?.checkedAt); if (!refreshed) retryLater(); }, retryLater); return plan.state; diff --git a/packages/contracts/src/delivery.ts b/packages/contracts/src/delivery.ts index 7cb643b7d26e0..b3a3291b2269a 100644 --- a/packages/contracts/src/delivery.ts +++ b/packages/contracts/src/delivery.ts @@ -71,6 +71,16 @@ export const DeliveryResponse = Schema.Struct({ }); export type DeliveryResponse = typeof DeliveryResponse.Type; +export const DeliveryNativeBuildStepInput = Schema.Struct({ + attemptId: TrimmedNonEmptyString.check(Schema.isPattern(/^att-[A-Za-z0-9-]+$/)), +}); +export const DeliveryNativeBuildStepResult = Schema.Struct({ + attemptId: Schema.String, + state: Schema.String, + taskId: Schema.NullOr(Schema.String), + next: Schema.optional(Schema.String), +}); + /** * What a thread was started with. Pinned at bind time: a thread keeps its * team, role and configuration revision even when the team is edited later.