Skip to content

Commit 7219315

Browse files
committed
feat(ru-code): Add MCP UI, exceptions engine, model selection, and fix Git Cyrillic support
- Added MCP management UI: Introduced a new user interface to manage Model Context Protocol (MCP) settings. - Added exceptions engine: Built a robust error-handling system to gracefully manage any application errors. - Fixed VCS (Git) issues: Resolved bug causing errors with Cyrillic characters in repository names and paths. - Enabled model selection: Added the ability for users to choose and switch between different AI models.
1 parent 67977d7 commit 7219315

46 files changed

Lines changed: 4319 additions & 251 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/server/src/checkpointing/Layers/CheckpointStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ const makeCheckpointStore = Effect.gen(function* () {
269269
}
270270

271271
const diffArgs = [
272+
// Emit raw UTF-8 pathnames instead of git's default octal-escaped,
273+
// double-quoted form. The downstream diff parser only reads the
274+
// unquoted capture groups of the `diff --git` header, so quoted
275+
// (non-ASCII) paths crash it. ASCII paths are unaffected.
276+
"-c",
277+
"core.quotePath=false",
272278
"diff",
273279
"--patch",
274280
"--minimal",

apps/server/src/cli/config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,11 @@ export const resolveServerConfig = (
384384
cwd,
385385
baseDir,
386386
// ru-fork: straight from the resolver — no dirname(baseDir) derivation.
387-
cliJs: cli.cliJs,
387+
// RU_FORK_CLI_JS is a DEV-ONLY override: point it at the built fake ACP
388+
// server (tests/fixtures/fake-acp-server) to drive the real app against a
389+
// scripted wire failure for manual UI checks. Combine with RU_FORK_FAKE_ACP
390+
// (read by the fake itself) to pick which error to reproduce. See §9.5.
391+
cliJs: process.env["RU_FORK_CLI_JS"] ?? cli.cliJs,
388392
cliConfigDir: cli.configDir,
389393
...derivedPaths,
390394
host,

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ const make = Effect.gen(function* () {
279279
// detail → return its detail") is now `Z_CLEAN_REQUEST_ERROR` in
280280
// recognizers.ts. The old `Cause.pretty` fallback is replaced by
281281
// `UNRECOGNIZED_DECISION` — a fixed Russian fallback in UI plus the
282-
// full pretty cause in the `[cli-error.unrecognized]` log line.
282+
// full pretty cause in the `[runtime]` breadcrumb (code: "unrecognized").
283283

284284
const setThreadSession = (input: {
285285
readonly threadId: ThreadId;
@@ -859,7 +859,16 @@ const make = Effect.gen(function* () {
859859
// Russian text in the UI, full `Cause.pretty` in the server log).
860860
// The old `formatFailureDetail` helper has been deleted — its fast
861861
// path now lives as the `Z_CLEAN_REQUEST_ERROR` recognizer.
862-
const handleTurnStartFailure = (cause: Cause.Cause<unknown>) =>
862+
// ru-fork: PRE-TURN failures only (buildSendTurnRequestForThread fails
863+
// before sendTurn ever runs — most commonly B3 spawn, or an RPC error
864+
// during the initialize/session.new handshake). No `turn.started` was
865+
// emitted ⇒ no `turn.completed{failed}` will come ⇒ ingestion can't help ⇒
866+
// the reactor is the SOLE writer here, and there is no competing
867+
// `session.exited` (nothing live to exit). This is the only place the
868+
// reactor writes turn-failure projection state. A TURN failure (sendTurn
869+
// itself) goes through `handleTurnFailure` below (killAcp only) — its
870+
// presentation is written by ingestion off the finalizer's turn.completed.
871+
const handlePreTurnFailure = (cause: Cause.Cause<unknown>) =>
863872
Effect.gen(function* () {
864873
if (Cause.hasInterruptsOnly(cause)) return;
865874

@@ -872,16 +881,21 @@ const make = Effect.gen(function* () {
872881
.interruptTurn({ threadId: event.payload.threadId })
873882
// Best-effort: if there's no live session to kill (already
874883
// dead, or never attached), don't fail the dispatch. The
875-
// [cli-error.<id>] log line above already records the
876-
// failure context.
884+
// [runtime] breadcrumb above already records the failure context.
877885
.pipe(Effect.catch(() => Effect.void)),
878886
appendActivity: (detail: string) =>
879887
appendProviderFailureActivity({
880888
threadId: event.payload.threadId,
881889
kind: "provider.turn.start.failed",
882890
summary: "Provider turn start failed",
883891
detail,
884-
turnId: null,
892+
// ru-fork: bind to the PRIOR (settled) turn so the work-log
893+
// filter (session-logic.ts:492 — activity.turnId === latestTurnId)
894+
// keeps the row even when the thread already has turns. Falls back
895+
// to null when there is no prior turn (then latestTurnId is
896+
// undefined ⇒ the filter's `: true` branch shows it anyway).
897+
// `latestTurn` is the settled prior turn, NOT the racy active turn.
898+
turnId: thread.latestTurn?.turnId ?? null,
885899
createdAt: event.payload.createdAt,
886900
}),
887901
setLastError: (detail: string) =>
@@ -897,13 +911,31 @@ const make = Effect.gen(function* () {
897911
});
898912
});
899913

900-
const recoverTurnStartFailure = (cause: Cause.Cause<unknown>) =>
901-
handleTurnStartFailure(cause).pipe(
902-
Effect.catchCause((recoveryCause) =>
903-
Effect.logWarning("provider command reactor failed to recover turn start failure", {
914+
// ru-fork: TURN failures (sendTurn itself failed). The adapter's finalizer
915+
// already emitted the one `turn.completed{failed}` carrying the classified
916+
// text + surface + real turnId, and ingestion (single writer) has written
917+
// the timeline row / banner / message-finalize from it. The reactor must
918+
// NOT write any projection state here (doing so is the two-writer race this
919+
// whole change removes) — its only job is to kill the session when the
920+
// recognizer says so. `killAcp → stopSession` (not interruptTurn) so we
921+
// don't set the adapter's cancel-intent flag on a genuine failure.
922+
const handleTurnFailure = (cause: Cause.Cause<unknown>) =>
923+
Effect.gen(function* () {
924+
// Stop / teardown interrupts → ingestion already finalized as cancelled.
925+
if (Cause.hasInterruptsOnly(cause)) return;
926+
const error = cause.reasons.find(Cause.isFailReason)?.error;
927+
const decision = classify(error, cause) ?? UNRECOGNIZED_DECISION;
928+
if (decision.killAcp === true) {
929+
yield* providerService
930+
.stopSession({ threadId: event.payload.threadId })
931+
.pipe(Effect.catch(() => Effect.void));
932+
}
933+
}).pipe(
934+
Effect.catchCause((handlerCause) =>
935+
Effect.logWarning("provider command reactor failed to handle turn failure", {
904936
eventType: event.type,
905937
threadId: event.payload.threadId,
906-
cause: Cause.pretty(recoveryCause),
938+
cause: Cause.pretty(handlerCause),
907939
originalCause: Cause.pretty(cause),
908940
}),
909941
),
@@ -951,7 +983,7 @@ const make = Effect.gen(function* () {
951983
createdAt: event.payload.createdAt,
952984
}).pipe(
953985
Effect.map(Option.some),
954-
Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))),
986+
Effect.catchCause((cause) => handlePreTurnFailure(cause).pipe(Effect.as(Option.none()))),
955987
);
956988

957989
if (Option.isNone(sendTurnRequest)) {
@@ -960,7 +992,7 @@ const make = Effect.gen(function* () {
960992

961993
yield* providerService
962994
.sendTurn(sendTurnRequest.value)
963-
.pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
995+
.pipe(Effect.catchCause(handleTurnFailure), Effect.forkScoped);
964996
});
965997

966998
const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ function truncateDetail(value: string, limit = ACTIVITY_DETAIL_MAX_CHARS): strin
169169
return value.length > limit ? `${value.slice(0, limit - 3)}...` : value;
170170
}
171171

172+
// ru-fork: DEFENSIVE only. By contract the adapter always sets
173+
// `errorMessage = decision.text` (non-empty for every T/T+N recognizer and the
174+
// unrecognized fallback) on turn.completed{failed}, so this is unreachable in
175+
// practice — it exists so a malformed event can't produce an empty banner/row.
176+
const FALLBACK_TURN_FAILED_TEXT = "Шаг не выполнен";
177+
172178
function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined {
173179
const trimmed = planMarkdown?.trim();
174180
if (!trimmed) {
@@ -1275,7 +1281,17 @@ const make = Effect.gen(function* () {
12751281
? null
12761282
: (thread.session?.lastError ?? null);
12771283

1278-
if (shouldApplyThreadLifecycle) {
1284+
// ru-fork: a FAILED turn.completed is owned end-to-end by the dedicated
1285+
// single-writer block below (it writes the timeline row with the real
1286+
// turnId + the session error per surface). Skip the generic lifecycle
1287+
// session.set here so there is exactly ONE writer for failed-turn state
1288+
// (and so the legacy "Шаг не выполнен" + always-banner path no longer
1289+
// races it). Completed / cancelled turns still flow through here.
1290+
const isFailedTurnCompleted =
1291+
event.type === "turn.completed" &&
1292+
normalizeRuntimeTurnState(event.payload.state) === "failed";
1293+
1294+
if (shouldApplyThreadLifecycle && !isFailedTurnCompleted) {
12791295
if (event.type === "turn.started" && acceptedTurnStartedSourcePlan !== null) {
12801296
yield* markSourceProposedPlanImplemented(
12811297
acceptedTurnStartedSourcePlan.sourceThreadId,
@@ -1315,6 +1331,59 @@ const make = Effect.gen(function* () {
13151331
createdAt: now,
13161332
});
13171333
}
1334+
1335+
// ru-fork: SINGLE WRITER for a failed turn. Everything the user sees on
1336+
// a failure comes from this one event: the timeline error row carries
1337+
// the REAL turnId (event.turnId, set by the adapter when it created the
1338+
// turn) so the web work-log filter (activity.turnId === latestTurnId,
1339+
// session-logic.ts:492) keeps the row and it persists; the surface
1340+
// decides banner-or-not. The message-finalize loop further below still
1341+
// runs for failed turns and clears `message.streaming` (timer stops).
1342+
// No projection write for a failed turn happens on the reactor bus.
1343+
if (event.type === "turn.completed" && isFailedTurnCompleted && shouldApplyThreadLifecycle) {
1344+
const failedTurnId = toTurnId(event.turnId) ?? null;
1345+
const detail = event.payload.errorMessage ?? FALLBACK_TURN_FAILED_TEXT;
1346+
// true ⇒ T+N (row + notification); false ⇒ T (row only); absent ⇒
1347+
// notification (legacy default). The row is written unconditionally below.
1348+
const surfacesNotification = event.payload.showNotification ?? true;
1349+
// (1) timeline row, bound to the real turn id
1350+
yield* orchestrationEngine.dispatch({
1351+
type: "thread.activity.append",
1352+
commandId: providerCommandId(event, "turn-failed-activity"),
1353+
threadId: thread.id,
1354+
activity: {
1355+
id: event.eventId,
1356+
tone: "error",
1357+
kind: "provider.turn.start.failed",
1358+
summary: "Provider turn failed",
1359+
payload: { detail: truncateDetail(detail) },
1360+
turnId: failedTurnId,
1361+
createdAt: now,
1362+
} satisfies OrchestrationThreadActivity,
1363+
createdAt: now,
1364+
});
1365+
// (2) session error per surface — T+N ⇒ banner (status error +
1366+
// lastError); T ⇒ no banner (status ready + lastError null). Both
1367+
// clear activeTurnId so the "Работаю" timer stops.
1368+
yield* orchestrationEngine.dispatch({
1369+
type: "thread.session.set",
1370+
commandId: providerCommandId(event, "turn-failed-session"),
1371+
threadId: thread.id,
1372+
session: {
1373+
threadId: thread.id,
1374+
status: surfacesNotification ? "error" : "ready",
1375+
providerName: event.provider,
1376+
...(event.providerInstanceId !== undefined
1377+
? { providerInstanceId: event.providerInstanceId }
1378+
: {}),
1379+
runtimeMode: thread.session?.runtimeMode ?? thread.runtimeMode,
1380+
activeTurnId: null,
1381+
lastError: surfacesNotification ? detail : null,
1382+
updatedAt: now,
1383+
},
1384+
createdAt: now,
1385+
});
1386+
}
13181387
}
13191388

13201389
const assistantDelta =

0 commit comments

Comments
 (0)