Skip to content

Commit e7ab945

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-29063
2 parents 2882d38 + f7b81e1 commit e7ab945

31 files changed

Lines changed: 663 additions & 230 deletions

‎.github/workflows/ci.yml‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,16 @@ jobs:
140140
- name: Start installing browser secret helper build libraries
141141
if: needs.changes.outputs.build != 'false'
142142
run: |
143-
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
143+
# Fetch only the indexes these main and universe packages need. The full set is ~42 MB,
144+
# which a slow mirror can stretch past the wait below.
145+
sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \
146+
/etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
144147
(
145148
set +e
146-
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config xvfb
149+
sudo apt-get update -o Acquire::Languages=none \
150+
-o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \
151+
-o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false &&
152+
sudo apt-get install -y libsecret-1-dev pkg-config xvfb
147153
echo "$?" > "$RUNNER_TEMP/apt-status"
148154
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &
149155
@@ -287,10 +293,16 @@ jobs:
287293
# the background while Vite+ installs; the step before the tests waits for it.
288294
- name: Start installing browser secret helper build libraries
289295
run: |
290-
sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
296+
# Fetch only the indexes these main and universe packages need. The full set is ~42 MB,
297+
# which a slow mirror can stretch past the wait below.
298+
sudo sed -i -e 's|http://|https://|g' -e 's/^Components: .*/Components: main universe/' \
299+
/etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
291300
(
292301
set +e
293-
sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
302+
sudo apt-get update -o Acquire::Languages=none \
303+
-o Acquire::IndexTargets::deb::DEP-11::DefaultEnabled=false \
304+
-o Acquire::IndexTargets::deb::CNF::DefaultEnabled=false &&
305+
sudo apt-get install -y libsecret-1-dev pkg-config
294306
echo "$?" > "$RUNNER_TEMP/apt-status"
295307
) > "$RUNNER_TEMP/apt.log" 2>&1 < /dev/null &
296308

‎apps/mobile/src/features/threads/LinkRequestCard.tsx‎

Lines changed: 31 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { pipe } from "effect/Function";
22
import { useAtomValue } from "@effect/atom-react";
33
import {
4+
ANOTHER_ADDRESS,
45
type LinkableMachine,
6+
linkDestination,
57
linkRequestAnswerInput,
68
linkRequestDefaultAccess,
79
linkRequestDisplay,
810
linkRequestFailureMessage,
911
linkRequestInitialMachine,
1012
type LinkRequestItem,
13+
linkRequestTarget,
1114
linkRequestTargetName,
1215
rankMachinesByHint,
1316
} from "@t3tools/client-runtime/link-request";
@@ -58,9 +61,6 @@ const accessLabel = (access: AuthMcpClientAccess) =>
5861

5962
const LINK_SYMBOL: AppSymbolName = { ios: "link", android: "desktop_windows" };
6063

61-
/** The machine list's row for linking by an address typed in. */
62-
const ANOTHER_ADDRESS = "another-address";
63-
6464
/** Whether the agent left the machine for the user to pick. */
6565
const picksMachine = (item: LinkRequestItem) =>
6666
item.environmentId === undefined && item.url === undefined;
@@ -141,11 +141,6 @@ function PendingLinkRequest(props: {
141141
{item.reason.trim() ? (
142142
<Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
143143
) : null}
144-
{item.label !== undefined && item.url !== undefined ? (
145-
<Text className="font-sans text-xs text-foreground-muted" numberOfLines={1}>
146-
{item.url}
147-
</Text>
148-
) : null}
149144
</View>
150145
{error !== null ? (
151146
<Text
@@ -194,37 +189,6 @@ function PendingLinkRequest(props: {
194189
);
195190
}
196191

197-
/** What the sheet links; see web's LinkRequestDialog for the same rules. */
198-
type SheetTarget =
199-
| { readonly kind: "none" }
200-
| {
201-
readonly kind: "machine";
202-
readonly environmentId: EnvironmentId;
203-
readonly label: string;
204-
readonly urls: ReadonlyArray<string>;
205-
readonly linked: boolean;
206-
}
207-
| { readonly kind: "address" };
208-
209-
function resolveTarget(
210-
item: LinkRequestItem,
211-
machines: ReadonlyArray<LinkableMachine>,
212-
choice: string | null,
213-
): SheetTarget {
214-
if (item.environmentId === undefined && item.url !== undefined) return { kind: "address" };
215-
const chosen = item.environmentId ?? choice;
216-
if (chosen === null) return { kind: "none" };
217-
if (chosen === ANOTHER_ADDRESS) return { kind: "address" };
218-
const machine = machines.find((candidate) => candidate.environmentId === chosen);
219-
return {
220-
kind: "machine",
221-
environmentId: machine?.environmentId ?? (chosen as EnvironmentId),
222-
label: machine?.label ?? linkRequestTargetName(item),
223-
urls: [...new Set([...(machine?.urls ?? []), ...(item.url === undefined ? [] : [item.url])])],
224-
linked: machine?.linked ?? false,
225-
};
226-
}
227-
228192
function LinkRequestSheet(props: {
229193
readonly environmentId: EnvironmentId;
230194
readonly item: LinkRequestItem;
@@ -251,17 +215,22 @@ function LinkRequestSheet(props: {
251215
reportDefect: false,
252216
});
253217
const selected = choice ?? linkRequestInitialMachine(item, machines);
254-
const target = resolveTarget(item, machines, selected);
218+
const target = linkRequestTarget(item, machines, selected);
255219
const targetName = target.kind === "machine" ? target.label : linkRequestTargetName(item);
256-
// This phone can mint a code there when it is connected to that environment
257-
// with a session that may create pairing links.
258220
const targetId = target.kind === "machine" ? target.environmentId : null;
259221
const connected = usePreparedConnection(targetId);
260222
const mayMint = useEnvironmentScope(targetId, AuthAccessWriteScope);
261-
const canMint =
262-
targetId !== null && Option.isSome(connected) && mayMint && !mintFailed.has(targetId);
263223
const needsAddress =
264224
target.kind === "address" || (target.kind === "machine" && target.urls.length === 0);
225+
// This phone mints a code there when it is connected with a session that may
226+
// create pairing links, and only for its own addresses: anything can answer
227+
// as that machine at one typed in, so that one gets a code the user fetched.
228+
const canMint =
229+
targetId !== null &&
230+
!needsAddress &&
231+
Option.isSome(connected) &&
232+
mayMint &&
233+
!mintFailed.has(targetId);
265234
const useExisting = target.kind === "machine" && target.linked;
266235
const ready =
267236
target.kind !== "none" &&
@@ -288,10 +257,7 @@ function LinkRequestSheet(props: {
288257
}
289258
return linkRequestAnswerInput(item, {
290259
type: "link",
291-
target:
292-
target.kind === "address"
293-
? { url: address }
294-
: { ...target, urls: needsAddress ? [address.trim()] : target.urls },
260+
target: target.kind === "machine" && target.urls.length > 0 ? target : { url: address },
295261
access,
296262
pairingCode: code,
297263
});
@@ -335,12 +301,27 @@ function LinkRequestSheet(props: {
335301
Agents here can then launch, message and wait on threads there. It appears in that
336302
machine's Connections, where it can be revoked.
337303
</Text>
338-
{!pickMachine && item.url !== undefined ? (
304+
{item.url !== undefined && target.kind === "machine" ? (
339305
<Text className="font-sans text-xs text-foreground-muted" numberOfLines={1}>
340306
{item.url}
341307
</Text>
342308
) : null}
343309
</View>
310+
{needsAddress && !useExisting && address.trim() ? (
311+
<View
312+
accessibilityRole="alert"
313+
className="gap-1 rounded-xl border border-warning-border bg-warning p-3"
314+
>
315+
<Text className="font-t3-bold text-sm text-warning-foreground">Check this address</Text>
316+
<Text className="font-sans text-xs leading-snug text-warning-foreground">
317+
This app cannot confirm it is one of your machines. The pairing code you paste is sent
318+
there.
319+
</Text>
320+
<Text selectable className="font-sans text-xs leading-snug text-warning-foreground">
321+
{linkDestination(address)}
322+
</Text>
323+
</View>
324+
) : null}
344325
{pickMachine ? (
345326
<SettingsSection title="Machine">
346327
{machines.map((machine, index) => (
@@ -369,7 +350,7 @@ function LinkRequestSheet(props: {
369350
/>
370351
</SettingsSection>
371352
) : null}
372-
{needsAddress && !(item.url !== undefined && item.environmentId === undefined) ? (
353+
{needsAddress && item.url === undefined ? (
373354
<View className="gap-2">
374355
<TextInput
375356
accessibilityLabel="Address"

‎apps/mobile/src/lib/threadActivity.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
ThreadPendingApproval,
33
ThreadPendingUserInput,
44
} from "@t3tools/client-runtime/state/thread-requests";
5+
import { linkRequestTargetName } from "@t3tools/client-runtime/link-request";
56
import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
67
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
78
import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
@@ -624,7 +625,7 @@ function itemSummary(
624625
case "secret_request":
625626
return item.label;
626627
case "link_request":
627-
return `Link ${item.label ?? item.url ?? "an environment"}`;
628+
return `Link ${linkRequestTargetName(item)}`;
628629
case "dynamic_tool": {
629630
const classified = classifyToolActivity({
630631
itemType: "dynamic_tool_call",

‎apps/server/src/mcp/OrchestratorMcpService.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -969,8 +969,8 @@ function turnItemText(item: OrchestrationV2TurnItem): string | null {
969969
return `Asked the user for ${item.label}: ${item.secretStatus}.`;
970970
case "link_request":
971971
return item.linkStatus === "linked"
972-
? `Linked ${item.linkedLabel ?? item.label ?? item.url ?? "an environment"} (${item.linkedAccess}).`
973-
: `Asked the user to link ${item.label ?? item.url ?? "an environment"}: ${item.linkStatus}.`;
972+
? `Linked ${item.linkedLabel ?? item.url ?? "an environment"} (${item.linkedAccess}).`
973+
: `Asked the user to link ${item.url ?? "an environment"}: ${item.linkStatus}.`;
974974
case "subagent":
975975
return item.result ?? item.progress ?? item.prompt;
976976
case "dynamic_tool":

‎apps/server/src/orchestration-v2/ThreadFind.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { assert, it, describe } from "@effect/vitest";
2+
import { searchableMessageSegments } from "@t3tools/shared/threadFindText";
23
import {
34
EventId,
45
MessageId,
@@ -19,6 +20,8 @@ import * as Effect from "effect/Effect";
1920
import * as Layer from "effect/Layer";
2021
import * as SqlClient from "effect/sql/SqlClient";
2122
import * as Stream from "effect/Stream";
23+
import * as TestClock from "effect/testing/TestClock";
24+
import { vi } from "vite-plus/test";
2225

2326
import * as SqlitePersistence from "../persistence/Sqlite.ts";
2427
import * as ProjectionStore from "./ProjectionStore.ts";
@@ -31,6 +34,8 @@ import {
3134
} from "./threadHistoryPaging.ts";
3235
import { projectTurnItemForWire } from "./WireProjection.ts";
3336

37+
vi.mock("@t3tools/shared/threadFindText", { spy: true });
38+
3439
const layerTest = Layer.mergeAll(EventStore.layer, ProjectionStore.layer, ProjectStore.layer).pipe(
3540
Layer.provideMerge(SqlitePersistence.layerMemory),
3641
);
@@ -460,6 +465,40 @@ describe("V2 thread find", () => {
460465
}).pipe(Effect.provide(layerTest)),
461466
);
462467

468+
it.effect("keeps its caches while Find is in use and drops them after one idle minute", () =>
469+
Effect.gen(function* () {
470+
const projection = yield* setup;
471+
yield* putItems([item("one", 1, "idle needle one"), item("two", 2, "idle needle two")]);
472+
const parses = () =>
473+
vi
474+
.mocked(searchableMessageSegments)
475+
.mock.calls.filter(([message]) => message.text.startsWith("idle needle")).length;
476+
const search = (query: string) => projection.searchThread({ threadId, query });
477+
// One idle minute passes inside this search. The later counts fail if it let the caches go.
478+
yield* projection.searchThreadStream({ threadId, query: "needle" }).pipe(
479+
Stream.tap((frame) =>
480+
frame.complete === false ? TestClock.adjust("1 minute") : Effect.void,
481+
),
482+
Stream.runDrain,
483+
);
484+
assert.equal(parses(), 2);
485+
yield* TestClock.adjust("40 seconds");
486+
yield* search("idle");
487+
yield* TestClock.adjust("40 seconds");
488+
// A repeated query is answered from the completed scan and still counts as use.
489+
const repeated = yield* projection
490+
.searchThreadStream({ threadId, query: "needle" })
491+
.pipe(Stream.runCollect);
492+
assert.equal(repeated.length, 1);
493+
yield* TestClock.adjust("40 seconds");
494+
yield* search("one");
495+
assert.equal(parses(), 2);
496+
yield* TestClock.adjust("1 minute");
497+
yield* search("needle");
498+
assert.equal(parses(), 4);
499+
}).pipe(Effect.provide(layerTest)),
500+
);
501+
463502
it.effect("invalidates cached counts when streamed text changes or a run rolls back", () =>
464503
Effect.gen(function* () {
465504
const projection = yield* setup;

‎apps/server/src/orchestration-v2/ThreadFind.ts‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { countThreadSearchOccurrences } from "@t3tools/shared/threadSearch";
1212
import * as Cache from "effect/Cache";
1313
import * as Data from "effect/Data";
1414
import * as Effect from "effect/Effect";
15+
import * as RcRef from "effect/RcRef";
1516
import * as Schema from "effect/Schema";
1617
import * as Stream from "effect/Stream";
1718
import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
@@ -38,6 +39,14 @@ interface FindDocument extends FindRow {
3839
readonly runId: OrchestrationV2TurnItem["runId"];
3940
readonly count: number;
4041
}
42+
type CompletedScans = Map<
43+
string,
44+
{
45+
sequence: number;
46+
documents: readonly FindDocument[];
47+
sourceThreadIds: readonly ThreadId[];
48+
}
49+
>;
4150
class TextKey extends Data.Class<{
4251
text: string;
4352
role: "user" | "assistant" | "plan";
@@ -232,26 +241,21 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
232241
});
233242
});
234243
});
235-
// Scans visit every message; a cache smaller than the thread churns without hits.
236-
const textCache = yield* Cache.make({
237-
capacity: 8192,
238-
timeToLive: "1 minute",
239-
lookup: (key: TextKey) => Effect.sync(() => parseText(key)),
240-
});
241-
// Bounded separately because each entry can hold a large body; typing reuses the parse.
242-
const largeTextCache = yield* Cache.make({
243-
capacity: 32,
244-
timeToLive: "1 minute",
245-
lookup: (key: TextKey) => Effect.sync(() => parseText(key)),
244+
const makeTextCache = (capacity: number) =>
245+
Cache.make({ capacity, lookup: (key: TextKey) => Effect.sync(() => parseText(key)) });
246+
// Text keys are message content, so entries never go stale. Cache TTL only expires on access
247+
// and would keep the last searched threads forever. Each search borrows this for its whole
248+
// stream, and all of it is dropped once Find is idle.
249+
const findCaches = yield* RcRef.make({
250+
acquire: Effect.all({
251+
// Scans visit every message; a cache smaller than the thread churns without hits.
252+
text: makeTextCache(8192),
253+
// Bounded separately because each entry can hold a large body; typing reuses the parse.
254+
largeText: makeTextCache(32),
255+
scans: Effect.sync((): CompletedScans => new Map()),
256+
}),
257+
idleTimeToLive: "1 minute",
246258
});
247-
const scans = new Map<
248-
string,
249-
{
250-
sequence: number;
251-
documents: readonly FindDocument[];
252-
sourceThreadIds: readonly ThreadId[];
253-
}
254-
>();
255259
const readRevision = Effect.fn("ThreadFind.readRevision")(function* (
256260
threadIds: readonly ThreadId[],
257261
) {
@@ -275,7 +279,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
275279
return revision[0]?.sequence ?? 0;
276280
});
277281
const snapshot = Effect.fn("ThreadFind.snapshot")(
278-
function* (input: OrchestrationV2SearchThreadInput) {
282+
function* (input: OrchestrationV2SearchThreadInput, scans: CompletedScans) {
279283
const { threadId } = input;
280284
const active = yield* sql<{ cwd: string | null }>`
281285
SELECT COALESCE(json_extract(t.payload_json, '$.worktreePath'), p.workspace_root) AS cwd
@@ -326,8 +330,9 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
326330
const searchThreadStream = (input: OrchestrationV2SearchThreadInput) =>
327331
Stream.unwrap(
328332
Effect.gen(function* () {
333+
const { scans, ...textCache } = yield* RcRef.get(findCaches);
329334
// Release SQLite before decoding and parsing. Both frames describe one snapshot.
330-
const data = yield* snapshot(input);
335+
const data = yield* snapshot(input, scans);
331336
if (data.result) return Stream.succeed(data.result);
332337
const { payloads, cwd, sequence, cacheKey, sourceThreadIds } = data;
333338
const decode = (row: FindPayload) =>
@@ -357,7 +362,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* (
357362
const decoded = yield* decode(row);
358363
const key = textKey(decoded.item, cwd, input.skills ?? []);
359364
const segments = yield* Cache.get(
360-
key.text.length <= 32_768 ? textCache : largeTextCache,
365+
key.text.length <= 32_768 ? textCache.text : textCache.largeText,
361366
key,
362367
);
363368
const document = documentFor(decoded, countSegments(segments, input.query));

0 commit comments

Comments
 (0)