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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t3tools/desktop",
"version": "0.9.10",
"version": "0.10.0",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
Expand Down
28 changes: 10 additions & 18 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2553,25 +2553,17 @@ function getIconOption(): { icon: string } | Record<string, never> {
return iconPath ? { icon: iconPath } : {};
}

// macOS backs the translucent shell with window vibrancy, so the window is created
// transparent (`#00000000`) over the vibrancy material. Windows/Linux have no vibrancy:
// a transparent window there leaves backdrop-filter surfaces bleeding through and, on
// fractional DPI, rendering blurry. So off macOS we create an opaque window and skip the
// macOS-only options. The background is a fixed dark tone purely to avoid a flash before
// the renderer paints — the app is dark-only, and the window is shown only after first
// paint (`show: false`), so this color need not match the in-app surface exactly.
// The window is opaque on every platform. macOS used to create it transparent
// (`#00000000`) over an "under-window" vibrancy material so the renderer's glass
// surfaces could blur the desktop; that material is gone from the UI, and keeping
// the vibrancy layer would leave WindowServer compositing a blur under a window
// that paints over it completely.
//
// The background colour only avoids a flash before the renderer paints. The window
// is shown after first paint (`show: false`), so it need not track the active theme
// exactly — it is the dark canvas, which is the default.
function getWindowMaterialOptions(): BrowserWindowConstructorOptions {
if (process.platform !== "darwin") {
return { backgroundColor: "#181818" };
}
return {
vibrancy: "under-window",
// "followWindow" lets macOS drop vibrancy blending to inactive when the
// window is backgrounded, so WindowServer stops continuously recompositing
// it. "active" forced full-cost blending even when the app was unfocused.
visualEffectState: "followWindow",
backgroundColor: "#00000000",
};
return { backgroundColor: "#090909" };
}

// macOS keeps native traffic lights inset into the renderer's top chrome. Windows
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "t3",
"version": "0.9.10",
"version": "0.10.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ function makeTaskShell(overrides: {
workerId: projectId,
requesterWorkerId: null,
requesterTaskId: null,
requesterThreadId: null,
title: "Existing user Task",
brief: "User-owned work must remain under user control.",
status: "in_progress",
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/devServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,13 @@ export const DevServerManagerLive = Layer.effect(
...(input.env ? { env: input.env } : {}),
});

// A one-shot command exits its shell the moment it returns, so the PTY
// emits `exited` and the reaper drops the registry entry. Without this a
// finished setup script would be reported as running until the app quits.
yield* terminalManager.write({
threadId,
terminalId: DEFAULT_TERMINAL_ID,
data: `${input.command}\r`,
data: input.oneShot ? `${input.command}; exit\r` : `${input.command}\r`,
});

const server: ProjectDevServer = {
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/effectServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Effect, Exit, FileSystem, Layer, Path, Schema, Scope, ServiceMap } from
import { HttpRouter } from "effect/unstable/http";

import { AutomationRunReactor } from "./automation/Services/AutomationRunReactor";
import { WorkerInboxReactor } from "./orchestration/Services/WorkerInboxReactor";
import { AutomationScheduler } from "./automation/Services/AutomationScheduler";
import { AutomationService } from "./automation/Services/AutomationService";
import {
Expand Down Expand Up @@ -41,6 +42,7 @@ export interface ServerShape {
| Path.Path
| Keybindings
| AutomationRunReactor
| WorkerInboxReactor
| AutomationScheduler
| AutomationService
| ServerLifecycleEvents
Expand Down Expand Up @@ -69,6 +71,7 @@ export class ServerLifecycleError extends Schema.TaggedErrorClass<ServerLifecycl
export const createEffectServer = Effect.fn(function* () {
const config = yield* ServerConfig;
const automationRunReactor = yield* AutomationRunReactor;
const workerInboxReactor = yield* WorkerInboxReactor;
const automationScheduler = yield* AutomationScheduler;
const keybindings = yield* Keybindings;
const lifecycleEvents = yield* ServerLifecycleEvents;
Expand Down Expand Up @@ -135,6 +138,7 @@ export const createEffectServer = Effect.fn(function* () {
yield* Scope.provide(orchestrationReactor.start, subscriptionsScope);
yield* Scope.provide(automationScheduler.start(), subscriptionsScope);
yield* Scope.provide(automationRunReactor.start(), subscriptionsScope);
yield* Scope.provide(workerInboxReactor.start(), subscriptionsScope);
yield* Scope.provide(threadDeletionReactor.start(), subscriptionsScope);
yield* Scope.provide(providerSessionReaper.start(), subscriptionsScope);
yield* readiness.markOrchestrationSubscriptionsReady;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
workerId: event.payload.workerId,
requesterWorkerId: event.payload.requesterWorkerId,
requesterTaskId: event.payload.requesterTaskId,
requesterThreadId: event.payload.requesterThreadId ?? null,
title: event.payload.title,
brief: event.payload.brief,
status: event.payload.status,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ function toProjectedTask(row: ProjectionTaskDbRow): OrchestrationTask {
workerId: row.workerId,
requesterWorkerId: row.requesterWorkerId,
requesterTaskId: row.requesterTaskId,
requesterThreadId: row.requesterThreadId,
title: row.title,
brief: row.brief,
status: row.status,
Expand All @@ -468,6 +469,7 @@ function toProjectedTaskShell(row: ProjectionTaskDbRow): OrchestrationTaskShell
workerId: row.workerId,
requesterWorkerId: row.requesterWorkerId,
requesterTaskId: row.requesterTaskId,
requesterThreadId: row.requesterThreadId,
title: row.title,
brief: row.brief,
status: row.status,
Expand Down Expand Up @@ -813,6 +815,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
worker_id AS "workerId",
requester_worker_id AS "requesterWorkerId",
requester_task_id AS "requesterTaskId",
requester_thread_id AS "requesterThreadId",
title,
brief,
status,
Expand Down Expand Up @@ -1231,6 +1234,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
SELECT
task_id AS "taskId", worker_id AS "workerId",
requester_worker_id AS "requesterWorkerId", requester_task_id AS "requesterTaskId",
requester_thread_id AS "requesterThreadId",
title, brief, status, origin, artifacts_json AS "artifacts",
completion_summary AS "completionSummary", created_at AS "createdAt",
updated_at AS "updatedAt", completed_at AS "completedAt"
Expand Down
171 changes: 171 additions & 0 deletions apps/server/src/orchestration/Layers/WorkerInboxReactor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// FILE: WorkerInboxReactor.ts
// Purpose: Spawn a session on the receiving Worker when a cross-Worker request arrives.
// Layer: Server orchestration reactor

import {
CommandId,
DEFAULT_RUNTIME_MODE,
MessageId,
TaskId,
ThreadId,
type OrchestrationTaskShell,
} from "@t3tools/contracts";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import { Cause, Effect, Layer, Stream } from "effect";

import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import {
buildDelegationRequestPrompt,
delegationTasksAwaitingResponder,
responderThreadIdFor,
} from "../workerInboxChannel.ts";
import {
WorkerInboxReactor,
type WorkerInboxReactorShape,
} from "../Services/WorkerInboxReactor.ts";

// Command ids are derived from the Task id rather than random, so a retry after a
// partial failure replays the same commands instead of spawning a second Thread.
const spawnCommandIds = (taskId: TaskId) => ({
threadId: ThreadId.makeUnsafe(`worker-inbox:${taskId}`),
messageId: MessageId.makeUnsafe(`worker-inbox:${taskId}:request`),
threadCreate: CommandId.makeUnsafe(`worker-inbox:${taskId}:thread-create`),
taskInProgress: CommandId.makeUnsafe(`worker-inbox:${taskId}:in-progress`),
turnStart: CommandId.makeUnsafe(`worker-inbox:${taskId}:turn-start`),
});

const make = Effect.gen(function* () {
const engine = yield* OrchestrationEngineService;
const snapshotQuery = yield* ProjectionSnapshotQuery;

const spawnResponder = (taskId: TaskId) =>
Effect.gen(function* () {
const snapshot = yield* snapshotQuery.getShellSnapshot();
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task || task.origin !== "delegation") return;
// Re-check under the fresh snapshot: the live event and startup recovery can
// both target the same Task, and a Task owns exactly one canonical Thread.
if (responderThreadIdFor({ taskId: task.id, threads: snapshot.threads })) return;

const worker = snapshot.projects.find(
(candidate) => candidate.id === task.workerId && candidate.kind === "project",
);
if (!worker) return;
const requester = task.requesterWorkerId
? snapshot.projects.find((candidate) => candidate.id === task.requesterWorkerId)
: undefined;
const requesterThread = task.requesterThreadId
? snapshot.threads.find((candidate) => candidate.id === task.requesterThreadId)
: undefined;

// The receiving Worker's own default wins; otherwise mirror the requester's
// model so a repository without a configured default still answers.
const modelSelection = worker.defaultModelSelection ?? requesterThread?.modelSelection;
if (!modelSelection) {
yield* Effect.logWarning("worker inbox reactor cannot resolve a model for the request", {
taskId: task.id,
workerId: task.workerId,
});
return;
}
// Inherit the requester's runtime mode, falling back to the same default a
// hand-created Thread gets. An approval-gated session would stall waiting on
// a user click, which is exactly what auto-answering exists to avoid.
const runtimeMode = requesterThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const ids = spawnCommandIds(task.id);
const now = new Date().toISOString();

yield* engine.dispatch({
type: "thread.create",
commandId: ids.threadCreate,
threadId: ids.threadId,
projectId: task.workerId,
taskId: task.id,
title: task.title,
modelSelection,
runtimeMode,
envMode: "local",
branch: null,
worktreePath: null,
createdAt: now,
});
yield* engine.dispatch({
type: "task.update",
commandId: ids.taskInProgress,
taskId: task.id,
status: "in_progress",
});
yield* engine.dispatch({
type: "thread.turn.start",
commandId: ids.turnStart,
threadId: ids.threadId,
message: {
messageId: ids.messageId,
role: "user",
text: buildDelegationRequestPrompt({
task,
requesterWorkerTitle: requester?.title ?? "requesting",
}),
attachments: [],
},
modelSelection,
dispatchMode: "queue",
// Reuses the automation origin rather than adding a Worker-specific one:
// it already means "dispatched by the system, not typed by the user", and
// a new enum value would break decoding of persisted messages on downgrade.
dispatchOrigin: "automation",
runtimeMode,
createdAt: now,
});
});

const spawnSafely = (taskId: TaskId) =>
spawnResponder(taskId).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause);
return Effect.logWarning("worker inbox reactor failed to answer a request", {
taskId,
cause: Cause.pretty(cause),
});
}),
);

const queuedTaskIds = new Set<TaskId>();
const worker = yield* makeDrainableWorker((taskId: TaskId) =>
spawnSafely(taskId).pipe(Effect.ensuring(Effect.sync(() => queuedTaskIds.delete(taskId)))),
);
const enqueueSpawn = (taskId: TaskId) =>
Effect.sync(() => {
if (queuedTaskIds.has(taskId)) return false;
queuedTaskIds.add(taskId);
return true;
}).pipe(Effect.flatMap((fresh) => (fresh ? worker.enqueue(taskId) : Effect.void)));

const start: WorkerInboxReactorShape["start"] = Effect.fn(function* () {
// Pick up requests that landed while the server was down before watching the
// live stream. Spawning is idempotent, so overlap with live events is harmless.
const pending = yield* snapshotQuery.getShellSnapshot().pipe(
Effect.map(delegationTasksAwaitingResponder),
Effect.catchCause((cause) =>
Effect.logWarning("worker inbox reactor recovery failed", {
cause: Cause.pretty(cause),
}).pipe(Effect.as([] as ReadonlyArray<OrchestrationTaskShell>)),
),
);
for (const task of pending) {
yield* enqueueSpawn(task.id);
}
yield* Effect.forkScoped(
Stream.runForEach(engine.streamDomainEvents, (event) =>
event.type === "task.created" && event.payload.origin === "delegation"
? enqueueSpawn(event.payload.taskId)
: Effect.void,
),
);
});

return { start, drain: worker.drain } satisfies WorkerInboxReactorShape;
});

export const WorkerInboxReactorLive = Layer.effect(WorkerInboxReactor, make);
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/Services/WorkerInboxReactor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// FILE: WorkerInboxReactor.ts
// Purpose: Service contract for the reactor that answers cross-Worker inbox requests.
// Layer: Server orchestration service

import { Effect, Scope, ServiceMap } from "effect";

export interface WorkerInboxReactorShape {
readonly start: () => Effect.Effect<void, never, Scope.Scope>;
readonly drain: Effect.Effect<void>;
}

export class WorkerInboxReactor extends ServiceMap.Service<
WorkerInboxReactor,
WorkerInboxReactorShape
>()("t3/orchestration/Services/WorkerInboxReactor") {}
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
workerId: command.workerId,
requesterWorkerId: command.requesterWorkerId ?? null,
requesterTaskId: command.requesterTaskId ?? null,
requesterThreadId: command.requesterThreadId ?? null,
title: command.title,
brief: command.brief,
status: "open",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ export function projectEvent(
workerId: payload.workerId,
requesterWorkerId: payload.requesterWorkerId,
requesterTaskId: payload.requesterTaskId,
requesterThreadId: payload.requesterThreadId ?? null,
title: payload.title,
brief: payload.brief,
status: payload.status,
Expand Down
Loading
Loading