Skip to content
Merged
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
18 changes: 15 additions & 3 deletions apps/coder-gateway/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1511,7 +1511,13 @@ export function makeLocalCoderGateway(
return deferred.promise;
};

const startConfiguredPortForwards = async (): Promise<void> => {
/**
* Starts the saved rules. A config save passes `retryFailed: false`: a rule that failed stays
* stopped until it is restarted or changed (a changed rule's lifecycle is recreated as idle).
*/
const startConfiguredPortForwards = async (options?: {
readonly retryFailed?: boolean;
}): Promise<void> => {
if ((profileConfig.portForwards?.length ?? 0) === 0) return;
const workspacesByDeployment = new Map<
string,
Expand All @@ -1538,6 +1544,12 @@ export function makeLocalCoderGateway(
);
await Promise.allSettled(
(profileConfig.portForwards ?? []).map(async (portForward) => {
if (
options?.retryFailed === false &&
SynchronizedRef.getUnsafe(portForwardLifecycle(portForward.id))._tag === "Failed"
) {
return;
}
const workspace = profileConfig.workspaces.find(
(entry) => entry.id === portForward.workspaceId,
);
Expand Down Expand Up @@ -1909,7 +1921,7 @@ export function makeLocalCoderGateway(
for (const portForwardId of stalePortForwardIds) {
portForwardLifecycles.delete(portForwardId);
}
await startConfiguredPortForwards();
await startConfiguredPortForwards({ retryFailed: false });
return profileConfig;
});
sendText(response, 200, "application/json; charset=utf-8", JSON.stringify(savedConfig));
Expand Down Expand Up @@ -2501,7 +2513,7 @@ export function makeLocalCoderGateway(
yield* Effect.promise(() => saveGatewayPort(options.portPath!, address.port));
}

yield* Effect.promise(startConfiguredPortForwards);
yield* Effect.promise(() => startConfiguredPortForwards());

return { url: `http://${CODER_GATEWAY_HOST}:${address.port}` };
});
Expand Down
37 changes: 27 additions & 10 deletions apps/server/src/mcp/bridge/T3ToolBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export const makeBindingWith = (options: { readonly inlineBudgetMs?: number } =
const crypto = yield* Crypto.Crypto;
const jobScope = yield* Effect.scope;
const jobs = new Map<string, BridgeJob>();
// Calls still inside their inline budget, per thread; they count toward the cap before they
// become jobs, so concurrent calls cannot all pass the check.
const inlineCalls = new Map<string, number>();

const run = (
scope: McpInvocationContext.McpInvocationScope,
Expand Down Expand Up @@ -235,10 +238,12 @@ export const makeBindingWith = (options: { readonly inlineBudgetMs?: number } =
yield* Fiber.interrupt(job.fiber);
}
}
// Refuse before the tool starts, so a refused call never half-applies.
let running = 0;
// Refuse before the tool starts, so a refused call never half-applies. The count and the
// reservation below run without yielding, so concurrent calls see each other.
const threadId = scope.thread.threadId;
let running = inlineCalls.get(threadId) ?? 0;
for (const job of jobs.values()) {
if (job.threadId === scope.thread.threadId && job.fiber.pollUnsafe() === undefined) {
if (job.threadId === threadId && job.fiber.pollUnsafe() === undefined) {
running++;
}
}
Expand All @@ -248,13 +253,25 @@ export const makeBindingWith = (options: { readonly inlineBudgetMs?: number } =
"Too many T3 tool calls are still running. Read their results with task_status first.",
);
}
const fiber = yield* run(scope, request).pipe(Effect.forkIn(jobScope));
const exit = yield* Fiber.await(fiber).pipe(
Effect.timeoutOption(options.inlineBudgetMs ?? INLINE_BUDGET_MS),
);
if (Option.isSome(exit)) return yield* exit.value;
const taskId = `${BRIDGE_JOB_PREFIX}${yield* crypto.randomUUIDv4().pipe(Effect.orDie)}`;
jobs.set(taskId, { threadId: scope.thread.threadId, startedAt: now, fiber });
inlineCalls.set(threadId, (inlineCalls.get(threadId) ?? 0) + 1);
const releaseInline = Effect.sync(() => {
const remaining = (inlineCalls.get(threadId) ?? 1) - 1;
if (remaining > 0) inlineCalls.set(threadId, remaining);
else inlineCalls.delete(threadId);
});
const outcome = yield* Effect.gen(function* () {
const fiber = yield* run(scope, request).pipe(Effect.forkIn(jobScope));
const exit = yield* Fiber.await(fiber).pipe(
Effect.timeoutOption(options.inlineBudgetMs ?? INLINE_BUDGET_MS),
);
if (Option.isSome(exit)) return { _tag: "Done" as const, exit: exit.value };
const taskId = `${BRIDGE_JOB_PREFIX}${yield* crypto.randomUUIDv4().pipe(Effect.orDie)}`;
// Registered before the inline slot is released, so the call is counted throughout.
jobs.set(taskId, { threadId, startedAt: now, fiber });
return { _tag: "Job" as const, taskId };
}).pipe(Effect.ensuring(releaseInline));
if (outcome._tag === "Done") return yield* outcome.exit;
const taskId = outcome.taskId;
return {
status: "running",
taskId,
Expand Down
26 changes: 20 additions & 6 deletions apps/web/src/coder/serverForwards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,31 @@ export async function forwardServer(input: {
localPort: await findFreeCoderForwardPort(remotePort),
remotePort,
};
// Saving starts the forward; the gateway answers once its process has spawned.
await updateConfig((current) => addServerForward(current, rule));
// Saving starts the forward; the gateway answers once its process has spawned. Another tab may
// have forwarded the same server first; that rule is used instead of adding a second one.
let forwarded = rule;
await updateConfig((current) => {
const existing = findServerForward(current, rule.workspaceId, remotePort);
if (existing !== undefined) {
forwarded = existing;
return current;
}
return addServerForward(current, rule);
});
const deadline = Date.now() + FORWARD_READY_TIMEOUT_MS;
for (;;) {
const status = (await loadCoderPortForwardStatuses()).find((entry) => entry.id === rule.id);
const status = (await loadCoderPortForwardStatuses()).find(
(entry) => entry.id === forwarded.id,
);
const expired = Date.now() >= deadline;
// Coder versions that never print `Ready!` still open once the wait runs out.
if (status?.status === "running" && (status.ready === true || expired)) return rule;
if (status?.status === "running" && (status.ready === true || expired)) return forwarded;
if (status?.status !== "running" && (status?.status !== "starting" || expired)) {
// A forward that never ran is not kept, so Settings shows no rule to explain.
await updateConfig((current) => removeServerForward(current, rule.id));
// A forward this click added and that never ran is not kept, so Settings shows no rule to
// explain; a rule another tab added stays.
if (forwarded === rule) {
await updateConfig((current) => removeServerForward(current, rule.id));
}
throw new Error(status?.error ?? "The port forward did not start.");
}
await new Promise((resolve) => setTimeout(resolve, FORWARD_READY_POLL_MS));
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/ThreadErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { Button } from "../ui/button";
import { CircleAlertIcon, XIcon } from "lucide-react";
import { ExpandableText } from "../settings/ExpandableText";

// Coder: no ChatGPT usage-limit branch (`ChatGptUsageButton`, the workspace admin request, and
// the `errorCode` prop); ChatGPT accounts and plan limits are cut.

export function getThreadErrorBannerMessage(error: string): string {
let payload: unknown;
try {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/ThreadServersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@ export function ThreadServersPanel(props: {
if (forwarded === null) return;
const url = forwardedServerUrl(forwarded);
if (tab) tab.location.href = url;
else window.open(url, "_blank", "noopener,noreferrer");
else if (window.open(url, "_blank", "noopener,noreferrer") === null) {
// The browser blocked the tab; the forward still runs, so say where it is.
toastManager.add(
stackedThreadToast({ type: "success", title: "Server forwarded", description: url }),
);
}
};

const unforward = async (server: DiscoveredLocalServer) => {
Expand Down
14 changes: 10 additions & 4 deletions docs/internals/coder-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ removed, or the gateway exits. A running forward's status adds `ready` once the
Coder's `Ready!` line on stderr. The thread details card's Servers section saves the same rules
(see [Web chat, composer, and files](#web-chat-composer-and-files)); its free-port check
(`/api/port-forwards/free-port`, at most `MAX_LOCAL_PORT_CANDIDATES` ports past the requested one)
binds and closes a probe listener on `127.0.0.1` only and opens no connection.
binds and closes a probe listener on `127.0.0.1` only and opens no connection. On Windows a
specific-address bind can succeed while another program listens on `0.0.0.0` with the same port,
so the check may call such a port free (unverified on a Windows laptop); the gateway cannot probe
the wildcard address or connect out to tell, by boundary 1 and its no-outbound-connection rule.

```text
browser -> 127.0.0.1 gateway -> coder ssh stdio -> workspace helper -> codex / claude
Expand Down Expand Up @@ -597,7 +600,9 @@ checks that every name here still exists and that every modified upstream file i
`coderServer.ts` provides the runtime coordinator alone (upstream: `layerFromHost`);
`rpc.ts`, client-runtime's `state/server.ts`, and `AcpSessionManagementSection.tsx` carry only
those two. Session delete is cut because Kilo Code advertises no `session/delete`; logout and
agent provider configuration are account and provider management.
agent provider configuration are account and provider management. The section keeps upstream's
`readOnly` prop (upstream's browser test passes it) and `probe.ts` keeps upstream's
`managementFailure` reasons for the cut actions; neither is read here.
- provider-acp's `adapter.ts` gives ACP sessions no MCP server and sends `t3ToolBridgeInstructions`
with the first prompt through provider-core's `t3AcpPromptWithInstructions`; `mcpOverAcpBridge.ts`
and `clientTerminals.ts` stay dormant (cutting them would add about 34 seams). HTTP MCP servers
Expand Down Expand Up @@ -980,8 +985,9 @@ checks that every name here still exists and that every modified upstream file i

### Build tooling and tests

- `apps/web/vite.config.ts` carries upstream's office/pdf asset handling and route splitting but
not the render scenarios project, `VITE_RELEASE_CHANNEL`, source maps, or the build manifest.
- `apps/web/vite.config.ts` carries upstream's office/pdf asset handling, route splitting and
build manifest (the gateway reads `.vite/manifest.json` to cache hashed assets) but not the render
scenarios project, `VITE_RELEASE_CHANNEL`, or source maps.
- Root `vite.config.ts` is upstream's without the remote task cache and cached test tasks (`infra/`
is cut), the per-package test projects (`vite.coder.config.ts` replaces upstream's
`apps/server/vite.config.ts` and extends this file, which therefore loads `gitConfig.setup.ts`),
Expand Down
7 changes: 5 additions & 2 deletions docs/internals/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ carried part of such a file.
An upstream file added under a deleted path can still be needed: a carried file may import it
(upstream #28041 adds `apps/server/src/auth/serverInternalPaths.ts`, which the carried
`AssetAccess.ts`, `GitVcsDriverCore.ts`, and `WorkspaceFileSystem.ts` import). The script lists
such files apart, under "Keep candidates", with their importers. Keep each one a carried file
such files apart, under "Keep candidates", with their importers; it resolves relative imports,
the web app's `~/` alias, and `@t3tools/<pkg>/...` imports through each package's `exports`.
Keep each one a carried file
needs (narrow the `DELETED_PATHS` entry if the directory is otherwise cut, and add the file to
the seams doc); an importer that is itself an uncarried upstream addition (`binCli.ts` for
`cli/up.ts`) means neither is needed. To remove every other listed upstream-added file at once:
Expand Down Expand Up @@ -357,7 +359,8 @@ result. Read every line it prints; it changes nothing. Its checks (`--only <name
- `markers`: each upstream-modified file's `Coder:` marker count against `$PREV`. A drop must be
inside a block upstream deleted, or be a seam that upstream's own mechanism now expresses (name
it in the merge commit body).
- `lost`: lines upstream added in `$MB..$SHA` that the result lacks; each must be a listed seam or
- `lost`: lines upstream added in `$MB..$SHA` under `apps/` and `packages/`, outside test files,
that the result lacks; each must be a listed seam or
a deleted surface. It uses the merged range, never `upstream/main`'s tip (later work would show
as losses).
- `both-sides`: lines both `$PREV` and `$SHA` have that the result lacks. Neither side deleted
Expand Down
4 changes: 2 additions & 2 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ finishes; the call's own result shows its status.

The thread details card lists the local servers that the thread's terminals or its Claude agent
started in the workspace, with their ports. Not every one serves a web page. A server the agent
started stays listed after the agent's turn ends. Select a server to forward it to `127.0.0.1` on this computer and open it in a
new tab; the forward is saved under **Settings → Environments → Port forwarding** and uses the same
started stays listed after the agent's turn ends. Select a server to forward it to `127.0.0.1` on
this computer and open it in a new tab; the forward is saved under **Settings → Environments → Port forwarding** and uses the same
port when it is free, otherwise the next free one. A forwarded server shows its local address, and
selecting it opens that address. **Stop forwarding** removes the forward; the server keeps running.
**Stop** ends the server's job and keeps the agent, the terminal, and T3 Coder running; a saved
Expand Down
3 changes: 2 additions & 1 deletion docs/user/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ workspace stops its helper and the work running there; the task waits and report
ended once the workspace is connected again.

To point an agent at a linked workspace in a message, type `@` and part of the workspace's name in
the composer and pick it. The list shows the connected workspaces the current one links to.
the composer and pick it. The list shows the connected workspaces the current one links to, once
**Linked environments** is turned on in **Settings → Labs** for the current workspace.

## Data ownership

Expand Down
2 changes: 2 additions & 0 deletions packages/client-runtime/src/composerThreadItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface ComposerThreadItem {
readonly description: string;
}

// Coder: threads on linked machines (#26201) are not carried, so there is no `linkedEnvironments`
// input and candidates come only from the composer's own environment.
/**
* Threads to reference from the composer, matched and ranked like the command
* palette, without subagent or side chat threads. The agent can only read threads on its
Expand Down
7 changes: 4 additions & 3 deletions packages/coder-cli/src/helperConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,14 @@ const terminateCoderProcess = (
}
};
// A negotiated helper is asked to exit, so `coder ssh` ends its session itself; before that,
// only signals stop the launch.
if (!process.negotiated) endStdin();
// or once stdin can no longer carry the request, only signals stop the launch.
const canRequestExit = process.negotiated && !stdin.destroyed && !stdin.writableEnded;
if (!canRequestExit) endStdin();
return terminateChildProcess({
child: process.child,
exit: process.exit,
graceMs: terminationGraceMs,
...(process.negotiated ? { requestExit: () => endStdin(SHUTDOWN_REQUEST) } : {}),
...(canRequestExit ? { requestExit: () => endStdin(SHUTDOWN_REQUEST) } : {}),
});
});

Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/composerContextReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@ export function projectComposerContextForProvider(input: {
return `${body}\n\n<${CONTEXT_ENVELOPE_TAG} version="1">\n${entries.join("\n")}\n</${CONTEXT_ENVELOPE_TAG}>`;
}

// Thread reads are environment-local even when a draft moves between machines.
// Coder: threads on linked machines (#26201) are not carried, so there is no `linkedEnvironments`
// input. Thread reads are environment-local even when a draft moves between machines.
export function threadContextsOutsideEnvironment(input: {
text: string;
records: ReadonlyArray<ComposerContextRecord>;
Expand Down
52 changes: 48 additions & 4 deletions scripts/sync-resolve-deleted.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,49 @@ const readMerged = (path) => {
return "";
}
};
// Workspace packages by name, so `@t3tools/<pkg>/<sub>` imports resolve through their `exports`.
const workspacePackages = new Map(
git("ls-files", "-z", "--", "apps/*/package.json", "packages/*/package.json")
.split("\0")
.filter(Boolean)
.flatMap((manifest) => {
try {
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
return [[parsed.name, { dir: NodePath.posix.dirname(manifest), exports: parsed.exports }]];
} catch {
return [];
}
}),
);
const exportTarget = (value) =>
typeof value === "string" ? value : (value?.import ?? value?.default ?? value?.types);
/** The repository path an import specifier names, for relative, package, and `~/` imports. */
const resolveSpecifier = (importer, specifier) => {
if (specifier.startsWith("./") || specifier.startsWith("../")) {
return NodePath.posix.join(NodePath.posix.dirname(importer), specifier);
}
// The web app's `~` alias (apps/web/tsconfig.json and vite.config.ts).
if (specifier.startsWith("~/")) return `apps/web/src/${specifier.slice(2)}`;
const match = /^(@[^/]+\/[^/]+)(?:\/(.+))?$/.exec(specifier);
const pkg = match ? workspacePackages.get(match[1]) : undefined;
if (!pkg || typeof pkg.exports !== "object" || pkg.exports === null) return undefined;
const key = match[2] === undefined ? "." : `./${match[2]}`;
for (const [pattern, value] of Object.entries(pkg.exports)) {
const target = exportTarget(value);
if (typeof target !== "string") continue;
if (pattern === key) return NodePath.posix.join(pkg.dir, target);
const star = pattern.indexOf("*");
if (
star !== -1 &&
key.startsWith(pattern.slice(0, star)) &&
key.endsWith(pattern.slice(star + 1))
) {
const captured = key.slice(star, key.length - (pattern.length - star - 1));
return NodePath.posix.join(pkg.dir, target.replace("*", captured));
}
}
return undefined;
};
const importersOf = (path) => {
const stem = NodePath.posix.basename(stripExtension(path));
let hits = "";
Expand All @@ -497,10 +540,11 @@ const importersOf = (path) => {
.split("\0")
.filter((file) => file && file !== path && !categoryOf(file))
.filter((file) =>
[...readMerged(file).matchAll(/(?:from\s+|import\s*\(\s*)["'](\.{1,2}\/[^"']+)["']/g)].some(
([, specifier]) =>
stripExtension(NodePath.posix.join(NodePath.posix.dirname(file), specifier)) ===
stripExtension(path),
[...readMerged(file).matchAll(/(?:from\s+|import\s*\(\s*)["']([^"']+)["']/g)].some(
([, specifier]) => {
const resolved = resolveSpecifier(file, specifier);
return resolved !== undefined && stripExtension(resolved) === stripExtension(path);
},
),
);
};
Expand Down