Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
# open: if the diff cannot be resolved, everything runs.
changes:
name: Changes
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
typecheck: ${{ steps.detect.outputs.typecheck }}
Expand Down Expand Up @@ -63,7 +63,7 @@ jobs:
contents: read
id-token: write
name: Lint
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -119,12 +119,13 @@ jobs:
name: Build and Typecheck
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.build != 'false' || needs.changes.outputs.typecheck != 'false') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 2
sparse-checkout: |
/*
!/.repos/
Expand All @@ -138,7 +139,7 @@ jobs:
- name: Start installing browser secret helper build libraries
if: needs.changes.outputs.build != 'false'
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
sudo sed -i 's|http://|https://|g' /etc/apt/sources.list.d/ubuntu.sources
(
set +e
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
Expand Down Expand Up @@ -200,6 +201,17 @@ jobs:
cat "$RUNNER_TEMP/typecheck.log"
exit "$(cat "$RUNNER_TEMP/typecheck-status")"

# Changed browser tests run here too, before the Test Web job's own install.
- name: Install Chromium for changed browser tests
if: ${{ github.event_name == 'pull_request' }}
run: node apps/web/node_modules/playwright/cli.js install --with-deps --only-shell chromium

- name: Check changed files on Linux
if: ${{ github.event_name == 'pull_request' }}
env:
CHECK_BASE: ${{ github.event.pull_request.base.sha }}
run: vp run check:changed "$CHECK_BASE"

# Everything except `t3` (apps/server) and the web app, which has its own job
# below. `--parallel` drops the package dependency ordering that `vp run`
# applies by default: these `test` tasks declare no `dependsOn` and resolve
Expand All @@ -213,7 +225,7 @@ jobs:
name: Test
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.test != 'false' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand All @@ -231,7 +243,7 @@ jobs:
# the background while Vite+ installs; the step before the tests waits for it.
- name: Start installing browser secret helper build libraries
run: |
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
sudo sed -i 's|http://|https://|g' /etc/apt/sources.list.d/ubuntu.sources
(
set +e
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
Expand Down Expand Up @@ -289,7 +301,7 @@ jobs:
name: Test Web
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -353,7 +365,7 @@ jobs:
contents: read
id-token: write
name: Test Server ${{ matrix.shard }}
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
# Shards take 2-5 minutes. When the shared task cache throttles reads, each
# failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes.
timeout-minutes: 20
Expand Down Expand Up @@ -469,7 +481,7 @@ jobs:
name: Rust
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.rust != 'false' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -502,7 +514,7 @@ jobs:
name: Mobile Native Static Analysis
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }}
runs-on: blacksmith-6vcpu-macos-26
runs-on: macos-26
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -535,7 +547,7 @@ jobs:
name: Release Smoke
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.release_smoke != 'false' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -575,7 +587,7 @@ jobs:
mobile_native_static_analysis,
release_smoke,
]
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Require every job to pass
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mobile-fingerprint-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ concurrency:
jobs:
fingerprint:
name: Native fingerprint diff
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubuntu-24.04
permissions:
contents: read
env:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Permission modes](./docs/user/permission-modes.md)
- [Keyboard shortcuts](./docs/user/keybindings.md)
- [Project settings](./docs/user/project-settings.md)
- [Tasks](./docs/user/tasks.md)
- [Appearance preferences](./docs/user/appearance.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md)
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
it.effect("publishes proven install ownership only for manually updated servers", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const platform = yield* HostProcess.Platform;
const baseDir = yield* fs.makeTempDirectoryScoped();
const prefix = `${baseDir}/node`;
const entry = `${prefix}/lib/node_modules/t3/dist/bin.mjs`;
Expand All @@ -87,7 +88,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
`${prefix}/lib/node_modules/t3/package.json`,
'{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
);
yield* fs.symlink(entry, `${prefix}/bin/t3`);
if (platform !== "win32") yield* fs.symlink(entry, `${prefix}/bin/t3`);
const config = yield* makeServerConfig(baseDir);
yield* fs.makeDirectory(config.stateDir, { recursive: true });
for (const mode of ["web", "desktop"] as const) {
Expand All @@ -103,11 +104,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
),
Effect.provideService(HostProcess.Arguments, ["node", entry]),
Effect.provideService(HostProcess.IsExecutable, false),
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(HostProcess.Platform, platform),
Effect.provideService(HostProcess.Environment, {}),
);
expect(descriptor.capabilities.serverInstallation).toEqual(
mode === "web" ? { kind: "npm-global", prefix } : undefined,
mode === "web" && platform !== "win32" ? { kind: "npm-global", prefix } : undefined,
);
expect(descriptor.capabilities.serverSelfUpdate).toBe(
mode === "web" ? undefined : "desktop-managed",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export const make = Effect.gen(function* () {
orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION,
...(databaseMigration === undefined ? {} : { databaseMigration }),
capabilities: {
taskDocuments: true,
repositoryIdentity: true,
connectionProbe: true,
attachmentUploads: true,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/McpToolAccess.testkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const idleThreadProjection = (
...thread
} = shell;
return {
thread: { ...thread, lastVisitedAt: lastVisitedAt ?? null },
thread: { ...thread, taskInitialization: null, lastVisitedAt: lastVisitedAt ?? null },
runs: [],
attempts: [],
nodes: [],
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
taskThreadInitializationSummary,
CommandId,
type RunId,
isProviderAvailable,
Expand Down Expand Up @@ -806,6 +807,8 @@ function threadDetail(
interactionMode: projection.thread.interactionMode,
linkedPullRequest: projection.thread.linkedPullRequest ?? null,
pullRequests: threadPullRequests(projection.thread.pullRequests),
taskDocumentId: projection.thread.taskDocumentId ?? null,
taskInitialization: taskThreadInitializationSummary(projection.thread.taskInitialization),
titleRegeneration:
projection.thread.titleRegeneration === undefined ||
projection.thread.titleRegeneration === null
Expand Down
42 changes: 40 additions & 2 deletions apps/server/src/mcp/toolkits/project/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,42 @@ const access = Effect.gen(function* () {
yield* readCaller();
return yield* Project.ProjectService;
});
// File-system causes stay out of the message; authored reasons are safe to show.
const taskLaunchFailure = (error: ThreadLaunch.ThreadLaunchError) =>
error.operation === "resolve-task"
? new OrchestratorMcpFailure({
code: "invalid_request",
message:
typeof error.cause === "string"
? error.cause
: "The task document or update instructions could not be read.",
})
: unavailable();

export const layer = McpToolAccess.toLayer(ProjectToolkit, {
task_thread_initialization_result: McpToolAccess.writesThreads(
() => [],
(input) =>
Effect.gen(function* () {
const scope = yield* McpInvocationContext.McpInvocationContext;
const { caller } = yield* readCaller();
if (scope.thread === undefined || scope.thread.subagent === true || caller === undefined)
return yield* new OrchestratorMcpFailure({
code: "invalid_request",
message: "Only the initializing thread can report its context outcome.",
});
const launches = yield* ThreadLaunch.ThreadLaunchService;
yield* launches
.reportTaskInitialization({
threadId: caller.id,
projectId: caller.projectId,
providerInstanceId: scope.thread.providerInstanceId,
outcome: input.outcome,
})
.pipe(Effect.mapError(taskLaunchFailure));
return { recorded: true };
}),
),
t3_thread_launch: McpToolAccess.startsThreads(
(input) => input,
(input, { runtimeMode, interactionMode, linkOrigin }) =>
Expand Down Expand Up @@ -209,6 +244,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
)
: runtimeMode;
const result = yield* ThreadMessageIntake.launchThread({
...(input.task === undefined ? {} : { task: input.task }),
commandId,
threadId,
projectId,
Expand Down Expand Up @@ -246,11 +282,13 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
message: error.message,
});
}
return unavailable();
return error._tag === "ThreadLaunchError" ? taskLaunchFailure(error) : unavailable();
}),
);
const thread = result.projection.thread;
const run = result.projection.runs.find((run) => run.userMessageId === messageId);
const run = result.projection.runs.find(
(run) => run.userMessageId === (thread.taskInitialization?.messageId ?? messageId),
);
return {
threadId: thread.id,
link: McpInvocationContext.threadLinkFor(scope, thread.id),
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/mcp/toolkits/project/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
OrchestrationV2DelegatedFrom,
OrchestrationV2RunStatus,
OrchestrationV2ThreadLaunchWorkspaceStrategy,
TaskThreadLaunchOptions,
RuntimeMode,
ProviderInteractionMode,
Project,
Expand All @@ -28,6 +29,7 @@ import * as ServerSettings from "../../../serverSettings.ts";
import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
import * as Crypto from "effect/Crypto";
import * as Schema from "effect/Schema";
import * as Struct from "effect/Struct";
import { Tool, Toolkit } from "effect/ai";
import * as ProjectService from "../../../project/ProjectService.ts";
import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts";
Expand Down Expand Up @@ -123,6 +125,13 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
description:
"Create one new top-level thread, bound to its workspace before the agent starts. Task in `message`. Omit `projectId`, model and modes to inherit yours (outside a thread, pass `projectId` or `scratch`); modes cannot exceed yours. After an error or lost reply, retry with the same `clientRequestId`. Follow it with `t3_thread_read` or `t3_thread_wait`. Mention it with its `link`.",
parameters: Schema.Struct({
task: Schema.optional(
// Only a task's creator asks for enrichment; agents launch threads on tasks that exist.
TaskThreadLaunchOptions.mapFields(Struct.omit(["enrichment"])).annotate({
description:
"Optional registered task document and update-discipline path on the destination environment. Runs visible context initialization first. Omit message/attachments; send work after launch. No filesystem permission is added.",
}),
),
environmentId: OrchestratorMcpEnvironmentTarget,
projectId: Schema.optional(ProjectId),
scratch: Schema.optional(
Expand Down Expand Up @@ -174,7 +183,17 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
.annotate(Tool.Destructive, true)
.annotate(Tool.OpenWorld, true);

const TaskInitializationResultTool = Tool.make("task_thread_initialization_result", {
...shared,
description:
"Report this task thread's initialization outcome after reading its required context. Use ready only when the task, relevant parents, update discipline and workspace instructions are readable and the selected workspace matches. Otherwise report failed and explain why. This reports context readiness; it does not start user work or change permissions.",
parameters: Schema.Struct({ outcome: Schema.Literals(["ready", "failed"]) }),
success: Schema.Struct({ recorded: Schema.Boolean }),
dependencies: [...shared.dependencies, ThreadLaunchService.ThreadLaunchService],
}).annotate(Tool.Destructive, true);

export const ProjectToolkit = Toolkit.make(
TaskInitializationResultTool,
ThreadLaunchTool,
ProjectListTool,
ProjectReadTool,
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];

// Trace queries and dashboards depend on these labels, including deviations from method prefixes.
const RPC_AGGREGATES = {
[WS_METHODS.taskDocumentsRegister]: "taskDocuments",
[WS_METHODS.taskDocumentsCreate]: "taskDocuments",
[WS_METHODS.taskDocumentsDefaultFolder]: "taskDocuments",
[WS_METHODS.taskDocumentsOpen]: "taskDocuments",
[WS_METHODS.taskDocumentsList]: "taskDocuments",
[WS_METHODS.taskDocumentsSave]: "taskDocuments",
[WS_METHODS.taskDocumentsRelink]: "taskDocuments",
[WS_METHODS.taskDocumentsUnregister]: "taskDocuments",
[WS_METHODS.taskDocumentsListRecovery]: "taskDocuments",
[WS_METHODS.taskDocumentsReadRecovery]: "taskDocuments",
[WS_METHODS.taskDocumentsSubscribeCatalogue]: "taskDocuments",
[WS_METHODS.taskDocumentsSubscribeDocument]: "taskDocuments",

[ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",
Expand Down
Loading
Loading