Skip to content

feat: run Discord coding tasks in isolated worktrees - #5467

Draft
shivamhwp wants to merge 12 commits into
pingdotgg:mainfrom
shivamhwp:demo/copilotkit-discord-channels
Draft

feat: run Discord coding tasks in isolated worktrees#5467
shivamhwp wants to merge 12 commits into
pingdotgg:mainfrom
shivamhwp:demo/copilotkit-discord-channels

Conversation

@shivamhwp

@shivamhwp shivamhwp commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Discord coding requests currently have to be copied into T3 Code by hand, and there is no safe way to start a run from a team channel. This adds a Discord demo powered by CopilotKit Channels so a mention or /t3 command can start a T3 Code task and receive status and completion in the same Discord thread.

Every channel task creates a unique branch and worktree before the agent turn starts. Worktree creation is fail-closed: the base branch is only the starting ref, and a failure never falls back to running on main. The new Channels settings page keeps project selection, Discord credentials, and branch policy in T3 Code; the bot token is persisted through the server secret store and redacted from the browser and settings.json.

The Discord adapter posts native start, status, and completion cards. Existing orchestration and thread.turn-diff-completed events drive the run, so the Discord conversation remains linked to the real T3 Code thread and diff.

Screenshots and demo walkthrough: https://ki136pbimw6u.postplan.dev

Verification:

  • server, web, and contracts typechecks
  • focused lint for touched files
  • 22 focused channel-isolation and secret-storage tests
  • server production bundle
  • isolated browser pass covering configuration, save, secret redaction after reload, and disabled persistence

The PR is intentionally draft for the demo. A live Discord round trip still requires maintainers to supply a Discord application and bot token in Settings → Channels.

Model: GPT-5.6-Sol
Harness: Codex (T3 Code)

Note

Add Discord channel integration that runs coding tasks in isolated git worktrees

  • Adds a new T3CodeDiscordChannel layer that listens for Discord mentions or /t3 commands, creates an isolated git worktree via GitWorkflowService.createWorktree, and dispatches a new orchestration thread for each task.
  • Introduces Discord-specific UI cards (status, started, completed) using @copilotkit/channels-* packages; the channel lifecycle is driven by ServerSettings and restarts when config changes.
  • Adds a /settings/channels route with a ChannelSettings UI for configuring Discord app credentials, project, base branch, and branch prefix.
  • Discord bot token is stored in the secret store rather than settings.json and is redacted in client-facing settings responses.
  • Risk: task deduplication and completion notification routing use an in-memory map, so concurrent server restarts may drop notifications.
📊 Macroscope summarized d7ee184. 8 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fc204033-1ef7-48cc-9585-bc6c2d62f95b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Aug 6, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the new Effect service code in this PR against the repo's Effect service conventions.

One clear violation: the Discord channel task-start effect fails with plain strings instead of a Schema.TaggedErrorClass with structured attributes, so the failures carry no structural context and are indistinguishable at the Promise boundary (the catch in handleText reports "could not create an isolated worktree" for every case, including configuration failures).

Everything else checked out: subpath namespace imports for effect/*, the Effect.runPromiseWith(yield* Effect.context<never>()) bridge is confined to the imperative channel adapter (same pattern as ClaudeAdapter) and is not injected into another Effect service, dependencies are acquired from the environment with yield*, and the serverSettings.ts secret plumbing reuses the existing structured ServerSettingsError with a valid operation literal.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/channels/T3CodeDiscordChannel.ts Outdated
Comment on lines +394 to +407
const botToken = discord.botTokenRedacted
? yield* readSecret(DISCORD_BOT_TOKEN_SECRET_NAME)
: discord.botToken;
return {
...settings,
channelIntegrations: {
...settings.channelIntegrations,
discord: {
...discord,
botToken,
},
},
};
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/serverSettings.ts:394

When botTokenRedacted is true but the secret store returns Option.none (e.g. after copying settings.json to a machine without its secret store), materializeChannelSecrets sets botToken to "" but leaves botTokenRedacted: true. redactServerSettingsForClient preserves that marker, so the Channels UI treats the integration as configured even though no credential exists. Clear botTokenRedacted when the secret is absent.

      const botToken = discord.botTokenRedacted
        ? yield* readSecret(DISCORD_BOT_TOKEN_SECRET_NAME)
        : discord.botToken;
+      const botTokenRedacted = discord.botTokenRedacted && botToken.length > 0;
      return {
        ...settings,
        channelIntegrations: {
          ...settings.channelIntegrations,
          discord: {
            ...discord,
            botToken,
+            botTokenRedacted,
          },
        },
      };
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around lines 394-407:

When `botTokenRedacted` is `true` but the secret store returns `Option.none` (e.g. after copying `settings.json` to a machine without its secret store), `materializeChannelSecrets` sets `botToken` to `""` but leaves `botTokenRedacted: true`. `redactServerSettingsForClient` preserves that marker, so the Channels UI treats the integration as configured even though no credential exists. Clear `botTokenRedacted` when the secret is absent.

Comment on lines +73 to +78
projectId !== null &&
baseBranch.trim().length > 0 &&
branchPrefix.trim().length > 0 &&
applicationId.trim().length > 0 &&
hasBotToken;
const selectedProject = projects.find((project) => project.id === projectId) ?? null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ChannelSettings.tsx:73

setupComplete treats any non-null projectId as valid, but when the configured project was deleted or belongs to a different environment, selectedProject is null. The UI still labels the integration "Configured" and allows saving, so Discord requests target a project that no longer exists and cannot start. Consider requiring selectedProject !== null in setupComplete so the status badge and save button reflect the missing project.

   const setupComplete =
-    projectId !== null &&
+    selectedProject !== null &&
     baseBranch.trim().length > 0 &&
     branchPrefix.trim().length > 0 &&
     applicationId.trim().length > 0 &&
     hasBotToken;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ChannelSettings.tsx around lines 73-78:

`setupComplete` treats any non-null `projectId` as valid, but when the configured project was deleted or belongs to a different environment, `selectedProject` is `null`. The UI still labels the integration "Configured" and allows saving, so Discord requests target a project that no longer exists and cannot start. Consider requiring `selectedProject !== null` in `setupComplete` so the status badge and save button reflect the missing project.

);
return { value: "", redacted: true } as const;
});
const botToken = yield* persistSecret({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/serverSettings.ts:568

persistChannelSecrets writes to the secret store before normalizeServerSettings and writeSettingsAtomically commit the settings file. If normalization or the file write fails, the error is returned but the secret mutation is not rolled back — a replaced Discord bot token is already changed in the store, and a cleared token is already deleted, even though the old settings file still references it. The API reports failure while leaving credentials modified or lost. Consider staging secret mutations and committing them only after the settings write succeeds, or rolling them back on failure.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/serverSettings.ts around line 568:

`persistChannelSecrets` writes to the secret store before `normalizeServerSettings` and `writeSettingsAtomically` commit the settings file. If normalization or the file write fails, the error is returned but the secret mutation is not rolled back — a replaced Discord bot token is already changed in the store, and a cleared token is already deleted, even though the old settings file still references it. The API reports failure while leaving credentials modified or lost. Consider staging secret mutations and committing them only after the settings write succeeds, or rolling them back on failure.

config.enabled &&
config.projectId !== null &&
config.baseBranch.trim().length > 0 &&
config.branchPrefix.trim().length > 0 &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High channels/T3CodeDiscordChannel.ts:83

isDiscordChannelConfigured accepts branchPrefix values like "/" or strings containing spaces, which then cause every task to fail during worktree creation. After channelBranchName strips surrounding slashes, a branchPrefix of "/" produces a branch like /task-<suffix>, and prefixes with spaces pass through into the branch name. Git rejects refs that begin with / or contain spaces, so these settings are accepted as valid but always fail at gitWorkflow.createWorktree. Consider trimming and validating branchPrefix more strictly — for example, rejecting it when nothing remains after stripping slashes or when it contains whitespace.

Suggested change
config.branchPrefix.trim().length > 0 &&
config.branchPrefix.trim().length > 0 &&
!/^\/+|\s|\/+$/u.test(config.branchPrefix) &&
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around line 83:

`isDiscordChannelConfigured` accepts `branchPrefix` values like `"/"` or strings containing spaces, which then cause every task to fail during worktree creation. After `channelBranchName` strips surrounding slashes, a `branchPrefix` of `"/"` produces a branch like `/task-<suffix>`, and prefixes with spaces pass through into the branch name. Git rejects refs that begin with `/` or contain spaces, so these settings are accepted as valid but always fail at `gitWorkflow.createWorktree`. Consider trimming and validating `branchPrefix` more strictly — for example, rejecting it when nothing remains after stripping slashes or when it contains whitespace.

Comment thread apps/web/src/components/settings/ChannelSettings.tsx Outdated
].join("\u0000");
}

export const layer = Layer.effectDiscard(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium channels/T3CodeDiscordChannel.ts:427

notifyCompleted silently drops the completion card whenever the channel is restarted or reconfigured while a task is running. linkedThreads is an in-memory Map on a single channel instance; each reconcile that changes the fingerprint replaces the instance and discards all prior Discord↔T3 thread links. When thread.turn-diff-completed later fires, linkedThreads.get(completion.threadId) returns undefined, so notifyCompleted exits at if (!thread) return and the completion reply is never posted. Persisting thread links or keying them independently of the channel instance would prevent the loss.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around line 427:

`notifyCompleted` silently drops the completion card whenever the channel is restarted or reconfigured while a task is running. `linkedThreads` is an in-memory `Map` on a single channel instance; each `reconcile` that changes the fingerprint replaces the instance and discards all prior Discord↔T3 thread links. When `thread.turn-diff-completed` later fires, `linkedThreads.get(completion.threadId)` returns `undefined`, so `notifyCompleted` exits at `if (!thread) return` and the completion reply is never posted. Persisting thread links or keying them independently of the channel instance would prevent the loss.

return yield* Effect.fail("Discord channel branch must differ from its base branch");
}

const worktree = yield* gitWorkflow.createWorktree({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium channels/T3CodeDiscordChannel.ts:342

After createWorktree succeeds, a failure in either thread.create or thread.turn.start rejects startTaskEffect without removing the worktree, so the worktree and its branch are leaked on disk. The Discord user sees a "Task did not start" error, but the worktree remains registered and the branch name stays occupied. Repeated transient dispatch failures accumulate orphaned worktrees and storage. Consider wrapping the post-createWorktree dispatch logic in an Effect.ensuring or Effect.catchAll that calls gitWorkflow.removeWorktree on failure.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around line 342:

After `createWorktree` succeeds, a failure in either `thread.create` or `thread.turn.start` rejects `startTaskEffect` without removing the worktree, so the worktree and its branch are leaked on disk. The Discord user sees a "Task did not start" error, but the worktree remains registered and the branch name stays occupied. Repeated transient dispatch failures accumulate orphaned worktrees and storage. Consider wrapping the post-`createWorktree` dispatch logic in an `Effect.ensuring` or `Effect.catchAll` that calls `gitWorkflow.removeWorktree` on failure.

Comment on lines +62 to +69
useEffect(() => {
setEnabled(settings.enabled);
setProjectId(settings.projectId);
setBaseBranch(settings.baseBranch);
setBranchPrefix(settings.branchPrefix);
setApplicationId(settings.applicationId);
setGuildId(settings.guildId);
}, [settings]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ChannelSettings.tsx:62

The useEffect at line 62 that syncs local state when settings changes resets applicationId, guildId, projectId, etc., but never clears botToken or botTokenChanged. If the primary environment/settings changes after a token is typed but before Save, the form repopulates with the new environment's fields while still showing the previously typed secret. Pressing Save then sends the old environment's botToken to the new environment — leaking the credential across environments. Consider resetting botToken to "" and botTokenChanged to false inside the same effect so secrets don't persist across environment switches.

  useEffect(() => {
    setEnabled(settings.enabled);
    setProjectId(settings.projectId);
    setBaseBranch(settings.baseBranch);
    setBranchPrefix(settings.branchPrefix);
    setApplicationId(settings.applicationId);
    setGuildId(settings.guildId);
+   setBotToken("");
+   setBotTokenChanged(false);
  }, [settings]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ChannelSettings.tsx around lines 62-69:

The `useEffect` at line 62 that syncs local state when `settings` changes resets `applicationId`, `guildId`, `projectId`, etc., but never clears `botToken` or `botTokenChanged`. If the primary environment/settings changes after a token is typed but before Save, the form repopulates with the new environment's fields while still showing the previously typed secret. Pressing Save then sends the old environment's `botToken` to the new environment — leaking the credential across environments. Consider resetting `botToken` to `""` and `botTokenChanged` to `false` inside the same effect so secrets don't persist across environment switches.

);
};

const handleText = async (thread: ChannelThread, rawText: string) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium channels/T3CodeDiscordChannel.ts:229

Two messages in the same Discord thread that arrive before the first startTask resolves both start separate agent runs and worktrees, and the second setState call silently overwrites the first link — so a rapid duplicate mention or command launches unintended duplicate coding tasks. handleText reads thread.state() and later calls thread.setState(...) without any per-thread serialization, so concurrent handlers both observe no active task and proceed to startTask. Consider serializing handleText per thread (e.g., with a per-thread lock or in-flight queue) so the second message waits for the first startTask/setState to complete.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around line 229:

Two messages in the same Discord thread that arrive before the first `startTask` resolves both start separate agent runs and worktrees, and the second `setState` call silently overwrites the first link — so a rapid duplicate mention or command launches unintended duplicate coding tasks. `handleText` reads `thread.state()` and later calls `thread.setState(...)` without any per-thread serialization, so concurrent handlers both observe no active task and proceed to `startTask`. Consider serializing `handleText` per thread (e.g., with a per-thread lock or in-flight queue) so the second message waits for the first `startTask`/`setState` to complete.

Comment thread apps/server/src/channels/T3CodeDiscordChannel.ts
@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Aug 6, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One convention issue remains in apps/server/src/channels/T3CodeDiscordChannel.ts: the new DiscordChannelTaskError carries an unstructured message string as its only attribute, so the four distinct start-up failures it represents carry no structural context and cannot be discriminated by callers.

Posted via Macroscope — Effect Service Conventions

Comment on lines +91 to +94
class DiscordChannelTaskError extends Schema.TaggedErrorClass<DiscordChannelTaskError>()(
"DiscordChannelTaskError",
{ message: Schema.String },
) {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tagged error stores an unstructured message as its only attribute, which is the one shape the repo's error convention rules out: failures should carry stable structural fields (project id, branch/base branch) and derive message from them. It is also used for four semantically distinct failures (project not configured, project not found, no default model, branch equals base branch), each with its own user-facing wording, so callers cannot tell them apart.

Consider one error class per failure, each with the context available at the failure site and a derived message — matching e.g. WorkspacePaths.ts / ProjectSetupScriptRunner.ts:

Suggested change
class DiscordChannelTaskError extends Schema.TaggedErrorClass<DiscordChannelTaskError>()(
"DiscordChannelTaskError",
{ message: Schema.String },
) {}
class DiscordChannelProjectNotConfiguredError extends Schema.TaggedErrorClass<DiscordChannelProjectNotConfiguredError>()(
"DiscordChannelProjectNotConfiguredError",
{},
) {
override get message(): string {
return "Discord channel project is not configured";
}
}
class DiscordChannelProjectNotFoundError extends Schema.TaggedErrorClass<DiscordChannelProjectNotFoundError>()(
"DiscordChannelProjectNotFoundError",
{ projectId: ProjectId },
) {
override get message(): string {
return `Discord channel project ${this.projectId} was not found`;
}
}
class DiscordChannelProjectModelMissingError extends Schema.TaggedErrorClass<DiscordChannelProjectModelMissingError>()(
"DiscordChannelProjectModelMissingError",
{ projectId: ProjectId },
) {
override get message(): string {
return `Discord channel project ${this.projectId} has no default model`;
}
}
class DiscordChannelBranchCollisionError extends Schema.TaggedErrorClass<DiscordChannelBranchCollisionError>()(
"DiscordChannelBranchCollisionError",
{ branch: Schema.String, baseBranch: Schema.String },
) {
override get message(): string {
return `Discord channel branch '${this.branch}' must differ from its base branch '${this.baseBranch}'`;
}
}

(ProjectId is already exported from @t3tools/contracts; the four new DiscordChannelTaskError({ message }) sites would construct the matching class instead.)

Posted via Macroscope — Effect Service Conventions

Comment on lines +238 to +245
void persistDiscordPatch({ botToken, botTokenRedacted: false }, ["botToken"]).then(
(saved) => {
if (!saved) return;
setBotTokenChanged(false);
setBotTokenStored(botToken.length > 0);
},
);
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ChannelSettings.tsx:238

If the user types a new bot token while a previous token save is still pending, the stale request's success callback unconditionally calls setBotTokenChanged(false), overwriting the newer edit's dirty state. A subsequent save() then sends botToken: "", silently dropping the newly entered token. In SecretInput's onBlur handler, the .then callback should only clear botTokenChanged when botToken still equals the value that was actually persisted.

Suggested change
void persistDiscordPatch({ botToken, botTokenRedacted: false }, ["botToken"]).then(
(saved) => {
if (!saved) return;
setBotTokenChanged(false);
setBotTokenStored(botToken.length > 0);
},
);
}}
void persistDiscordPatch({ botToken, botTokenRedacted: false }, ["botToken"]).then(
(saved) => {
if (!saved) return;
if (botToken !== value) return;
setBotTokenChanged(false);
setBotTokenStored(botToken.length > 0);
},
);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ChannelSettings.tsx around lines 238-245:

If the user types a new bot token while a previous token save is still pending, the stale request's success callback unconditionally calls `setBotTokenChanged(false)`, overwriting the newer edit's dirty state. A subsequent `save()` then sends `botToken: ""`, silently dropping the newly entered token. In `SecretInput`'s `onBlur` handler, the `.then` callback should only clear `botTokenChanged` when `botToken` still equals the value that was actually persisted.

hasBotToken;
const selectedProject = projects.find((project) => project.id === projectId) ?? null;

const persistDiscordPatch = useCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ChannelSettings.tsx:98

After a blur-save starts for a field, further edits to that same field are silently overwritten by the useEffect that syncs from settings. For example, blur applicationId to start saving value A, then refocus and type B before the A request completes. When the A response arrives, persistDiscordPatch deletes the applicationId dirty marker even though the field now holds B. The next settings update runs the sync effect at lines 76–87, and with the marker removed it overwrites the unsaved B value with A. The completion handler must only clear the dirty marker for the exact revision it persisted, not unconditionally for the field name.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ChannelSettings.tsx around line 98:

After a blur-save starts for a field, further edits to that same field are silently overwritten by the `useEffect` that syncs from `settings`. For example, blur `applicationId` to start saving value A, then refocus and type B before the A request completes. When the A response arrives, `persistDiscordPatch` deletes the `applicationId` dirty marker even though the field now holds B. The next `settings` update runs the sync effect at lines 76–87, and with the marker removed it overwrites the unsaved B value with A. The completion handler must only clear the dirty marker for the exact revision it persisted, not unconditionally for the field name.

Comment on lines +78 to +89
useEffect(() => {
setEnabled(settings.enabled);
setProjectId(settings.projectId);
setThreadEnvMode(settings.threadEnvMode);
if (!dirtyFieldsRef.current.has("baseBranch")) setBaseBranch(settings.baseBranch);
if (!dirtyFieldsRef.current.has("branchPrefix")) setBranchPrefix(settings.branchPrefix);
if (!dirtyFieldsRef.current.has("applicationId")) setApplicationId(settings.applicationId);
if (!dirtyFieldsRef.current.has("guildId")) setGuildId(settings.guildId);
if (!dirtyFieldsRef.current.has("botToken")) {
setBotTokenStored(settings.botTokenRedacted);
}
}, [settings]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High settings/ChannelSettings.tsx:78

The useEffect that syncs state from settings skips fields tracked in dirtyFieldsRef, so an unsaved draft value (e.g. baseBranch) is carried over when the primary environment changes and is then persisted to the wrong environment. Switching environments does not reset dirtyFieldsRef or the draft state, so the next blur/save uses the stale value from the previous environment with the new environment's persistServerSettings. This silently cross-contaminates configuration across environments. Consider clearing dirtyFieldsRef and resetting draft state when primaryEnvironment.environmentId changes.

  useEffect(() => {
+    const environmentId = primaryEnvironment?.environmentId ?? null;
+    if (environmentId !== dirtyFieldsRef.current["__envId"] as string | undefined) {
+      dirtyFieldsRef.current.clear();
+      (dirtyFieldsRef.current as any)["__envId"] = environmentId;
+    }
    setEnabled(settings.enabled);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ChannelSettings.tsx around lines 78-89:

The `useEffect` that syncs state from `settings` skips fields tracked in `dirtyFieldsRef`, so an unsaved draft value (e.g. `baseBranch`) is carried over when the primary environment changes and is then persisted to the wrong environment. Switching environments does not reset `dirtyFieldsRef` or the draft state, so the next blur/save uses the stale value from the previous environment with the new environment's `persistServerSettings`. This silently cross-contaminates configuration across environments. Consider clearing `dirtyFieldsRef` and resetting draft state when `primaryEnvironment.environmentId` changes.

Comment on lines +351 to +362
const linked = linkedTasks.get(completion.threadId);
if (!linked) return;
if (completion.changedFileCount !== undefined) {
linked.changedFileCount = completion.changedFileCount;
}
const task = await input.operations.getTaskStatus(completion.threadId);
if (!task) return;
linked.messageRef = await linked.thread.update(
linked.messageRef,
taskStatusText(task, linked.changedFileCount),
);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium channels/T3CodeDiscordChannel.ts:351

Entries added to linkedTasks are never removed after a task reaches done or failed, so each completed Discord task permanently retains its Thread object and MessageRef. Over the lifetime of a long-running server this map grows without bound and leaks memory. Consider deleting the entry from linkedTasks once the task reaches a terminal state, e.g. inside refreshTask after the final status update.

      const linked = linkedTasks.get(completion.threadId);
      if (!linked) return;
      if (completion.changedFileCount !== undefined) {
        linked.changedFileCount = completion.changedFileCount;
      }
      const task = await input.operations.getTaskStatus(completion.threadId);
      if (!task) return;
      linked.messageRef = await linked.thread.update(
        linked.messageRef,
        taskStatusText(task, linked.changedFileCount),
      );
+      if (task.state === "done" || task.state === "failed") {
+        linkedTasks.delete(completion.threadId);
+      }
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around lines 351-362:

Entries added to `linkedTasks` are never removed after a task reaches `done` or `failed`, so each completed Discord task permanently retains its `Thread` object and `MessageRef`. Over the lifetime of a long-running server this map grows without bound and leaks memory. Consider deleting the entry from `linkedTasks` once the task reaches a terminal state, e.g. inside `refreshTask` after the final status update.

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect service conventions review: two findings on the new Discord channel wiring, plus one previously reported issue that is still present.

  • apps/server/src/channels/T3CodeDiscordChannel.ts — the layer consumes the Promise adapter (operations.listModels) from inside Effect code, round-tripping ProviderRegistry through runPromise and back through Effect.tryPromise.
  • apps/web/src/hooks/useSettings.tsAsyncResult is imported as a named export from the effect/unstable/reactivity barrel instead of as a module namespace from its subpath.
  • Still open from an earlier review (not re-commented): DiscordChannelTaskError carries only an unstructured message and is reused for four semantically distinct failures.

Posted via Macroscope — Effect Service Conventions

import { resolveSidebarV2Enabled } from "~/branding.logic";
import { ensureLocalApi } from "~/localApi";
import * as Struct from "effect/Struct";
import { AsyncResult } from "effect/unstable/reactivity";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect library modules should be imported as namespaces from their own subpath rather than as named exports of a barrel; the rest of the app uses import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" (e.g. apps/web/src/state/desktopUpdate.ts).

Suggested change
import { AsyncResult } from "effect/unstable/reactivity";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";

Posted via Macroscope — Effect Service Conventions

Comment on lines +616 to +618
const models = yield* Effect.tryPromise(() => operations.listModels()).pipe(
Effect.orElseSucceed(() => []),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listModels is only consumed here, from Effect code, so ProviderRegistry.getProviders is run through Effect.runPromiseWith inside makeOperations and then bridged back with Effect.tryPromise — the Promise adapter becomes a dependency of an Effect service, and orElseSucceed erases the typed failure. The other operations members genuinely need the Promise shape for the channel callbacks; this one does not.

Consider acquiring ProviderRegistry in this layer (const providerRegistry = yield* ProviderRegistry;) and reading the models directly in Effect:

-      const models = yield* Effect.tryPromise(() => operations.listModels()).pipe(
-        Effect.orElseSucceed(() => []),
-      );
+      const models = yield* providerRegistry.getProviders.pipe(
+        Effect.map(discordModelOptions),
+        Effect.orElseSucceed(() => []),
+      );

Posted via Macroscope — Effect Service Conventions

}
}

try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium channels/T3CodeDiscordChannel.ts:325

When startTask succeeds but a later step (such as the message PATCH in postStatus) fails, the catch block posts "T3 Code could not start" even though the agent task is already running. This misleads users into resubmitting duplicate tasks. The try/catch wraps thread.setState, postText, and postStatus in addition to startTask, so any notification failure is reported as a startup failure. Consider narrowing the try/catch to startTask only and handling notification failures separately.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around line 325:

When `startTask` succeeds but a later step (such as the message `PATCH` in `postStatus`) fails, the catch block posts "T3 Code could not start" even though the agent task is already running. This misleads users into resubmitting duplicate tasks. The `try`/`catch` wraps `thread.setState`, `postText`, and `postStatus` in addition to `startTask`, so any notification failure is reported as a startup failure. Consider narrowing the `try`/`catch` to `startTask` only and handling notification failures separately.

Comment thread apps/server/src/channels/T3CodeDiscordChannel.ts Outdated
),
Effect.catchCause(() => Effect.succeed(false)),
);
if (!connected) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High channels/T3CodeDiscordChannel.ts:632

When ɵruntime.start() fails or times out, reconcile stops the runtime and returns without scheduling a retry or recording an active instance. Reconciliation is only triggered by settings changes, so a transient Discord or network outage during startup leaves the configured integration offline indefinitely until settings change or the server restarts. Consider scheduling a retry on connect failure so the channel can recover from transient outages.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around line 632:

When `ɵruntime.start()` fails or times out, `reconcile` stops the runtime and returns without scheduling a retry or recording an active instance. Reconciliation is only triggered by settings changes, so a transient Discord or network outage during startup leaves the configured integration offline indefinitely until settings change or the server restarts. Consider scheduling a retry on connect failure so the channel can recover from transient outages.

Comment on lines +376 to +382
const completeLinkedTask = async (threadId: ThreadId, status: ChannelTaskStatus) => {
const linked = linkedTasks.get(threadId);
if (!linked || linked.terminal || status.assistantResponse === null) return;
await updateLinkedTask(threadId, status);
await discordClient.reply(linked.messageRef, taskResponseUi(status.assistantResponse));
linked.terminal = true;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium channels/T3CodeDiscordChannel.ts:376

completeLinkedTask sets linked.terminal = true only after awaiting both updateLinkedTask and discordClient.reply, so a concurrent /status request and the refreshPendingTasks poll can both pass the initial linked.terminal guard. Both calls then await the same network operations and each sends the assistant response to Discord, producing duplicate completion replies for the same task. Mark the task terminal before awaiting the network operations so the second concurrent caller exits early.

  const completeLinkedTask = async (threadId: ThreadId, status: ChannelTaskStatus) => {
    const linked = linkedTasks.get(threadId);
    if (!linked || linked.terminal || status.assistantResponse === null) return;
+    linked.terminal = true;
    await updateLinkedTask(threadId, status);
    await discordClient.reply(linked.messageRef, taskResponseUi(status.assistantResponse));
-    linked.terminal = true;
  };
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/channels/T3CodeDiscordChannel.ts around lines 376-382:

`completeLinkedTask` sets `linked.terminal = true` only *after* awaiting both `updateLinkedTask` and `discordClient.reply`, so a concurrent `/status` request and the `refreshPendingTasks` poll can both pass the initial `linked.terminal` guard. Both calls then await the same network operations and each sends the assistant response to Discord, producing duplicate completion replies for the same task. Mark the task terminal *before* awaiting the network operations so the second concurrent caller exits early.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant