From b67b7265c5b4d9a3986aadee4c1a10f311bbce7d Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 03:19:28 -0600 Subject: [PATCH 01/18] fix(usage): attribute shared homes by transcript source --- .../src/features/usage/UsageRouteScreen.tsx | 13 ++ apps/server/src/usage/UsageService.test.ts | 16 +- apps/server/src/usage/UsageService.ts | 19 +- apps/web/src/components/usage/UsagePage.tsx | 26 ++- docs/user/usage.md | 4 +- packages/contracts/src/usage.ts | 8 +- packages/shared/src/usageMerge.test.ts | 179 ++++++++++++++++++ packages/shared/src/usageMerge.ts | 104 ++++++++-- 8 files changed, 349 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index a515f2ddd6..131ad22c3a 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -286,6 +286,19 @@ export function UsageRouteScreen() { {merged.duplicateSources.join(", ")} ) : null} + {merged.approximateEnvironments.length > 0 ? ( + + Totals may count shared usage more than once because these environments run older + servers or have directories whose filesystem identity could not be read:{" "} + {selectedEnvironments + .filter((environment) => + merged.approximateEnvironments.includes(environment.environmentId), + ) + .map((environment) => environment.label) + .join(", ")} + . + + ) : null} {isPending ? ( Scanning provider transcripts… diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index ec1c88ccfd..e2f4fe6894 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -139,7 +139,7 @@ describe("UsageService", () => { await NodeFSP.mkdir(NodePath.join(claudeHome, "projects"), { recursive: true }); await NodeFSP.writeFile( NodePath.join(claudeHome, "projects", "session.jsonl"), - claudeLine(2, 7), + claudeLine(1, 5) + claudeLine(2, 7), ); await NodeFSP.mkdir(NodePath.join(codexHome, "sessions"), { recursive: true }); await NodeFSP.symlink(codexHome, alias, "junction"); @@ -226,6 +226,20 @@ describe("UsageService", () => { sources.filter((source) => source.fingerprint.provider === "codex").length, 1, ); + assert.strictEqual( + sources.reduce( + (sum, source) => + sum + + (source.buckets ?? []).reduce((total, bucket) => total + bucket.totals.outputTokens, 0), + 0, + ), + totalOutputTokens(summary), + ); + assert.isTrue( + sources.every((source) => + source.buckets?.every((bucket) => bucket.provider === source.fingerprint.provider), + ), + ); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 122b89436e..6a32ea6433 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -784,6 +784,7 @@ export const make = Effect.gen(function* () { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: "missing", + buckets: [], scannedFiles: 0, skippedFiles: 0, malformedRecords: 0, @@ -799,6 +800,18 @@ export const make = Effect.gen(function* () { // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); + // Keep attribution at the physical directory boundary. The primary + // aggregator decides scan-wide de-duplication first, so source buckets + // partition the same accepted records as the legacy flat buckets. + const sourceAggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + resolution: input.resolution ?? "day", + ...hourlyWindow, + rates, + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + }); for (const file of files) { livePaths.add(file.path); @@ -810,8 +823,9 @@ export const make = Effect.gen(function* () { for (const record of file.records) { // Only sessions that contributed in-window count: the mtime slack // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); + if (aggregator.add(record)) { + sourceAggregator.add(record); + if (record.sessionId.length > 0) sessionIds.add(record.sessionId); } } } @@ -819,6 +833,7 @@ export const make = Effect.gen(function* () { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, status: status ?? "ok", + buckets: sourceAggregator.finish().buckets, scannedFiles, skippedFiles, malformedRecords: malformedRecords ?? 0, diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 89effa6b47..9ac7dab6c9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -226,6 +226,7 @@ export function UsagePage() { isPartial={isPartial} duplicateSources={merged.duplicateSources} staleEnvironments={merged.staleEnvironments} + approximateEnvironments={merged.approximateEnvironments} /> @@ -642,16 +643,26 @@ function UsageCoverageNotice({ environments, duplicateSources, staleEnvironments, + approximateEnvironments, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly duplicateSources: readonly string[]; readonly staleEnvironments: readonly string[]; + readonly approximateEnvironments: readonly string[]; }) { const failed = environments.filter((environment) => environment.error !== null); const stale = environments.filter((environment) => staleEnvironments.includes(environment.environmentId), ); - if (failed.length === 0 && stale.length === 0 && duplicateSources.length === 0) { + const approximate = environments.filter((environment) => + approximateEnvironments.includes(environment.environmentId), + ); + if ( + failed.length === 0 && + stale.length === 0 && + duplicateSources.length === 0 && + approximate.length === 0 + ) { return null; } @@ -671,6 +682,13 @@ function UsageCoverageNotice({ {duplicateSources.join(", ")} ) : null} + {approximate.length > 0 ? ( + + Totals may include shared usage more than once because these environments run older + servers or have directories whose filesystem identity could not be read:{" "} + {approximate.map((entry) => entry.label).join(", ")}. + + ) : null} ); } @@ -685,6 +703,7 @@ function UsageEnvironmentFilter({ isPartial, duplicateSources, staleEnvironments, + approximateEnvironments, }: { readonly environments: readonly EnvironmentUsageStatus[]; readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; @@ -694,6 +713,7 @@ function UsageEnvironmentFilter({ readonly isPartial: boolean; readonly duplicateSources: readonly string[]; readonly staleEnvironments: readonly string[]; + readonly approximateEnvironments: readonly string[]; }) { const [modelPricesOpen, setModelPricesOpen] = useState(false); const allSelected = selectedEnvironmentIds === null; @@ -708,7 +728,8 @@ function UsageEnvironmentFilter({ ).length; const hasIssue = selectedEnvironments.some((environment) => environment.error !== null) || - staleEnvironments.length > 0; + staleEnvironments.length > 0 || + approximateEnvironments.length > 0; return ( <> @@ -807,6 +828,7 @@ function UsageEnvironmentFilter({ environments={selectedEnvironments} duplicateSources={duplicateSources} staleEnvironments={staleEnvironments} + approximateEnvironments={approximateEnvironments} /> ) : null} diff --git a/docs/user/usage.md b/docs/user/usage.md index 3d9283cc80..a82bd83c63 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -13,7 +13,9 @@ Usage includes each configured account's history, including disabled accounts. C the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment variable. Use absolute paths in the account's environment settings; relative environment paths depend on each project's working directory and cannot be reliably discovered -by Usage. Accounts sharing a history directory count once. +by Usage. Accounts sharing an identifiable history directory count once, while distinct directories +keep their own totals. If an older server cannot separate those totals, or a directory's filesystem +identity is unavailable, Usage warns that shared history may be counted more than once. Antigravity totals come from saved native conversations, including standalone CLI history when available. Models without known prices still contribute tokens; set a custom model price to include diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 3506d733d6..33ff1a8bbe 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -21,7 +21,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 6 as const; +export const USAGE_CONTRACT_VERSION = 7 as const; /** * Oldest {@link UsageSummary} version a current client will still merge. @@ -150,6 +150,12 @@ export const UsageSource = Schema.Struct({ * those overcounts; this is the figure clients should total. */ distinctSessions: NonNegativeInt, + /** + * Buckets attributed to this directory after the server's scan-wide record + * de-duplication. Added in v7; older peers only report provider-wide buckets. + * Raw transcript records never cross the wire. + */ + buckets: Schema.optional(Schema.Array(UsageBucket)), message: Schema.NullOr(TrimmedNonEmptyString), }); export type UsageSource = typeof UsageSource.Type; diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3da3865022..db351edd1b 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -41,6 +41,7 @@ function summary( homePath: string; volumeId?: string; distinctSessions?: number; + buckets?: readonly UsageBucket[]; }[], contractVersion: number = USAGE_CONTRACT_VERSION, ): UsageSummary { @@ -63,6 +64,7 @@ function summary( skippedFiles: 0, malformedRecords: 0, distinctSessions: source.distinctSessions ?? 1, + ...(source.buckets === undefined ? {} : { buckets: source.buckets }), message: null, })), pricing: { status: "fresh", source: "litellm", fetchedAt: null, knownModels: 10 }, @@ -75,6 +77,183 @@ function environment(id: string, usageSummary: UsageSummary): EnvironmentUsage { } describe("mergeUsage", () => { + it("keeps unique older homes while taking a newer shared-home scan", () => { + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/shared" }; + const old = summary( + [bucket({ costUsd: 10 })], + [ + { ...shared, buckets: [bucket({ costUsd: 4 })] }, + { ...shared, homePath: "/unique-a", buckets: [bucket({ costUsd: 6 })] }, + ], + ); + const latest = summary( + [bucket({ costUsd: 16 })], + [ + { ...shared, buckets: [bucket({ costUsd: 5 })] }, + { ...shared, homePath: "/unique-b", buckets: [bucket({ costUsd: 11 })] }, + ], + ); + const merged = mergeUsage( + [ + environment("env-a", { ...old, readAt: "2026-08-07T00:00:00.000Z" }), + environment("env-b", { ...latest, readAt: "2026-08-08T00:00:00.000Z" }), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(22); + expect(merged.approximateEnvironments).toEqual([]); + }); + + it("counts overlapping Claude homes once while retaining both unique homes", () => { + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/shared" }; + const uniqueA = { provider: "claude" as const, hostId: "mac", homePath: "/unique-a" }; + const uniqueB = { provider: "claude" as const, hostId: "mac", homePath: "/unique-b" }; + const four = bucket({ costUsd: 4, records: 1 }); + const six = bucket({ costUsd: 6, records: 1 }); + const eleven = bucket({ costUsd: 11, records: 1 }); + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ costUsd: 10, records: 2 })], + [ + { ...shared, buckets: [four] }, + { ...uniqueA, buckets: [six] }, + ], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 15, records: 2 })], + [ + { ...shared, buckets: [four] }, + { ...uniqueB, buckets: [eleven] }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(21); + expect(merged.records).toBe(3); + expect(merged.sessions).toBe(3); + expect(merged.duplicateSources).toHaveLength(1); + expect(merged.approximateEnvironments).toEqual([]); + }); + + it("does not collapse identical paths on different hosts or filesystems", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/same" }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket({ costUsd: 4 })], [{ ...source, buckets: [bucket({ costUsd: 4 })] }]), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 6 })], + [{ ...source, hostId: "other", buckets: [bucket({ costUsd: 6 })] }], + ), + ), + environment( + "env-c", + summary( + [bucket({ costUsd: 11 })], + [{ ...source, volumeId: "moved-volume", buckets: [bucket({ costUsd: 11 })] }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(21); + expect(merged.duplicateSources).toEqual([]); + }); + + it("keeps unknown filesystem identities separate and reports uncertain overlap", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/same" }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [bucket({ costUsd: 4 })], + [{ ...source, volumeId: "", buckets: [bucket({ costUsd: 4 })] }], + ), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 6 })], + [{ ...source, volumeId: "vol-mac", buckets: [bucket({ costUsd: 6 })] }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(10); + expect(merged.duplicateSources).toEqual([]); + expect(merged.approximateEnvironments).toEqual(["env-a", "env-b"]); + }); + + it("marks legacy mixed-home totals approximate without dropping their unique usage", () => { + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/shared" }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket({ costUsd: 4 })], [{ ...shared, buckets: [bucket({ costUsd: 4 })] }]), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 15 })], + [shared, { ...shared, homePath: "/unique-b" }], + USAGE_CONTRACT_VERSION - 1, + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(19); + expect(merged.approximateEnvironments).toEqual(["env-b"]); + expect(merged.staleEnvironments).toEqual([]); + }); + + it("falls back to flat provider totals when a source claims buckets for another provider", () => { + const shared = { provider: "claude" as const, hostId: "mac", homePath: "/shared" }; + const merged = mergeUsage( + [ + environment( + "env-a", + summary([bucket({ costUsd: 4 })], [{ ...shared, buckets: [bucket({ costUsd: 4 })] }]), + ), + environment( + "env-b", + summary( + [bucket({ costUsd: 15 })], + [ + { ...shared, buckets: [bucket({ provider: "codex", costUsd: 4 })] }, + { ...shared, homePath: "/unique-b", buckets: [bucket({ costUsd: 11 })] }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.costUsd).toBe(19); + expect(merged.providers.map((provider) => provider.provider)).toEqual(["claude"]); + expect(merged.approximateEnvironments).toEqual(["env-b"]); + }); + it("sums environments that read different transcript directories", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index e0cb0510eb..730b610a65 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -94,6 +94,8 @@ export interface MergedUsage { readonly duplicateSources: readonly string[]; readonly contributingEnvironments: readonly EnvironmentId[]; readonly staleEnvironments: readonly EnvironmentId[]; + /** Legacy mixed homes or unknown filesystem identities prevent exact attribution. */ + readonly approximateEnvironments: readonly EnvironmentId[]; } /** @@ -104,13 +106,16 @@ export interface MergedUsage { * home path, which is every Mac in a fleet, from collapsing into one source and * having one of them silently dropped. */ -function fingerprintKey(fingerprint: UsageSourceFingerprint): string { - return [ +function fingerprintKey(fingerprint: UsageSourceFingerprint, environmentId: EnvironmentId): string { + return JSON.stringify([ fingerprint.hostId, fingerprint.provider, fingerprint.resolvedHomePath, fingerprint.volumeId, - ].join(" "); + // An unreadable filesystem identity cannot prove two remote environments + // see the same directory, even if their hostnames and paths match. + fingerprint.volumeId === "" ? environmentId : "", + ]); } /** @@ -118,23 +123,39 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { * * Several environments on one machine (worktree servers, for instance) resolve * the same provider home and would otherwise double count every token. The - * first environment in a stable order claims a fingerprint; the rest have that - * provider's buckets dropped. Environments are sorted by id so the winner does - * not change between renders. + * most recently read summary claims a fingerprint; environment ids break ties + * so ownership stays stable when two summaries have the same read time. */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; readonly duplicates: readonly string[]; + readonly uncertainEnvironments: readonly EnvironmentId[]; } { const ownerByFingerprint = new Map(); const duplicates: string[] = []; + const weakMatches = new Map(); - const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); + const ordered = [...environments].sort( + (a, b) => + (Date.parse(b.summary.readAt) || 0) - (Date.parse(a.summary.readAt) || 0) || + a.environmentId.localeCompare(b.environmentId), + ); for (const environment of ordered) { for (const source of environment.summary.sources) { if (source.status === "missing") continue; - const key = fingerprintKey(source.fingerprint); + const weakKey = JSON.stringify([ + source.fingerprint.hostId, + source.fingerprint.provider, + source.fingerprint.resolvedHomePath, + ]); + const matches = weakMatches.get(weakKey) ?? []; + matches.push({ + environmentId: environment.environmentId, + volumeId: source.fingerprint.volumeId, + }); + weakMatches.set(weakKey, matches); + const key = fingerprintKey(source.fingerprint, environment.environmentId); if (ownerByFingerprint.has(key)) { duplicates.push(`${environment.label}: ${source.fingerprint.resolvedHomePath}`); continue; @@ -143,7 +164,17 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } } - return { ownerByFingerprint, duplicates }; + const uncertainEnvironments = new Set(); + for (const matches of weakMatches.values()) { + if ( + matches.some((match) => match.volumeId === "") && + new Set(matches.map((match) => match.environmentId)).size > 1 + ) { + for (const match of matches) uncertainEnvironments.add(match.environmentId); + } + } + + return { ownerByFingerprint, duplicates, uncertainEnvironments: [...uncertainEnvironments] }; } /** Sources this environment owns after fingerprint claims, plus their buckets. */ @@ -153,12 +184,16 @@ function ownedContribution( ): { readonly buckets: readonly UsageBucket[]; readonly sessionsByProvider: ReadonlyMap; + readonly approximate: boolean; } { const ownedProviders = new Set(); const sessionsByProvider = new Map(); + const sourcesByProvider = new Map(); for (const source of environment.summary.sources) { if (source.status === "missing") continue; - const key = fingerprintKey(source.fingerprint); + const providerSources = sourcesByProvider.get(source.fingerprint.provider) ?? []; + sourcesByProvider.set(source.fingerprint.provider, [...providerSources, source]); + const key = fingerprintKey(source.fingerprint, environment.environmentId); if (ownerByFingerprint.get(key) === environment.environmentId) { const provider = source.fingerprint.provider; ownedProviders.add(provider); @@ -170,9 +205,45 @@ function ownedContribution( ); } } + const buckets: UsageBucket[] = []; + let approximate = false; + for (const [provider, sources] of sourcesByProvider) { + if (!ownedProviders.has(provider)) continue; + // Mixed-version peers still have provider-wide buckets. Keep those totals + // visible, but explicitly report their attribution as approximate when a + // provider spans both owned and duplicated directories. + const granular = sources.every( + (source) => + source.buckets !== undefined && + source.buckets.every((bucket) => bucket.provider === source.fingerprint.provider), + ); + if (granular) { + for (const source of sources) { + if ( + ownerByFingerprint.get(fingerprintKey(source.fingerprint, environment.environmentId)) === + environment.environmentId + ) { + buckets.push(...(source.buckets ?? [])); + } + } + } else { + buckets.push(...environment.summary.buckets.filter((bucket) => bucket.provider === provider)); + if ( + sources.some( + (source) => + ownerByFingerprint.get( + fingerprintKey(source.fingerprint, environment.environmentId), + ) !== environment.environmentId, + ) + ) { + approximate = true; + } + } + } return { - buckets: environment.summary.buckets.filter((bucket) => ownedProviders.has(bucket.provider)), + buckets, sessionsByProvider, + approximate, }; } @@ -213,6 +284,7 @@ const EMPTY_MERGED: MergedUsage = { duplicateSources: [], contributingEnvironments: [], staleEnvironments: [], + approximateEnvironments: [], }; /** @@ -242,7 +314,7 @@ export function mergeUsage( } } - const { ownerByFingerprint, duplicates } = claimSources(current); + const { ownerByFingerprint, duplicates, uncertainEnvironments } = claimSources(current); let costUsd = 0; let uncachedInputTokens = 0; @@ -289,9 +361,14 @@ export function mergeUsage( } >(); const contributingEnvironments: EnvironmentId[] = []; + const approximateEnvironments = new Set(uncertainEnvironments); for (const environment of current) { - const { buckets, sessionsByProvider } = ownedContribution(environment, ownerByFingerprint); + const { buckets, sessionsByProvider, approximate } = ownedContribution( + environment, + ownerByFingerprint, + ); + if (approximate) approximateEnvironments.add(environment.environmentId); if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { @@ -444,5 +521,6 @@ export function mergeUsage( duplicateSources: duplicates, contributingEnvironments, staleEnvironments, + approximateEnvironments: [...approximateEnvironments], }; } From 762792de1c3857a60612169a5e6aa4c1614a8be9 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:17:55 -0600 Subject: [PATCH 02/18] fix(server): preserve pending turn admission during provider bind (#752) A reused provider can still report its previous turn as running when a new exact turn is reserved. Bind that reservation as starting so matching turn.started can be accepted, reject malformed and terminal binds, and retain strict incarnation fences. --- .../Layers/ProviderCommandReactor.test.ts | 94 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 15 ++- .../Layers/ProviderRuntimeIngestion.test.ts | 21 +++++ .../decider.sessionLifecycle.test.ts | 6 ++ apps/server/src/orchestration/decider.ts | 1 + 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3be323be0..8ca84fd7c1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2005,6 +2005,100 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect( + "keeps exact admission pending while a provider reports a running prior turn", + () => + Effect.gen(function* () { + const sendEntered = yield* Deferred.make(); + const releaseSend = yield* Deferred.make(); + const requestId = CommandId.make("cmd-running-provider-exact-admission"); + const messageId = asMessageId("message-running-provider-exact-admission"); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Effect.succeed({ ...session, status: "running" }), + sendTurnEffect: () => + Deferred.succeed(sendEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseSend)), + Effect.as({ threadId: ThreadId.make("thread-1"), turnId: asTurnId("new-turn") }), + ), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: requestId, + threadId: ThreadId.make("thread-1"), + message: { messageId, role: "user", text: "follow up", attachments: [] }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: isoAt(0), + }); + yield* Deferred.await(sendEntered); + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.pendingTurnRequestId).toBe(requestId); + expect(thread?.session?.pendingTurnMessageId).toBe(messageId); + + yield* Deferred.succeed(releaseSend, undefined); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.activeTurnId === asTurnId("new-turn"); + }), + ); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.activeTurnRequestId).toBe(requestId); + expect(thread?.session?.pendingTurnRequestId).toBeUndefined(); + }), + ); + + for (const status of ["error", "closed"] as const) { + effectIt.effect(`rejects exact admission when the provider session is ${status}`, () => + Effect.gen(function* () { + const requestId = CommandId.make(`cmd-terminal-provider-${status}`); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Effect.succeed({ ...session, status }), + }), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: requestId, + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId(`message-terminal-provider-${status}`), + role: "user", + text: "follow up", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: isoAt(0), + }); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }), + ); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.failedTurnRequestId).toBe(requestId); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + } + effectIt.effect("does not disarm when raw turn start ingestion never accepts the CAS", () => Effect.gen(function* () { const testClock = yield* TestClock.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e5fcf3d49c..fd9d22bb73 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1048,10 +1048,23 @@ const make = Effect.gen(function* () { detail: `Provider session '${session.threadId}' started without a provider instance or session incarnation id.`, }); } + if ( + options?.pendingTurnStart === true && + (session.status === "error" || session.status === "closed") + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(session.provider), + method: "thread.turn.start", + detail: `Provider session '${session.threadId}' is ${session.status}; it cannot accept a new turn.`, + }); + } const sessionBinding: OrchestrationSession = { threadId, + // The provider can still report the previous turn as running while + // a new exact admission is reserved. The reservation owns the + // projected lifecycle until its matching turn.started is accepted. status: - options?.pendingTurnStart === true && session.status === "ready" + options?.pendingTurnStart === true ? "starting" : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9bb1d25e74..91cdbb9373 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1663,6 +1663,27 @@ describe("ProviderRuntimeIngestion", () => { expect(thread?.session?.pendingTurnRequestId).toBe(requestId); expect(thread?.messages.some((message) => message.role === "assistant")).toBe(false); + // The prior turn can finish on the same provider session after the new + // admission is reserved. Its terminal event must not clear that pending + // admission before the matching turn.started arrives. + await harness.emitAndDrain([ + { + type: "turn.completed", + eventId: asEventId("evt-runtime-session-b-prior-turn-completed"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + turnId: asTurnId("turn-runtime-session-b-prior"), + admissionRequestId: CommandId.make("cmd-runtime-session-b-prior"), + sessionIncarnationId: sessionB, + createdAt: "2026-01-01T00:00:02.500Z", + payload: { state: "completed" }, + }, + ]); + thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.pendingTurnRequestId).toBe(requestId); + const currentTurnId = asTurnId("turn-runtime-session-b"); harness.emit({ type: "turn.started", diff --git a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts index f60a02abe8..79b2158d40 100644 --- a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts +++ b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts @@ -199,6 +199,12 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { readModel: makeReadModel(current), }); expect(stale).toEqual([]); + + const runningWithPendingRequest = yield* decideOrchestrationCommand({ + command: { ...command, session: { ...acceptedSession, status: "running" } }, + readModel: makeReadModel(current), + }); + expect(runningWithPendingRequest).toEqual([]); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 8c9a180a60..5afe8eb9a2 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2629,6 +2629,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.session.activeTurnId !== null || command.session.pendingTurnRequestId !== command.requestId || command.session.pendingTurnMessageId !== command.messageId || + command.session.status !== "starting" || command.session.providerInstanceId !== command.modelSelection.instanceId || command.session.runtimeMode !== command.runtimeMode || command.session.sessionIncarnationId === undefined || From a8d90411ddb7d9432bb605b02798e65ee013bbf1 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:19:23 -0600 Subject: [PATCH 03/18] feat(chat): deliver buffered prose at natural boundaries (#757) --- .../Layers/ProviderRuntimeIngestion.test.ts | 250 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 92 ++++++- apps/web/src/components/ChatMarkdown.tsx | 11 +- apps/web/src/index.css | 13 + 4 files changed, 362 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 91cdbb9373..dd5dd4f3dc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -68,6 +68,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive, runtimeEventToActivities, + splitBufferedAssistantText, } from "./ProviderRuntimeIngestion.ts"; import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -488,7 +489,23 @@ describe("ProviderRuntimeIngestion", () => { }); }), ).pipe(Layer.provide(projectionSnapshotLayer)); + // Advance pacing deterministically while keeping real sleeps and deadlines. + let clockOffsetMs = 0; + const realClock = Effect.runSync(Effect.service(Clock.Clock)); + const shiftedClock: Clock.Clock = { + currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs, + currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs), + currentTimeNanosUnsafe: () => + realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + currentTimeNanos: Effect.sync( + () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + ), + monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: realClock.monotonicTimeNanos, + sleep: (duration) => realClock.sleep(duration), + }; const layer = ProviderRuntimeIngestionLive.pipe( + Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the @@ -587,6 +604,9 @@ describe("ProviderRuntimeIngestion", () => { dispatch, readModel: () => testRuntime.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, + advanceClock: (ms: number) => { + clockOffsetMs += ms; + }, emitAndDrain, sqlCount: sqlCounter.count, readTurn: (turnId: TurnId) => @@ -4986,6 +5006,145 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("delivers finished paragraphs while the rest of the message stays buffered", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paragraph-flush"); + const itemId = asItemId("item-paragraph-flush"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paragraph-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + + // Each delta lands well outside the pacing window of the one before. + const emitDelta = (eventId: string, delta: string) => { + harness.advanceClock(1_000); + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + }; + + emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); + const afterFirst = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`, + ), + ); + expect( + afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`), + ).toMatchObject({ + text: "First paragraph.\n\n", + streaming: true, + }); + + // An open code block holds the whole block until its closing fence lands. + emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); + await harness.drain(); + expect( + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text, + ).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + + emitDelta("evt-paragraph-3", "```\n\nTail without newline"); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-paragraph-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + const finalThread = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && !message.streaming, + ), + ); + expect( + finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`) + ?.text, + ).toBe( + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline", + ); + }); + + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { + const harness = await createHarness(); + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paced"); + const itemId = asItemId("item-paced"); + // Every delta carries the same event time, like OpenCode does for one + // part. Pacing must follow the server clock, not the event stamp. + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paced-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + // Emit is fire-and-forget, so drain after each delta before moving the + // clock. Otherwise the worker reads a clock that has already advanced. + let clockMs = 0; + const emitDelta = async (eventId: string, delta: string, offsetMs: number) => { + harness.advanceClock(offsetMs - clockMs); + clockMs = offsetMs; + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }, + ]); + }; + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + + await emitDelta("evt-paced-1", "One.\n\n", 0); + await emitDelta("evt-paced-2", "Two.\n\n", 100); + await emitDelta("evt-paced-3", "Three.\n\n", 200); + // The first paragraph lands right away. The next two are inside the window. + expect(await messageText()).toBe("One.\n\n"); + + await emitDelta("evt-paced-4", "Four.\n\n", 500); + expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -6818,3 +6977,94 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + +describe("splitBufferedAssistantText", () => { + it("keeps a partial trailing line buffered", () => { + expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" }); + expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); + }); + + it("does not split inside an open fence and delivers the block at its closing fence", () => { + const open = "intro\n\n```\ncode\n\nmore\n"; + expect(splitBufferedAssistantText(open)).toEqual({ + ready: "intro\n\n", + rest: "```\ncode\n\nmore\n", + }); + expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({ + ready: `${open}\`\`\`\n`, + rest: "after", + }); + }); + + it("does not treat a fence with an info string as a closing fence", () => { + const text = "```\n```javascript\nstill code\n\nmore\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + }); + + it("treats a fence indented four or more spaces as code, not a closing fence", () => { + const text = "```\n ```\n\nstill code\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({ + ready: "```\n ```\n", + rest: "after", + }); + }); + + it("keeps a fence nested under a list item open across its blank lines", () => { + const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n", + rest: "after\n", + }); + }); + + it("does not treat a no-break-space line as blank", () => { + expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({ + ready: "para\n\u00a0\ncont\n\n", + rest: "next", + }); + }); + + it("treats CRLF blank lines as boundaries", () => { + expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ + ready: "one\r\n\r\n", + rest: "two", + }); + }); + + it("only closes a fence with the same marker of equal or greater length", () => { + const text = "````\n```\nstill code\n\n````\n\nout\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "````\n```\nstill code\n\n````\n\n", + rest: "out\n", + }); + expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({ + ready: "", + rest: "~~~\n```\n\nx\n", + }); + }); + + it("delivers tight list items one at a time", () => { + expect(splitBufferedAssistantText("## Steps\n\n- one\n- two\n- thr")).toEqual({ + ready: "## Steps\n\n- one\n- two\n", + rest: "- thr", + }); + expect(splitBufferedAssistantText("1. one\n2. two\n more\n3. t")).toEqual({ + ready: "1. one\n2. two\n more\n", + rest: "3. t", + }); + }); + + it("keeps a partial list marker and list-like code buffered", () => { + expect(splitBufferedAssistantText("intro\n-")).toEqual({ ready: "", rest: "intro\n-" }); + expect(splitBufferedAssistantText("intro\n1.")).toEqual({ ready: "", rest: "intro\n1." }); + // `intro\n- \n` would parse as a setext heading, so a bare marker with only + // trailing whitespace is not a boundary on the partial line either. + expect(splitBufferedAssistantText("intro\n- ")).toEqual({ ready: "", rest: "intro\n- " }); + expect(splitBufferedAssistantText("- one\n")).toEqual({ ready: "", rest: "- one\n" }); + expect(splitBufferedAssistantText("```\n- one\n- two\n")).toEqual({ + ready: "", + rest: "```\n- one\n- two\n", + }); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7aea6f4d5d..4012a4089f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -27,6 +27,7 @@ import { } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -158,6 +159,7 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; +const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< @@ -268,6 +270,51 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } +// A closing fence may be indented at most three spaces more than its opener. +const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; +const BLANK_LINE_PATTERN = /^[ \t]*$/; +// A complete list marker lets the previous item land before the next is done. +const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; + +/** Return text whose Markdown shape is complete, leaving the active block buffered. */ +export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { + let openFence: { marker: string; indent: number } | null = null; + let boundary = -1; + let lineStart = 0; + for (;;) { + const newline = text.indexOf("\n", lineStart); + const line = text + .slice(lineStart, newline === -1 ? text.length : newline) + .replace(/[ \t\r]+$/, ""); + if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { + boundary = lineStart; + } + if (newline === -1) break; + const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (fenceMatch) { + const indent = fenceMatch[1]!.length; + const marker = fenceMatch[2]!; + if (openFence === null) { + openFence = { marker, indent }; + } else if ( + marker[0] === openFence.marker[0] && + marker.length >= openFence.marker.length && + indent <= openFence.indent + 3 && + line.length === indent + marker.length + ) { + openFence = null; + boundary = newline + 1; + } + } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { + boundary = newline + 1; + } + lineStart = newline + 1; + } + return boundary === -1 + ? { ready: "", rest: text } + : { ready: text.slice(0, boundary), rest: text.slice(boundary) }; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -1618,6 +1665,11 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(""), }); + const lastAssistantDeliveryAtByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(0), + }); const assistantSegmentStateByTurnKey = yield* Cache.make({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1809,7 +1861,7 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string) => + const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1817,6 +1869,30 @@ const make = Effect.gen(function* () { onNone: () => delta, onSome: (text) => `${text}${delta}`, }); + const { ready, rest } = splitBufferedAssistantText(nextText); + const lastDeliveredAt = Option.getOrUndefined( + yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), + ); + if ( + (lastDeliveredAt === undefined || + atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS) && + hasRenderableAssistantText(ready) && + rest.length <= MAX_BUFFERED_ASSISTANT_CHARS + ) { + if (rest.length > 0) { + yield* commitRuntimeMutation( + Cache.set(bufferedAssistantTextByMessageId, messageId, rest), + ); + } else { + yield* commitRuntimeMutation( + Cache.invalidate(bufferedAssistantTextByMessageId, messageId), + ); + } + yield* commitRuntimeMutation( + Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis), + ); + return ready; + } if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { yield* commitRuntimeMutation( Cache.set(bufferedAssistantTextByMessageId, messageId, nextText), @@ -1843,7 +1919,11 @@ const make = Effect.gen(function* () { ); const clearBufferedAssistantText = (messageId: MessageId) => - commitRuntimeMutation(Cache.invalidate(bufferedAssistantTextByMessageId, messageId)); + commitRuntimeMutation(Cache.invalidate(bufferedAssistantTextByMessageId, messageId)).pipe( + Effect.andThen( + commitRuntimeMutation(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)), + ), + ); const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( @@ -2752,7 +2832,13 @@ const make = Effect.gen(function* () { : "buffered", ); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + // Provider event timestamps may be fixed for an entire part; pace + // delivery by the ingestion clock instead. + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + yield* Clock.currentTimeMillis, + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9ef4f6138d..26e4410c2f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3307,7 +3307,15 @@ const CHAT_MARKDOWN_COMPONENTS = { resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} fallback={
{children}
} > - {children}}> + {/* Keep the block's dimensions while highlighting loads so a + newly delivered code block does not flash as plain text. */} + + {children} + + } + > diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 90c63f62ba..d7c49958ff 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1911,6 +1911,19 @@ code { background: transparent !important; } +/* A completed block can arrive before the assistant finishes. Animate only + fresh live blocks, and honor Pylon's panel-motion and reduced-motion controls. */ +@media (prefers-reduced-motion: no-preference) { + [data-panel-animations="true"] .chat-markdown[data-streaming] > *, + [data-panel-animations="true"] .chat-markdown[data-streaming] .chat-markdown-shiki { + transition: opacity var(--panel-animation-duration, 600ms) ease-out; + + @starting-style { + opacity: 0; + } + } +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single From 9cadbaae264e45e4bbb049b3c62d69864bed53bb Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:19:26 -0600 Subject: [PATCH 04/18] fix(server): show Claude subagent tool progress (#745) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 66 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 9 ++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 028322f50c..4565e8dccd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -10179,4 +10179,70 @@ describe("ClaudeAdapterLive", () => { Effect.provide(harness.layer), ); }); + + it.effect("attributes live subagent tool heartbeats when Claude omits task_id", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const progressFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "tool.progress"), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "Delegate work" }); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-heartbeat", + description: "Inspect code", + task_type: "local_agent", + tool_use_id: "tool-task-heartbeat", + uuid: "task-heartbeat-start", + session_id: "sdk-heartbeat", + } as unknown as SDKMessage); + const heartbeat = (uuid: string, parentToolUseId: string, taskId?: string) => + harness.query.emit({ + type: "tool_progress", + tool_use_id: `tool-${uuid}`, + tool_name: "Bash", + elapsed_time_seconds: 30, + parent_tool_use_id: parentToolUseId, + ...(taskId ? { task_id: taskId } : {}), + uuid, + session_id: "sdk-heartbeat", + } as unknown as SDKMessage); + heartbeat("live", "tool-task-heartbeat"); + heartbeat("explicit", "tool-task-heartbeat", "task-explicit"); + heartbeat("unknown", "tool-unknown"); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-heartbeat", + status: "completed", + uuid: "task-heartbeat-end", + session_id: "sdk-heartbeat", + } as unknown as SDKMessage); + heartbeat("late", "tool-task-heartbeat"); + + const progress = Array.from(yield* Fiber.join(progressFiber)); + assert.deepEqual( + progress.map((event) => + event.type === "tool.progress" && event.payload.taskId + ? String(event.payload.taskId) + : undefined, + ), + ["task-heartbeat", "task-explicit", undefined, undefined], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3f74b6b855..e45474b80a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4346,6 +4346,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; if (message.type === "tool_progress") { + // Claude omits task_id on subagent tool heartbeats but includes the + // launching Task tool's id. Recover only a still-live agent: a late + // heartbeat for a completed task must not revive its activity row. + const owningTaskId = agentIdForParentToolUse(context.taskAgents, message.parent_tool_use_id); + const taskId = + message.task_id ?? + (owningTaskId && context.liveTaskIds.has(owningTaskId) ? owningTaskId : undefined); yield* offerRuntimeEvent(context.sessionIncarnationId, { ...base, type: "tool.progress", @@ -4353,7 +4360,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( toolUseId: message.tool_use_id, toolName: message.tool_name, elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}), + ...(taskId ? { taskId: RuntimeTaskId.make(taskId) } : {}), ...(message.parent_tool_use_id !== null ? { parentToolUseId: message.parent_tool_use_id } : {}), From a675fa8db072199d25339fb1fe7069c40e26e073 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:19:29 -0600 Subject: [PATCH 05/18] fix(server): show Relay workers from installed CLI results (#744) --- .../orchestration/RelayWorkerBridge.test.ts | 259 ++++++++++++++++- .../src/orchestration/RelayWorkerBridge.ts | 265 ++++++++++++++++-- 2 files changed, 491 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration/RelayWorkerBridge.test.ts b/apps/server/src/orchestration/RelayWorkerBridge.test.ts index 727e5260ce..ab4a135e61 100644 --- a/apps/server/src/orchestration/RelayWorkerBridge.test.ts +++ b/apps/server/src/orchestration/RelayWorkerBridge.test.ts @@ -213,6 +213,65 @@ describe("RelayWorkerBridge direct receipts", () => { ), ).toEqual([]); }); + + it("binds the installed Claude plugin's unversioned job result", () => { + const result = { + type: "tool_result", + content: [ + { + type: "text", + text: JSON.stringify({ + id: jobId, + status: "queued", + providerType: "codex", + createdAt: "2026-09-24T00:00:00.000Z", + }), + }, + ], + }; + expect( + relayBindingFromToolEvent( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", + result, + }), + ), + ).toEqual([ + { kind: "job", id: jobId, threadId: "thread-a", turnId: "turn-a", toolCallId: "tool-a" }, + ]); + expect( + relayBindingFromToolEvent( + toolEvent({ + toolName: "mcp__relay__relay_delegate", + result, + }), + ), + ).toEqual([ + { kind: "job", id: jobId, threadId: "thread-a", turnId: "turn-a", toolCallId: "tool-a" }, + ]); + expect( + relayBindingFromToolEvent( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_panel", + result: { + type: "tool_result", + content: [ + { + type: "text", + text: JSON.stringify({ + id: panelId, + jobs: [jobId], + unstarted: [{ index: 1 }], + }), + }, + ], + }, + }), + ), + ).toEqual([ + { kind: "panel", id: panelId, threadId: "thread-a", turnId: "turn-a", toolCallId: "tool-a" }, + ]); + }); }); describe("Relay observe v1", () => { @@ -267,7 +326,10 @@ const args = process.argv.slice(2); calls.push(args); fs.writeFileSync(callsPath, JSON.stringify(calls)); const id = args[0] === "observe" && args[1] === "--panel" ? args[2] : args[1]; -if (args[0] === "observe" && state[id]) console.log(JSON.stringify(state[id])); +if (args[0] === "observe" && state[id]?.schemaVersion === 1) console.log(JSON.stringify(state[id])); +else if (args[0] === "observe" && state[id]) console.log(JSON.stringify({ usage: "relay init | status [id]", request: "JSON from --request file" })); +else if (args[0] === "status" && state[id]) console.log(JSON.stringify(state[id])); +else if (args[0] === "panel-result" && state[id]) console.log(JSON.stringify(state[id])); else if (args[0] === "cancel" && state[id]) console.log(JSON.stringify({id, status: "cancelling"})); else process.exit(1); `, @@ -362,14 +424,11 @@ persistence("Relay persisted observer and controls", (it) => { toolCallId: "tool-a", status: "completed", data: { - toolName: "mcp__relay__relay_delegate", + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", result: { type: "tool_result", tool_use_id: "tool-a", - content: encodeJson({ - content: [{ type: "text", text: "Worker dispatched" }], - structuredContent: { schemaVersion: 1, kind: "job", jobId, attempt: 1 }, - }), + content: [{ type: "text", text: encodeJson({ id: jobId, status: "queued" }) }], }, }, }; @@ -395,6 +454,55 @@ persistence("Relay persisted observer and controls", (it) => { effort: "high", cancellable: true, }); + const legacyJobId = "job-77777777-7777-4777-8777-777777777777"; + cli.setState({ + [jobId]: job, + [legacyJobId]: { + id: legacyJobId, + status: "running", + providerType: "codex", + model: "gpt-6-sol", + effort: "low", + createdAt: "2026-09-24T00:00:00.000Z", + updatedAt: "2026-09-24T00:00:01.000Z", + }, + }); + yield* bridge.recordToolResult( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", + itemId: "legacy-tool", + turnId: "legacy-turn", + result: { + type: "tool_result", + content: [ + { + type: "text", + text: encodeJson({ + id: legacyJobId, + status: "queued", + providerType: "codex", + }), + }, + ], + }, + }), + ); + yield* bridge.reconcile; + const legacyRows = yield* sql` + SELECT turn_id AS turnId, payload_json AS payload + FROM projection_thread_activities + WHERE kind = 'task.started' + AND json_extract(payload_json, '$.taskId') = ${`relay:${legacyJobId}`} + `; + expect(legacyRows).toHaveLength(1); + expect(legacyRows[0]?.turnId).toBe("legacy-turn"); + expect(decodeJson(legacyRows[0]?.payload)).toMatchObject({ + taskId: `relay:${legacyJobId}`, + source: "relay", + model: "gpt-6-sol", + effort: "low", + toolUseId: "legacy-tool", + }); const wrongThread = yield* Effect.result( bridge.cancel(ThreadId.make("thread-b"), `relay:${jobId}`), ); @@ -412,7 +520,7 @@ persistence("Relay persisted observer and controls", (it) => { yield* unavailable.reconcile; yield* unavailable.reconcile; const idleRows = - yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable'`; + yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`}`; expect(idleRows).toHaveLength(1); expect(decodeJson(idleRows[0]?.payload)).toMatchObject({ taskId: `relay:${jobId}`, @@ -442,6 +550,7 @@ persistence("Relay persisted observer and controls", (it) => { const repeatedOutage = yield* sql` SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' + AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`} ORDER BY sequence `; expect(repeatedOutage).toHaveLength(2); @@ -466,7 +575,7 @@ persistence("Relay persisted observer and controls", (it) => { }); yield* reconnected.reconcile; const completed = - yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.completed'`; + yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.completed' AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`}`; expect(completed).toHaveLength(1); expect(decodeJson(completed[0]?.payload)).toMatchObject({ taskId: `relay:${jobId}`, @@ -515,6 +624,7 @@ persistence("Relay persisted observer and controls", (it) => { const pendingResume = yield* sql` SELECT turn_id AS turnId, payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND activity_id LIKE 'relay-observer-unavailable:%' + AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`} ORDER BY sequence DESC LIMIT 1 `; expect(pendingResume).toHaveLength(1); @@ -530,7 +640,7 @@ persistence("Relay persisted observer and controls", (it) => { cli.setState({ [jobId]: { ...job, attempt: 2, sequence: 1, status: "running" } }); yield* resumedBridge.reconcile; const resumed = - yield* sql`SELECT turn_id AS turnId, payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.started' ORDER BY created_at`; + yield* sql`SELECT turn_id AS turnId, payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.started' AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`} ORDER BY created_at`; expect(resumed).toHaveLength(2); expect(decodeJson(resumed[1]?.payload)).toMatchObject({ taskId: `relay:${jobId}`, @@ -944,12 +1054,143 @@ persistence("Relay persisted observer and controls", (it) => { Effect.provideService(ServerEnvironment, fakeEnvironment), Effect.provideService(ThreadBackgroundLivenessService, liveness), ); + const callsBeforeBoot = cli.calls().length; yield* bootAfterChurn.reconcile; + const bootCalls = cli.calls().slice(callsBeforeBoot); + const observed = (id: string) => + bootCalls.findIndex((args) => args[0] === "observe" && args[1] === id); + expect(observed(legacyJobId)).toBeGreaterThan(-1); + expect(observed(completedPanelJobId)).toBeGreaterThan(-1); + expect(observed(completedPanelJobId)).toBeLessThan(observed(legacyJobId)); expect( cli .calls() .filter((args) => args[0] === "observe" && args[1]?.startsWith("job-00000000-")), ).toHaveLength(0); + + const missingId = "job-cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ( + ${`relay-binding:${missingId}`}, 'thread-a', 'turn-missing', 'info', + 'relay.binding', 'Relay worker bound', + ${encodeJson({ kind: "job", id: missingId, toolCallId: "tool-missing", environmentId: "test-environment" })}, + 9999, '2026-09-24T00:00:00.000Z' + ) + `; + const missingBridge = yield* makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, liveness), + ); + yield* missingBridge.reconcile; + yield* missingBridge.reconcile; + yield* missingBridge.reconcile; + const missingRows = yield* sql` + SELECT payload_json AS payload FROM projection_thread_activities + WHERE kind = 'task.progress' AND json_extract(payload_json, '$.taskId') = ${`relay:${missingId}`} + `; + expect(missingRows).toHaveLength(1); + expect(decodeJson(missingRows[0]?.payload)).toMatchObject({ + status: "idle", + summary: "Relay observer unavailable", + cancellable: false, + }); + const missingPolls = cli + .calls() + .filter((args) => args[0] === "observe" && args[1] === missingId).length; + yield* missingBridge.reconcile; + expect( + cli.calls().filter((args) => args[0] === "observe" && args[1] === missingId), + ).toHaveLength(missingPolls); + + const legacyPanelId = "panel-88888888-8888-4888-8888-888888888888"; + const priorId = "job-99999999-9999-4999-8999-999999999999"; + const currentId = "job-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const otherId = "job-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const legacyDate = "2026-09-24T00:00:01.000Z"; + const legacyJobs = [ + { + id: currentId, + status: "running", + panelId: legacyPanelId, + panelMemberIndex: 0, + providerType: "codex", + createdAt: legacyDate, + updatedAt: legacyDate, + }, + { + id: otherId, + status: "running", + panelId: legacyPanelId, + panelMemberIndex: 1, + providerType: "claude-alt", + createdAt: legacyDate, + updatedAt: legacyDate, + }, + ]; + cli.setState({ + [legacyPanelId]: { + id: legacyPanelId, + request: { members: [{}, {}] }, + jobs: legacyJobs, + previousJobs: [priorId], + unstarted: [], + complete: false, + createdAt: legacyDate, + }, + [priorId]: { + id: priorId, + status: "failed", + panelId: legacyPanelId, + panelMemberIndex: 0, + createdAt: legacyDate, + updatedAt: legacyDate, + }, + [currentId]: legacyJobs[0], + [otherId]: legacyJobs[1], + }); + yield* bootAfterChurn.recordToolResult( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_panel", + itemId: "legacy-panel-tool", + turnId: "legacy-panel-turn", + result: { + type: "tool_result", + content: [ + { + type: "text", + text: encodeJson({ + id: legacyPanelId, + jobs: [currentId, otherId], + unstarted: [], + }), + }, + ], + }, + }), + ); + yield* bootAfterChurn.reconcile; + const legacyMemberId = `relay-panel:${legacyPanelId}:member:0`; + const legacyMember = yield* sql` + SELECT turn_id AS turnId, payload_json AS payload + FROM projection_thread_activities WHERE kind = 'task.started' + AND json_extract(payload_json, '$.taskId') = ${legacyMemberId} + `; + expect(legacyMember).toHaveLength(1); + expect(legacyMember[0]?.turnId).toBe("legacy-panel-turn"); + expect(decodeJson(legacyMember[0]?.payload)).toMatchObject({ + taskId: legacyMemberId, + attempt: 2_000_001, + parentAgentId: `relay-panel:${legacyPanelId}`, + }); + expect( + (yield* bootAfterChurn.cancel(ThreadId.make("thread-a"), legacyMemberId)).disposition, + ).toBe("cancel-requested"); + expect(cli.calls().some((args) => args[0] === "cancel" && args[1] === currentId)).toBe( + true, + ); }).pipe(Effect.ensuring(Effect.sync(() => cli.cleanup()))); }, ); diff --git a/apps/server/src/orchestration/RelayWorkerBridge.ts b/apps/server/src/orchestration/RelayWorkerBridge.ts index cd05b9e9b4..1671b3fea6 100644 --- a/apps/server/src/orchestration/RelayWorkerBridge.ts +++ b/apps/server/src/orchestration/RelayWorkerBridge.ts @@ -17,6 +17,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; @@ -154,7 +155,7 @@ function relayReceiptFromToolEvent(event: ProviderRuntimeEvent): RelayToolReceip return undefined; } const result = item?.result ?? data?.result; - const structured = relayStructuredResult(result); + const structured = relayStructuredResult(result) ?? relayLegacyResult(result); if (!structured || structured.schemaVersion !== 1) return undefined; const base = { threadId: event.threadId, @@ -193,6 +194,48 @@ function relayReceiptFromToolEvent(event: ProviderRuntimeEvent): RelayToolReceip return undefined; } +/** The installed Claude plugin still returns its job/panel record as text. */ +function relayLegacyResult(value: unknown): Record | undefined { + const object = record(value); + const content = object?.content; + if (!Array.isArray(content) || content.length > 4) return undefined; + const text = record(content[0])?.text; + if (typeof text !== "string" || text.length > MAX_MCP_RECEIPT_CODE_UNITS) return undefined; + try { + const parsed = record(JSON.parse(text)); + if (!parsed) return undefined; + if ( + typeof parsed.id === "string" && + JOB_ID.test(parsed.id) && + typeof parsed.status === "string" && + parsed.status !== "not_dispatched" + ) { + return { + schemaVersion: 1, + kind: "job", + jobId: parsed.id, + ...(nonNegativeInteger(parsed.attempt) !== undefined ? { attempt: parsed.attempt } : {}), + }; + } + if ( + typeof parsed.id === "string" && + PANEL_ID.test(parsed.id) && + Array.isArray(parsed.jobs) && + parsed.jobs.length <= 8 + ) { + return { + schemaVersion: 1, + kind: "panel", + panelId: parsed.id, + jobIds: parsed.jobs.filter((id): id is string => typeof id === "string" && JOB_ID.test(id)), + }; + } + } catch { + /* Invalid plugin text is not a dispatch receipt. */ + } + return undefined; +} + function relayStructuredResult(value: unknown, depth = 0): Record | undefined { if (depth > 4) return undefined; const object = record(value); @@ -280,6 +323,79 @@ export function parseObservation( return undefined; } +function legacyObservation( + value: unknown, + expectedId: string, + priorJobSlots: ReadonlyMap = new Map(), +): RelayJobObservation | RelayPanelObservation | undefined { + const object = record(value); + if (!object || object.id !== expectedId) return undefined; + if (JOB_ID.test(expectedId)) { + const attempt = nonNegativeInteger(object.attempt) ?? 1; + const sequence = Date.parse(String(object.updatedAt ?? object.createdAt)); + if (attempt < 1 || !Number.isSafeInteger(sequence) || typeof object.status !== "string") + return undefined; + return parseObservation( + { + schemaVersion: 1, + kind: "job", + id: expectedId, + attempt, + sequence, + status: object.status, + providerType: object.providerType, + model: object.actualModel ?? object.model, + effort: object.effort, + panelId: object.panelId, + panelMemberIndex: object.panelMemberIndex, + createdAt: object.createdAt, + updatedAt: object.updatedAt, + }, + expectedId, + ); + } + if ( + !PANEL_ID.test(expectedId) || + !Array.isArray(object.jobs) || + !Array.isArray(object.unstarted) || + object.jobs.length > 8 + ) + return undefined; + const request = record(object.request); + const requested = request?.members; + if (!Array.isArray(requested) || requested.length < 2 || requested.length > 8) return undefined; + const jobs = object.jobs.map(record); + const members = requested.map((_, index) => { + const prior = Array.isArray(object.previousJobs) ? object.previousJobs : []; + const slotAttempt = 1 + prior.filter((id) => priorJobSlots.get(id) === index).length; + const job = jobs.find((candidate) => candidate?.panelMemberIndex === index); + const jobId = typeof job?.id === "string" && JOB_ID.test(job.id) ? job.id : null; + return { + index, + slotAttempt, + state: jobId ? "started" : "unstarted", + jobId, + ...(jobId ? { job: legacyObservation(job, jobId) } : {}), + }; + }); + return parseObservation( + { + schemaVersion: 1, + kind: "panel", + id: expectedId, + createdAt: object.createdAt, + complete: object.complete === true, + members, + }, + expectedId, + ); +} + +function legacyObserveUnavailable(value: unknown): boolean { + const object = record(value); + return typeof object?.usage === "string" && typeof object.request === "string"; +} + function observationFingerprint(observation: RelayJobObservation | RelayPanelObservation): string { return NodeCrypto.createHash("sha256") .update(JSON.stringify(observation)) @@ -363,6 +479,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu const seen = new Map(); const currentPanelJobs = new Map>(); const failedObservations = new Map(); + const missingObservationRetryAt = new Map(); const unavailable = new Set(); const outageEpochs = new Map(); const pendingPanelDispatchPolls = new Map(); @@ -370,6 +487,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu string, { attempt: number; usage: RelayUsageRollup | undefined } >(); + const legacyPriorJobSlots = new Map>(); const append = Effect.fn("RelayWorkerBridge.append")(function* ( binding: RelayBinding, @@ -457,14 +575,14 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }); const readBindingPage = Effect.fn("RelayWorkerBridge.readBindingPage")(function* ( - afterRowId: number, + beforeRowId: number, ) { const rows = yield* sql` SELECT rowid AS rowId, thread_id AS threadId, turn_id AS turnId, payload_json AS payload FROM projection_thread_activities - WHERE rowid > ${afterRowId} AND kind = 'relay.binding' + WHERE rowid < ${beforeRowId} AND kind = 'relay.binding' AND json_extract(payload_json, '$.environmentId') = ${environmentId} - ORDER BY rowid ASC LIMIT 256 + ORDER BY rowid DESC LIMIT 256 `; return rows.map((raw) => ({ rowId: nonNegativeInteger(record(raw)?.rowId) ?? 0, @@ -729,19 +847,57 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }, ); - const runCli = Effect.fn("RelayWorkerBridge.runCli")(function* (args: ReadonlyArray) { + const runCli = Effect.fn("RelayWorkerBridge.runCli")(function* ( + args: ReadonlyArray, + timeout = 12_000, + ) { if (!enabled || !cliPath) return Option.none<{ stdout: string; stderr: string }>(); return yield* Effect.tryPromise({ try: () => execFile(process.execPath, [cliPath, ...args], { - timeout: 12_000, - maxBuffer: MAX_OBSERVE_BYTES, + timeout, + maxBuffer: args[0] === "panel-result" ? MAX_MCP_RECEIPT_CODE_UNITS : MAX_OBSERVE_BYTES, windowsHide: true, }), catch: () => undefined, }).pipe(Effect.option); }); + const parseLegacyCliObservation = Effect.fn("RelayWorkerBridge.parseLegacyCliObservation")( + function* (value: unknown, id: string) { + if (!PANEL_ID.test(id)) return legacyObservation(value, id); + const previousJobs = record(value)?.previousJobs; + if (!Array.isArray(previousJobs) || previousJobs.length > 64) return undefined; + const previousIds = previousJobs.filter( + (previousId): previousId is string => + typeof previousId === "string" && JOB_ID.test(previousId), + ); + if (previousIds.length !== previousJobs.length) return undefined; + const priorSlots = legacyPriorJobSlots.get(id) ?? new Map(); + const missing = previousIds.filter((previousId) => !priorSlots.has(previousId)); + // Old retry records are immutable. Cache resolved slots and bound an + // unavailable history lookup so a current panel cannot stall the sweep. + const resolved = yield* Effect.forEach( + missing, + (previousId) => + Effect.gen(function* () { + const result = yield* runCli(["status", previousId], 2_000); + const decoded = Option.isSome(result) ? decodeJson(result.value.stdout) : Option.none(); + const job = Option.isSome(decoded) ? record(decoded.value) : undefined; + const index = nonNegativeInteger(job?.panelMemberIndex); + return job?.id === previousId && job.panelId === id && index !== undefined && index <= 7 + ? ([previousId, index] as const) + : undefined; + }), + { concurrency: 8 }, + ); + if (resolved.some((entry) => entry === undefined)) return undefined; + for (const entry of resolved) if (entry) priorSlots.set(entry[0], entry[1]); + legacyPriorJobSlots.set(id, priorSlots); + return legacyObservation(value, id, priorSlots); + }, + ); + const readLatestTaskActivity = Effect.fn("RelayWorkerBridge.readLatestTaskActivity")(function* ( binding: RelayBinding, ) { @@ -964,7 +1120,35 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu const payload = latest.payload; const completedAttempt = nonNegativeInteger(payload?.attempt); const completedSequence = nonNegativeInteger(payload?.relaySequence); - if (completedAttempt === undefined || completedSequence === undefined) return; + if (completedAttempt === undefined || completedSequence === undefined) { + // A recovered receipt may outlive its Relay files. Retain the binding + // for a later return, but do not recheck every missing historical job + // on every sweep ahead of live workers. + if (!unavailable.has(binding.id)) { + yield* append( + binding, + "task.progress", + { + taskId, + title: binding.kind === "panel" ? "Relay panel" : "Relay worker", + attempt: 1, + relaySequence: 0, + outageEpoch: 1, + status: "idle", + summary: "Relay observer unavailable", + detail: "Relay observer unavailable", + ...bindingPayload(binding), + cancellable: false, + }, + "Relay observer unavailable", + `relay-observer-unavailable:${binding.id}:1`, + "1:0:1", + ); + unavailable.add(binding.id); + } + missingObservationRetryAt.set(binding.id, (yield* Clock.currentTimeMillis) + 60_000); + return; + } const pending = latest.kind === "task.completed" ? yield* pendingGenerationAfter(binding, latest.rowId, completedAttempt) @@ -972,6 +1156,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu if (latest.kind === "task.completed") { if (!pending) { active.delete(binding.id); + legacyPriorJobSlots.delete(binding.id); seen.delete(binding.id); failedObservations.delete(binding.id); unavailable.delete(binding.id); @@ -1010,16 +1195,27 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }); const poll = Effect.fn("RelayWorkerBridge.poll")(function* (binding: RelayBinding) { + if ((missingObservationRetryAt.get(binding.id) ?? 0) > (yield* Clock.currentTimeMillis)) return; const result = yield* runCli( binding.kind === "job" ? ["observe", binding.id] : ["observe", "--panel", binding.id], ); - if (Option.isNone(result)) return yield* markObserverUnavailable(binding); - const parsed = decodeJson(result.value.stdout); - const observation = parseObservation( + const parsed = Option.isSome(result) ? decodeJson(result.value.stdout) : Option.none(); + let observation = parseObservation( Option.isSome(parsed) ? parsed.value : undefined, binding.id, ); + if (!observation && Option.isSome(parsed) && legacyObserveUnavailable(parsed.value)) { + const legacy = yield* runCli( + binding.kind === "job" ? ["status", binding.id] : ["panel-result", binding.id], + ); + const decoded = Option.isSome(legacy) ? decodeJson(legacy.value.stdout) : Option.none(); + observation = yield* parseLegacyCliObservation( + Option.isSome(decoded) ? decoded.value : undefined, + binding.id, + ); + } if (!observation) return yield* markObserverUnavailable(binding); + missingObservationRetryAt.delete(binding.id); const wasUnavailableInMemory = unavailable.delete(binding.id); const lastPersisted = seen.has(binding.id) ? undefined : yield* readLatestTaskActivity(binding); const wasUnavailable = @@ -1154,6 +1350,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu fingerprint, ); active.delete(binding.id); + legacyPriorJobSlots.delete(binding.id); seen.delete(binding.id); failedObservations.delete(binding.id); unavailable.delete(binding.id); @@ -1165,6 +1362,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu // idle panel. A new continuation or member resume receipt reactivates // observation; polling this unchanged snapshot forever is needless. active.delete(binding.id); + legacyPriorJobSlots.delete(binding.id); seen.delete(binding.id); failedObservations.delete(binding.id); unavailable.delete(binding.id); @@ -1304,12 +1502,14 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu // the crash window after command dispatch and before observer registration. if (!bootstrapped) { yield* recoverPersistedToolReceipts(); - let afterRowId = 0; + // Replay inserts historical bindings in receipt order. Visit newest + // bindings first so current workers project before old missing jobs. + let beforeRowId = Number.MAX_SAFE_INTEGER; for (;;) { - const page = yield* readBindingPage(afterRowId); + const page = yield* readBindingPage(beforeRowId); if (page.length === 0) break; for (const { rowId, binding } of page) { - afterRowId = Math.max(afterRowId, rowId); + beforeRowId = Math.min(beforeRowId, rowId); if (binding && !active.has(binding.id) && (yield* shouldObserveOnBoot(binding, rowId))) { active.set(binding.id, binding); } @@ -1404,13 +1604,25 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu ) return yield* new ProviderCancelSessionAgentError({ reason: "agent-not-active" }); const current = yield* runCli(["observe", "--panel", panelId]); - if (Option.isNone(current)) - return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); - const decodedPanel = decodeJson(current.value.stdout); - const observedPanel = parseObservation( + const decodedPanel = Option.isSome(current) + ? decodeJson(current.value.stdout) + : Option.none(); + let observedPanel = parseObservation( Option.isSome(decodedPanel) ? decodedPanel.value : undefined, panelId, ); + if ( + !observedPanel && + Option.isSome(decodedPanel) && + legacyObserveUnavailable(decodedPanel.value) + ) { + const legacy = yield* runCli(["panel-result", panelId]); + const decoded = Option.isSome(legacy) ? decodeJson(legacy.value.stdout) : Option.none(); + observedPanel = yield* parseLegacyCliObservation( + Option.isSome(decoded) ? decoded.value : undefined, + panelId, + ); + } if (observedPanel?.kind !== "panel") return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); const member = observedPanel.members.find((candidate) => candidate.index === index); @@ -1441,12 +1653,17 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu if (!binding) return yield* new ProviderCancelSessionAgentError({ reason: "agent-not-active" }); const id = binding.id; const observed = yield* runCli(["observe", id]); - if (Option.isNone(observed)) - return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); - const decodedJob = decodeJson(observed.value.stdout); - if ( - parseObservation(Option.isSome(decodedJob) ? decodedJob.value : undefined, id)?.kind !== "job" - ) { + const decodedJob = Option.isSome(observed) ? decodeJson(observed.value.stdout) : Option.none(); + let current = parseObservation(Option.isSome(decodedJob) ? decodedJob.value : undefined, id); + if (!current && Option.isSome(decodedJob) && legacyObserveUnavailable(decodedJob.value)) { + const legacy = yield* runCli(["status", id]); + const decoded = Option.isSome(legacy) ? decodeJson(legacy.value.stdout) : Option.none(); + current = yield* parseLegacyCliObservation( + Option.isSome(decoded) ? decoded.value : undefined, + id, + ); + } + if (current?.kind !== "job") { return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); } const result = yield* runCli(["cancel", id]); From 90129e7e41ae318c5ccffa3c080b0f9639f654a0 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:19:33 -0600 Subject: [PATCH 06/18] fix(relay): accept delegated thread IDs in activity routes (#756) --- .../migration.sql | 2 + .../snapshot.json | 1516 +++++++++++++++++ infra/relay/src/http/Api.test.ts | 127 +- infra/relay/src/http/Api.ts | 6 + infra/relay/src/persistence/schema.ts | 4 +- infra/relay/src/worker.ts | 2 + 6 files changed, 1646 insertions(+), 11 deletions(-) create mode 100644 infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql create mode 100644 infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json diff --git a/infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql new file mode 100644 index 0000000000..1bb8be0559 --- /dev/null +++ b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "relay_agent_activity_rows" ALTER COLUMN "thread_id" SET DATA TYPE varchar(512) USING "thread_id"::varchar(512);--> statement-breakpoint +ALTER TABLE "relay_delivery_attempts" ALTER COLUMN "thread_id" SET DATA TYPE varchar(512) USING "thread_id"::varchar(512); \ No newline at end of file diff --git a/infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json new file mode 100644 index 0000000000..4e56b797b9 --- /dev/null +++ b/infra/relay/migrations/postgres/20260918175607_long_thread_ids/snapshot.json @@ -0,0 +1,1516 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "4b6d0d21-8d78-4499-9dde-b42bcf633d05", + "prevIds": ["1109ed23-036c-4c39-a459-52422a7ffd99"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "android_api_level", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_agent_activity_rows_updated", + "entityType": "indexes", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "thread_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 66b87e643a..bf7bd1f5a4 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -1,4 +1,15 @@ +import * as HttpServer from "effect/unstable/http/HttpServer"; +import { + RelayClientAuth, + RelayClientPrincipal, + type RelayClientDeviceRecord, +} from "@t3tools/contracts/relay"; +import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; +import * as RelayTokens from "../auth/RelayTokens.ts"; +import * as Devices from "../agentActivity/Devices.ts"; import { createClerkClient, verifyToken } from "@clerk/backend"; +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Context from "effect/Context"; @@ -12,22 +23,21 @@ import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; import * as TestClock from "effect/testing/TestClock"; import * as Tracer from "effect/Tracer"; +import * as Etag from "effect/unstable/http/Etag"; import * as HttpRouter from "effect/unstable/http/HttpRouter"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import * as HttpServer from "effect/unstable/http/HttpServer"; -import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { EnvironmentId } from "@t3tools/contracts"; import { - RelayApi, - RelayClientAuth, - RelayClientPrincipal, RelayEnvironmentAuth, - type RelayClientDeviceRecord, + RelayEnvironmentPrincipal, + RelayApi, } from "@t3tools/contracts/relay"; import { + RELAY_HTTP_ROUTER_CONFIG, RELAY_REQUEST_DEADLINE_MS, clientApi, relayCors, @@ -36,6 +46,7 @@ import { relayNotFoundRoute, relayDpopFailureReason, revokeEnvironmentLinkRecord, + serverApi, traceRelayHttpRequestWith, unlinkEnvironmentRecord, verifyRelayClientBearerToken, @@ -46,9 +57,8 @@ import * as RelayDb from "../db.ts"; import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; -import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; -import * as RelayTokens from "../auth/RelayTokens.ts"; -import * as Devices from "../agentActivity/Devices.ts"; +import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; +import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; vi.mock("@clerk/backend", () => ({ createClerkClient: vi.fn(), @@ -606,6 +616,105 @@ describe("relay request tracing", () => { }); describe("relay routing fallback", () => { + it.effect("publishes activity for escaped delegated thread IDs longer than 100 characters", () => + Effect.gen(function* () { + const environmentId = "environment-1"; + const environmentPublicKey = "environment-public-key"; + const published: Array< + Parameters[0] + > = []; + const verified: Array< + Parameters< + EnvironmentPublishSignatures.EnvironmentPublishSignatures["Service"]["verify"] + >[0] + > = []; + const publisher = Layer.succeed(AgentActivityPublisher.AgentActivityPublisher, { + publish: (input) => + Effect.sync(() => { + published.push(input); + return { ok: true as const, deliveries: [] }; + }), + replayForLiveActivityRegistration: () => Effect.succeed(null), + }); + const signatures = Layer.succeed(EnvironmentPublishSignatures.EnvironmentPublishSignatures, { + verify: (input) => + Effect.sync(() => { + verified.push(input); + }), + }); + const auth = Layer.succeed(RelayEnvironmentAuth, { + environmentBearer: (effect) => + effect.pipe( + Effect.provideService(RelayEnvironmentPrincipal, { + environmentId, + environmentPublicKey, + }), + ), + }); + const routes = HttpApiBuilder.layer( + HttpApi.make("RelayApi").add(RelayApi.groups.server), + ).pipe( + Layer.provide(serverApi.pipe(Layer.provide([publisher, signatures]))), + Layer.provide(auth), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layerWeak]), + ); + const httpEffect = yield* HttpRouter.toHttpEffect( + Layer.mergeAll(routes, relayNotFoundRoute, relayCors), + ).pipe(Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG)); + const threadIds = [ + "b7c8c522-d244-43dc-875f-7224fce79912", + "t".repeat(512), + `thread:${"t".repeat(505)}`, + "thread:delegated-task:command%3Amcp%3Ab7c8c522-d244-43dc-875f-7224fce79912%3Adelegate-task%3Agreet-subagent-20260813", + ]; + for (const threadId of threadIds) { + const request = HttpServerRequest.fromWeb( + new Request( + `https://relay.test/v1/environments/${environmentId}/threads/${encodeURIComponent(threadId)}/agent-activity`, + { + method: "POST", + headers: { + authorization: "Bearer environment-credential", + "content-type": "application/json", + }, + body: '{"state":null,"proof":"signed-proof"}', + }, + ), + ); + const response = yield* httpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, request), + ); + expect(response.status).toBe(200); + } + const overLimit = HttpServerRequest.fromWeb( + new Request( + `https://relay.test/v1/environments/${environmentId}/threads/${"t".repeat(513)}/agent-activity`, + { + method: "POST", + headers: { + authorization: "Bearer environment-credential", + "content-type": "application/json", + }, + body: '{"state":null,"proof":"signed-proof"}', + }, + ), + ); + const overLimitResponse = yield* httpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, overLimit), + ); + expect(overLimitResponse.status).toBe(404); + expect(published).toEqual( + threadIds.map((threadId) => ({ + environmentId, + environmentPublicKey, + threadId, + state: null, + })), + ); + expect(verified.map((input) => input.threadId)).toEqual(threadIds); + }).pipe(Effect.scoped), + ); + it.effect("redirects the relay root to the API docs", () => Effect.gen(function* () { const request = HttpServerRequest.fromWeb(new Request("https://relay.test/")); diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index fcaeca6404..5a4d0c9ca4 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -71,6 +71,12 @@ import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; import { withSpanAttributes } from "../observability.ts"; import * as RelayDb from "../db.ts"; +// Delegated thread IDs carry escaped command provenance and exceed the router's +// default 100-character path parameter limit. Match the environment server. +export const RELAY_HTTP_ROUTER_CONFIG = { + maxParamLength: 512, +} as const; + const relayCorsAllowedMethods = ["GET", "POST", "DELETE", "OPTIONS"] as const; const relayCorsAllowedHeaders = [ "authorization", diff --git a/infra/relay/src/persistence/schema.ts b/infra/relay/src/persistence/schema.ts index d9a7074bc0..fd766c7589 100644 --- a/infra/relay/src/persistence/schema.ts +++ b/infra/relay/src/persistence/schema.ts @@ -138,7 +138,7 @@ export const relayAgentActivityRows = pgTable( { environmentId: varchar("environment_id", { length: 191 }).notNull(), environmentPublicKey: text("environment_public_key").notNull(), - threadId: varchar("thread_id", { length: 191 }).notNull(), + threadId: varchar("thread_id", { length: 512 }).notNull(), stateJson: jsonb("state_json").notNull().$type(), updatedAt: varchar("updated_at", { length: 64 }).notNull(), createdAt: varchar("created_at", { length: 64 }).notNull(), @@ -156,7 +156,7 @@ export const relayDeliveryAttempts = pgTable( createdAt: varchar("created_at", { length: 64 }).notNull(), userId: varchar("user_id", { length: 255 }), environmentId: varchar("environment_id", { length: 191 }), - threadId: varchar("thread_id", { length: 191 }), + threadId: varchar("thread_id", { length: 512 }), deviceId: varchar("device_id", { length: 255 }), kind: varchar("kind", { length: 64 }).notNull(), sourceJobId: varchar("source_job_id", { length: 64 }), diff --git a/infra/relay/src/worker.ts b/infra/relay/src/worker.ts index da52da67c0..7cb05920e8 100644 --- a/infra/relay/src/worker.ts +++ b/infra/relay/src/worker.ts @@ -23,6 +23,7 @@ import { healthApi, metadataApi, mobileApi, + RELAY_HTTP_ROUTER_CONFIG, relayClientAuthLayer, relayDpopClientAuthLayer, relayCors, @@ -351,6 +352,7 @@ export const ApiLive = Api.make( relayNotFoundRoute, ).pipe( HttpRouter.toHttpEffect, + Effect.provideService(HttpRouter.RouterConfig, RELAY_HTTP_ROUTER_CONFIG), withoutCapturedParentSpan, Effect.flatMap((httpEffect) => traceRelayHttpRequestWith(httpEffect, relayTraceLayer)), ); From 29e57cfdb00f0685490660eacad93ac55b5312b3 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:19:36 -0600 Subject: [PATCH 07/18] feat(prime): show child progress notes in Agents view (#748) --- .../prime/PrimeAgentDaemonAdapter.test.ts | 42 ++++++++++++++++++- .../provider/prime/PrimeAgentDaemonAdapter.ts | 3 ++ .../prime/PrimeAgentDaemonEvents.test.ts | 20 +++++++++ .../provider/prime/PrimeAgentDaemonEvents.ts | 9 ++++ .../PrimeAgentDaemonRuntimeEvents.test.ts | 8 +++- .../prime/PrimeAgentDaemonRuntimeEvents.ts | 8 +++- .../src/state/subagentRuntime.test.ts | 23 ++++++++++ 7 files changed, 108 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts index ca3c2ae7d6..15db0b597b 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts @@ -6661,6 +6661,39 @@ describe("PrimeAgentDaemonAdapter", () => { }, }); } + yield* offer(captures, { + _tag: "ChildUpdated", + child: { + id: "child-private-progress", + parentId: "parent", + activeSessionId: "native-private-100", + model: "child-model", + label: "private-safe-label", + status: "running", + tokenCount: 10, + toolUseCount: 1, + activity: { kind: "executing", toolName: "bash" }, + progressNote: "Checking the failing test", + lastActivityAt: 1_000, + }, + }); + yield* awaitObservedType(subscription.observed, "task.progress"); + yield* offer(captures, { + _tag: "ChildUpdated", + child: { + id: "child-private-progress", + parentId: "parent", + activeSessionId: "native-private-100", + model: "child-model", + label: "private-safe-label", + status: "running", + tokenCount: 10, + toolUseCount: 1, + activity: { kind: "executing", toolName: "bash" }, + progressNote: "Checking the failing test", + lastActivityAt: 2_000, + }, + }); captures.agentRoster = [ { id: "child-private-progress", @@ -6676,6 +6709,7 @@ describe("PrimeAgentDaemonAdapter", () => { activity: { kind: "executing", toolName: "bash" }, answerPreview: "private-answer-roster", recap: "private-recap-roster", + progressNote: "Checking the failing test", }, ]; expect( @@ -6718,17 +6752,21 @@ describe("PrimeAgentDaemonAdapter", () => { yield* awaitObservedType(subscription.observed, "session.goal.updated"); const durableEvents = subscription.events.slice(durableStart); - expect(durableEvents.filter((event) => event.type === "task.progress")).toEqual([]); + expect(durableEvents.filter((event) => event.type === "task.progress")).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ summary: "Checking the failing test" }), + }), + ]); const completed = durableEvents.filter((event) => event.type === "task.completed"); expect(completed).toHaveLength(1); expect(completed[0]).toMatchObject({ payload: { taskId: "child-private-progress", status: "failed", + summary: "Failed", typedUsage: { totalTokens: 12, toolUses: 1, durationMs: 1_500 }, }, }); - expect(completed[0]?.payload).not.toHaveProperty("summary"); const persisted = encodeUnknownJson(durableEvents); expect(persisted).not.toContain("private-answer"); expect(persisted).not.toContain("private-recap"); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts index 18a0fb462b..3ecc76033b 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts @@ -531,6 +531,7 @@ type PrimeAgentDaemonChildDurableProjection = { readonly messageable: boolean; readonly waiting: boolean; readonly lastToolName: string | undefined; + readonly progressNote: string | undefined; readonly tokenCount: number | undefined; readonly toolUseCount: number | undefined; readonly terminalDurationMs: number | undefined; @@ -560,6 +561,7 @@ function durableChildProjection( child.status === "running" && child.activity?.kind !== "waiting" ? durableChildText(child.activity?.toolName) : undefined, + progressNote: active ? durableChildText(child.progressNote) : undefined, tokenCount: durableChildCount(child.tokenCount), toolUseCount: durableChildCount(child.toolUseCount), terminalDurationMs: active ? undefined : durableChildCount(child.durationMs), @@ -581,6 +583,7 @@ function durableChildChanged( left.messageable !== right.messageable || left.waiting !== right.waiting || left.lastToolName !== right.lastToolName || + left.progressNote !== right.progressNote || left.tokenCount !== right.tokenCount || left.toolUseCount !== right.toolUseCount || left.terminalDurationMs !== right.terminalDurationMs diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts index 1a7e865f87..7829ea5431 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts @@ -819,6 +819,26 @@ describe("PrimeAgentDaemonEvents", () => { _tag: "ChildUpdated", child: { id: "child-1", status: "running", activity: { toolName: "ipython" } }, }); + expect( + decodePrimeAgentDaemonEvent( + sessionEvent({ + type: "rlm_child_update", + child: { + ...child, + progressNote: "Checking tests", + lastActivityAt: 1_700_000_000_000, + activityStaleMs: 600_000, + }, + }), + ), + ).toMatchObject({ + _tag: "ChildUpdated", + child: { + progressNote: "Checking tests", + lastActivityAt: 1_700_000_000_000, + activityStaleMs: 600_000, + }, + }); const queueChanged = decodePrimeAgentDaemonEvent( sessionEvent({ type: "session_action_update", actions }), ); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts index de46966fe7..335895f01d 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts @@ -387,6 +387,9 @@ const rlmChild = Schema.Struct({ toolUseCount: Schema.optional(Schema.Number), tokenCount: Schema.optional(Schema.Number), recap: Schema.optional(Schema.String), + progressNote: Schema.optional(Schema.String), + lastActivityAt: Schema.optional(Schema.Finite), + activityStaleMs: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), sessionDir: Schema.String, activity: Schema.optional( Schema.Struct({ @@ -1309,6 +1312,9 @@ export type PrimeDaemonEvent = ( readonly toolUseCount?: number | undefined; readonly tokenCount?: number | undefined; readonly recap?: string | undefined; + readonly progressNote?: string | undefined; + readonly lastActivityAt?: number | undefined; + readonly activityStaleMs?: number | undefined; readonly activity?: | { readonly kind: "waiting" | "writing" | "executing"; @@ -1656,6 +1662,9 @@ function mapChild( toolUseCount: value.toolUseCount, tokenCount: value.tokenCount, recap: optionalBounded(value.recap, MAX_PREVIEW_LENGTH), + progressNote: optionalBounded(value.progressNote, 512), + lastActivityAt: value.lastActivityAt, + activityStaleMs: value.activityStaleMs, activity: value.activity, error: optionalBounded(value.error, MAX_PREVIEW_LENGTH), }; diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts index a4f078db40..4b42987701 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts @@ -660,6 +660,9 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { tokenCount: 90, answerPreview: "private live answer", recap: "private recap", + progressNote: "Checking the failing test", + lastActivityAt: 1_700_000_000_000, + activityStaleMs: 600_000, error: "private transient error", activity: { kind: "executing", toolName: "bash" }, }, @@ -676,6 +679,7 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { taskId: RuntimeTaskId.make("child-1"), description: "Review tests", status: "running", + summary: "Checking the failing test", typedUsage: { totalTokens: 90, toolUses: 4, durationMs: 250 }, lastToolName: "bash", taskType: "subagent", @@ -693,6 +697,8 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { expect(JSON.stringify(running)).not.toContain("private live answer"); expect(JSON.stringify(running)).not.toContain("private recap"); expect(JSON.stringify(running)).not.toContain("private transient error"); + expect(JSON.stringify(running)).not.toContain("activityStaleMs"); + expect(JSON.stringify(running)).not.toContain("lastActivityAt"); const terminal = mapPrimeAgentDaemonRuntimeEventDrafts({ ...context, @@ -716,12 +722,12 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { payload: { taskId: RuntimeTaskId.make("child-1"), status: "failed", + summary: "Failed", typedUsage: { totalTokens: 100 }, messageable: false, timelineBypass: true, }, }); - expect(terminal[0]?.payload).not.toHaveProperty("summary"); expect(JSON.stringify(terminal)).not.toContain("private terminal"); }); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts index b2912965b9..566f6f90c3 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts @@ -307,7 +307,8 @@ function childDraft( }; case "running": { const lastToolName = boundedNonEmpty(child.activity?.toolName, MAX_SCALAR_LENGTH); - if (child.activity?.kind === "waiting") { + const progressNote = boundedNonEmpty(child.progressNote?.trim(), 512); + if (child.activity?.kind === "waiting" && progressNote === undefined) { return { ...runtimeBase(input), type: "task.updated", @@ -320,7 +321,8 @@ function childDraft( payload: { taskId, description, - status: "running", + status: child.activity?.kind === "waiting" ? "waiting" : "running", + ...(progressNote === undefined ? {} : { summary: progressNote }), ...(typedUsage === undefined ? {} : { typedUsage }), ...(lastToolName === undefined ? {} : { lastToolName }), ...linkage, @@ -337,6 +339,8 @@ function childDraft( taskId, status: child.status === "done" ? "completed" : child.status === "error" ? "failed" : "stopped", + summary: + child.status === "done" ? "Completed" : child.status === "error" ? "Failed" : "Stopped", ...(typedUsage === undefined ? {} : { typedUsage }), ...linkage, }, diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ff066e045f..e1aa979c2e 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -71,6 +71,29 @@ function fold(rows: ReadonlyArray) { } describe("foldSubagentActivities", () => { + it("shows a Prime child note while active and its outcome after completion", () => { + const [agent] = fold([ + activity("task.progress", { + taskId: "prime-child-1", + taskType: "subagent", + title: "Review tests", + status: "running", + summary: "Checking the failing test", + }), + activity("task.completed", { + taskId: "prime-child-1", + taskType: "subagent", + status: "completed", + summary: "Completed", + }), + ]); + expect(agent).toMatchObject({ + status: "completed", + progress: "Checking the failing test", + result: "Completed", + }); + }); + it("shows the batch status limit after its parent turn ends without claiming a result", () => { const running = activity("task.progress", { taskId: "batch-1", From ccb8854cb8d511e9590569229c553063eb337a8b Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:34:10 -0600 Subject: [PATCH 08/18] fix(server): bypass provider caches on explicit refresh (#743) * fix(server): bypass provider caches on explicit refresh * docs(upstream): record provider refresh disposition * style(upstream): format provider refresh decision row --- .agents/upstream-review.md | 1 + .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../src/provider/Drivers/CursorDriver.ts | 5 +- .../provider/Layers/CursorProvider.test.ts | 6 +- .../src/provider/Layers/CursorProvider.ts | 7 +- .../server/src/provider/ModelManifest.test.ts | 78 +++++++++++++ apps/server/src/provider/ModelManifest.ts | 10 +- apps/server/src/provider/ProviderDriver.ts | 2 + apps/server/src/server.test.ts | 103 ++++++++++++++++++ apps/server/src/ws.ts | 27 +++++ packages/contracts/src/rpc.ts | 3 +- 11 files changed, 234 insertions(+), 9 deletions(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index ac1894ec4b..a9411b334f 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -35,6 +35,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Group / bounded head | Sources | Outcome and remaining scope | Pylon PR / verification | | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Explicit provider refresh cache bypass / `f193a6863c494fdfa36a1ef3132e42a7d7b42926` | `f193a6863c494fdfa36a1ef3132e42a7d7b42926` (#13109) | Implemented in PR #743, pending merge: explicit refresh bypasses the model manifest freshness/retry timer and provider-owned capability, model and package-version caches before status probes. Background probes retain their timers; Pylon's installed-provider `refreshModels` path remains. No behavior excluded. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #743](https://github.com/pylon-code/pylon/pull/743); independent adversarial review, 246 focused tests, server typecheck and scoped lint/format. | | OTLP log export, desktop main process telemetry, and per-signal observability / `fa1e1715538f6f8ac076761f7821f68479f616ab` | `3bb06ad910389fcfc886bd5156f41dc73f307f80` (#12493), `82059df1523d070b00ab6f45159115f626a4977e` (#12520), `a8693eb4ed3a66d72e6b814117e7beeaf0fb5f8b` (#12540), `fa1e1715538f6f8ac076761f7821f68479f616ab` (#12657) | Adopted all four in Cycle #689 (PR #690). Server exports structured log records over OTLP with trace/span ID correlation, replaces `tracerLogger` to eliminate duplicate in-span span-event exports, and applies dedicated flusher/serialization layers. Desktop exports main process traces and logs over OTLP while withholding metric emissions until metrics are instrumented. OTLP settings and HTTP header sets are isolated per signal (traces, metrics, logs) across server and desktop configurations. Adversarial review findings resolved: preserved `pylon-server` runtime and service identity defaults, verified `DEFAULT_SIGNAL_EXPORT` fallback, tested backward compatibility of `ServerObservability` schema decoding, and validated log deduplication. Cursor unchanged. | Cycle [#689](https://github.com/pylon-code/pylon/issues/689), [PR #690](https://github.com/pylon-code/pylon/pull/690); independent adversarial review, new ServerLogger test suite, 9 desktop observability tests, all CI checks green. | | Web chat/diff polish, editor discovery, diff cache eviction, ACP elicitation, and preview recovery / `c14f6015bfe479d313355cb234af1a5c16dbb15f` | `c14f6015bfe479d313355cb234af1a5c16dbb15f` (#12453), `0ff87f251dafb32703d5f531e7b248ad0a581ee2` (#10831), `4a560b4e4ebb37efb7f57805ba79e37f5500bdca` (#12438), `9accc567670da5096e9a694c1adb0d35adb51d9d` (#12571), `dfbb11bdd7c3f1a5575cb55d3e3abb12be025727` (#12377), `408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23` (#12439), `cb3d95c17487f3d834a5537f7c3aa0106bc9b5ec` (#12523), `de6a230db0bb71d3fa91d8de0ae7067b68253f82` (#11294), `efb96939fbb135b9f4de3bff19447200b178b23a` (#7861), `5378f87f99175bded2b9241876319567875aca03` (#12535) | Adopted all ten across Cycle #685 Group A (PR #686) and Group B (PR #687). Retained whitespace when copying diff contents, kept linebreaks in citation content, opened chat popovers with relative placement, discovered installed external editors on desktop outside PATH, wrapped long titles in confirmation dialogs, showed plain text in collapsed thought previews, released oversized pull request diff cache entries, accepted ACP SDK elicitation requests (`session/elicitation` & `elicitation/create`), surfaced Codex app permission requests as approvable, and recovered preview host registration after request timeouts via connection queue eviction. Hardening and adversarial review findings resolved: normalized `acceptAlways` approval decision in Codex session runtime to grant permissions, enforced retirement epoch and quarantine boundaries on approval requests, scoped event emission with `CodexNotificationEpoch`, wrapped diff cache invalidation in `Effect.uninterruptible`, cleaned up mobile icon maps, and rebranded pre-existing PR tool descriptions to Pylon. Preserved Pylon Effect services, session lifecycles, and UI layout authority. Cursor unchanged. | Cycle [#685](https://github.com/pylon-code/pylon/issues/685), [PR #686](https://github.com/pylon-code/pylon/pull/686) and [PR #687](https://github.com/pylon-code/pylon/pull/687); independent adversarial reviews, 260+ focused unit tests across web, server, and effect-acp, full workspace typecheck, scoped format/lint. | | Web interaction, ACP session startup stderr, Claude continuation, and GTK4 snapshot / `52d08a14b9e475371658fabbb12c056c3d3dad67` | `8dd02470b1e7603b8d36a21ae27c510480351f18` (#12552), `7445aa733ada33e45289e5aa5055f79142556513` (#11263), `599c9776eb887d4fff42da7d2daf8aa07ee9a9e2` (#11268), `dcf8942304cfef382af55c0068cb2f04c86757fc` (#12636), `e36725682bd2935f1bcfdf81660cb569b5f3df29` (#12577), `63ff33756c47f127391962458d8388a647e8ed44` (#12624), `d6f291303ddc0c9a14f570266a4d9eff6d431593` (#12625), `52d08a14b9e475371658fabbb12c056c3d3dad67` (#12635) | Adopted all eight across Cycle #682 Group A (PR #683) and Group B (PR #684). Preserved draft question input when clicking options, desktop CSP screenshot annotations, restored providers settings heading, aligned PR detail menu glyphs. Empty Claude homePath correctly resolves and shares continuation with `~/.claude` or `CLAUDE_CONFIG_DIR`, surfaced ACP stderr excerpt in session startup failures instead of generic closed session errors, and extracted desktop accessible text for Flatpak and GTK4 apps in SnapShot. Adversarial review findings resolved: ACP stderr excerpt sanitized against tokens/credentials and pending buffer bounded; defensive empty `homePath` trimming and `CLAUDE_CONFIG_DIR` resolution in OAuth usage; desktop accessibility timeout preserved without premature concurrency overlap. Preserved Pylon provider rate limits and error reporting. Cursor unchanged. | Cycle [#682](https://github.com/pylon-code/pylon/issues/682), [PR #683](https://github.com/pylon-code/pylon/pull/683) and [PR #684](https://github.com/pylon-code/pylon/pull/684); independent adversarial reviews, 225+ focused tests across desktop and server, full workspace typecheck, scoped format/lint. | diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index cbcf626858..291589e252 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -333,6 +333,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 5466af802e..8715dd9835 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -137,11 +137,11 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus( effectiveConfig, processEnv, - discoverModels, + modelDiscovery.discover, ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), @@ -196,6 +196,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: modelDiscovery.invalidate, snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 7337a19010..dd314b0ec9 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -626,7 +626,7 @@ describe("discoverCursorModelsViaAcp", () => { apiEndpoint: "", customModels: [], }; - const discover = yield* makeCursorModelDiscovery(settings, { + const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); @@ -639,6 +639,10 @@ describe("discoverCursorModelsViaAcp", () => { yield* fileSystem.writeFileString(requestLogPath, ""); expect(yield* discover(about)).toEqual(first); expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* invalidate; + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); yield* discover({ ...about, version: "2026.08.12" }); expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); yield* fileSystem.writeFileString(requestLogPath, ""); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 1d80fa20b8..e46989ab62 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -588,8 +588,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, }, ); - return (about: Pick) => - Cache.get(cache, JSON.stringify([about.version, about.auth])); + return { + discover: (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])), + invalidate: Cache.invalidateAll(cache), + }; }); function getCursorFallbackModels( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index f779280e6a..8f8e6b4d56 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -363,6 +363,84 @@ describe("ModelManifest service", () => { ); }); + it.live("explicit refresh bypasses fresh memory and disk caches", () => { + let fetchCount = 0; + const updated: ModelManifestData = { + ...REMOTE_MANIFEST, + currentModels: { codex: ["gpt-reloaded"] }, + }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* rebooted.current, updated); + assert.deepStrictEqual(yield* (yield* make).current, updated); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-refresh-test", + response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), + }), + ), + ); + }); + + it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 3); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-retry-test", + response: () => + fetchCount++ === 1 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + + it.live("explicit refresh bypasses the retry delay after an initial failure", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-initial-retry-test", + response: () => + fetchCount++ === 0 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* make; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index bcf9ccfa8b..0ba775e231 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -341,6 +341,8 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; /** Manifest after a TTL-gated remote refresh; never fails. */ readonly refresh: Effect.Effect; + /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */ + readonly forceRefresh: Effect.Effect; /** Forks `refresh` into the service's own scope. Drivers call this from * provider checks: the fetch is process-shared state, so it must survive * the teardown of whichever instance happened to trigger it. */ @@ -352,6 +354,7 @@ export class ModelManifest extends Context.Service< const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }; @@ -388,7 +391,7 @@ export const make = Effect.gen(function* () { }), ); - const refresh = Effect.fn("ModelManifest.refresh")(function* () { + const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; // A timestamp in the future means the wall clock moved backwards (the @@ -396,8 +399,8 @@ export const make = Effect.gen(function* () { // it as expired: the refetch rewrites both timestamps and self-heals. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; - if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; - if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; // The same switch that gates provider CLI update checks. It stops network // fetches only: a manifest already cached on disk from an earlier fetch @@ -450,6 +453,7 @@ export const make = Effect.gen(function* () { return ModelManifest.of({ current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), refresh: guardedRefresh, + forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)), refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 46b0f1cf80..2ca4fedcf7 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -84,6 +84,8 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + /** Invalidate provider-owned discovery caches before an explicit refresh. */ + readonly invalidateCaches?: Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; readonly refreshModels?: () => Effect.Effect; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e3eb8af1cb..75ba2db39f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -142,6 +142,12 @@ import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEve import { RollbackSagaRepository } from "./persistence/Services/RollbackSagas.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import { + ProviderVersionCache, + type ProviderVersionCacheEntry, +} from "./provider/providerMaintenance.ts"; +import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import { ProviderAdapterUnsupportedOperationError, ProviderValidationError, @@ -506,6 +512,8 @@ const buildAppUnderTest = (options?: { antigravityInstallation?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + modelManifest?: Partial; + providerVersionCache?: Map; providerService?: Partial; rollbackSagaRepository?: Partial; serverSettings?: Partial; @@ -767,6 +775,14 @@ const buildAppUnderTest = (options?: { getInstance: () => Effect.succeed(undefined), ...options?.layers?.providerInstances, }), + Layer.mock(ModelManifest.ModelManifest)({ + current: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, + ...options?.layers?.modelManifest, + }), + Layer.succeed(ProviderVersionCache, options?.layers?.providerVersionCache ?? new Map()), Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ current: Effect.succeed([]), streamChanges: Stream.empty, @@ -6428,6 +6444,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const mode of ["all", "targeted", "background"] as const) { + it.effect(`provider refresh invalidates owned caches before probing (${mode})`, () => { + const driver = ProviderDriverKind.make("codex"); + const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")]; + const packageNames = ["@example/personal", "@example/work"]; + const versionCache = new Map( + packageNames.map((name) => [ + name, + { expiresAt: Number.MAX_SAFE_INTEGER, version: "1.0.0" }, + ]), + ); + const invalidated: string[] = []; + const freshMaintenance: string[] = []; + let manifestRefreshed = false; + let probed = false; + const instances = instanceIds.map( + (instanceId, index) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + invalidateCaches: Effect.sync(() => { + invalidated.push(instanceId); + }), + snapshot: { + resolveMaintenance: (options) => + Effect.sync(() => { + assert.isTrue(options?.fresh); + freshMaintenance.push(instanceId); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: packageNames[index]!, + }); + }), + getSnapshot: Effect.never, + refresh: Effect.never, + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance, + ); + const expected = + mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds; + const probe = Effect.sync(() => { + probed = true; + assert.equal(manifestRefreshed, mode !== "background"); + assert.deepEqual(invalidated.toSorted(), expected.toSorted()); + assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted()); + for (let index = 0; index < instanceIds.length; index++) { + assert.equal( + versionCache.has(packageNames[index]!), + !expected.includes(instanceIds[index]!), + ); + } + return []; + }); + return Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + modelManifest: { + forceRefresh: Effect.sync(() => { + manifestRefreshed = true; + return ModelManifest.BUNDLED_MODEL_MANIFEST; + }), + }, + providerVersionCache: versionCache, + providerInstances: { listInstances: Effect.succeed(instances) }, + providerRegistry: { refresh: () => probe, refreshInstance: () => probe }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshProviders]({ + ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}), + ...(mode !== "background" ? { refreshModels: true } : {}), + }), + ), + ); + assert.isTrue(probed); + }).pipe(Effect.provide(NodeHttpServer.layerTest)); + }); + } + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { const refresh = vi.fn(() => Effect.never); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9d4328a8cc..92bdd6e753 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,6 +121,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -560,6 +562,8 @@ const makeWsRpcLayer = ( const deviceService = yield* DeviceService.DeviceService; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const modelManifest = yield* ModelManifest.ModelManifest; + const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; const providerService = yield* ProviderService.ProviderService; const relayWorkerBridge = Option.getOrUndefined( yield* Effect.serviceOption(RelayWorkerBridge), @@ -2345,6 +2349,29 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + if (input.refreshModels) { + // Explicit refresh bypasses Pylon-owned caches; background probes + // keep their normal freshness windows. + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) { + providerVersionCache.delete(maintenance.packageName); + } + }), + { concurrency: "unbounded", discard: true }, + ); + } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 1d481cc512..5580d8a67a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -567,7 +567,8 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, */ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), - /** Explicit user request. Background status refreshes must not open agent sessions. */ + /** Explicit user request: bypass provider discovery caches and rediscover models. + * Background status refreshes must not open agent sessions. */ refreshModels: Schema.optional(Schema.Boolean), }), success: ServerProviderUpdatedPayload, From 91e5166467536c5707caf236b395c9530524763a Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:34:14 -0600 Subject: [PATCH 09/18] fix(git): prevent stale ref checkout from restoring files (#751) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 53 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f93a74f5d3..4784e4fb12 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2134,6 +2134,59 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(result.branch, current); }), ); + + it.effect("rejects a missing branch without restoring a matching dirty file", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "obsolete-branch", "original\n"); + yield* git(cwd, ["add", "obsolete-branch"]); + yield* git(cwd, ["commit", "-m", "tracked file"]); + yield* git(cwd, ["branch", "obsolete-branch"]); + yield* git(cwd, ["branch", "-D", "obsolete-branch"]); + yield* writeTextFile(cwd, "obsolete-branch", "uncommitted work\n"); + + const result = yield* driver + .switchRef({ cwd, refName: "obsolete-branch" }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + assert.equal( + yield* fileSystem.readFileString(path.join(cwd, "obsolete-branch")), + "uncommitted work\n", + ); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + }), + ); + + it.effect("still creates and reuses remote tracking branches and allows detached refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/remote-only"]); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); + assert.equal(result.refName, "remote-only"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/remote-only", + ); + yield* driver.switchRef({ cwd, refName: initialBranch }); + } + const commit = yield* git(cwd, ["rev-parse", "HEAD"]); + const detached = yield* driver.switchRef({ cwd, refName: commit }); + assert.equal(detached.refName, null); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), commit); + }), + ); }); describe("worktree operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 58191a9129..1a872fc338 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3504,7 +3504,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? ["checkout", localTrackingBranch] : ["checkout", input.refName]; - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { + // A stale ref must not turn into a path checkout that discards local edits. + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], { timeoutMs: 10_000, fallbackErrorDetail: "git checkout failed", }); From 7cd8bb3d5a90ad4af9fce95fa2022d97928cce65 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:34:17 -0600 Subject: [PATCH 10/18] fix(shared): preserve final quoted empty CSV records (#761) --- packages/shared/src/delimitedPreview.test.ts | 29 ++++++++++++++++++++ packages/shared/src/delimitedPreview.ts | 6 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/delimitedPreview.test.ts b/packages/shared/src/delimitedPreview.test.ts index 303e904285..0188f9da50 100644 --- a/packages/shared/src/delimitedPreview.test.ts +++ b/packages/shared/src/delimitedPreview.test.ts @@ -21,6 +21,35 @@ describe("delimited file previews", () => { ["", "three"], ]); }); + describe.each([",", "\t"] as const)("with delimiter %j", (delimiter) => { + it("preserves a final quoted empty record without requiring a line ending", () => { + for (const ending of ["", "\n", "\r\n"]) { + expect(parseDelimitedPreview(`name\r\nAlice\r\n""${ending}`, delimiter)).toEqual({ + rows: [["name"], ["Alice"], [""]], + truncated: false, + }); + } + expect(parseDelimitedPreview('""', delimiter)).toEqual({ + rows: [[""]], + truncated: false, + }); + }); + it("does not invent records for empty input or a trailing line ending", () => { + for (const prefix of ["", "\ufeff"]) { + expect(parseDelimitedPreview(prefix, delimiter).rows).toEqual([]); + expect(parseDelimitedPreview(`${prefix}name\r\n`, delimiter).rows).toEqual([["name"]]); + expect(parseDelimitedPreview(`${prefix}""`, delimiter).rows).toEqual([[""]]); + } + }); + it("keeps a quoted empty record at the row limit", () => { + const text = `${"name\n".repeat(99)}""`; + const preview = parseDelimitedPreview(text, delimiter); + expect(preview.rows).toHaveLength(100); + expect(preview.rows.at(-1)).toEqual([""]); + expect(preview.truncated).toBe(false); + expect(parseDelimitedPreview(`${text}\nextra`, delimiter).truncated).toBe(true); + }); + }); it("bounds rows, columns and cell length and reports partial content", () => { const table = parseDelimitedPreview( Array.from({ length: 101 }, () => diff --git a/packages/shared/src/delimitedPreview.ts b/packages/shared/src/delimitedPreview.ts index d4e24409a0..1cfbd41a78 100644 --- a/packages/shared/src/delimitedPreview.ts +++ b/packages/shared/src/delimitedPreview.ts @@ -16,12 +16,13 @@ export function parseDelimitedPreview(text: string, delimiter: "," | "\t") { let cell = ""; let quoted = false; let truncated = false; + let rowStart = text.charCodeAt(0) === 0xfeff ? 1 : 0; const endCell = () => { if (row.length < 30) row.push(cell); else truncated = true; cell = ""; }; - for (let index = text.charCodeAt(0) === 0xfeff ? 1 : 0; index < text.length; index++) { + for (let index = rowStart; index < text.length; index++) { const char = text[index]; if (char === '"') { if (quoted && text[index + 1] === '"') { @@ -41,12 +42,13 @@ export function parseDelimitedPreview(text: string, delimiter: "," | "\t") { rows.push(row); row = []; if (char === "\r" && text[index + 1] === "\n") index++; + rowStart = index + 1; if (rows.length === 100) return { rows, truncated: truncated || index < text.length - 1 }; } } else if (cell.length < 2000) cell += char; else truncated = true; } - if (cell.length || row.length) { + if (rowStart < text.length) { endCell(); rows.push(row); } From b7f917958bd6abbc0242b08e2c711a9396cdae01 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:43:49 -0600 Subject: [PATCH 11/18] fix(server): reduce background GitHub PR quota use (#749) * fix(server): background PR checks spend less GitHub quota (#13189) Co-authored-by: Claude Opus 5.5 (cherry picked from commit 5975ec78b7eaff654e6183b99f8de7a05591e99d) * fix(server): background PR sync reads summaries in batches (#13198) Co-authored-by: Claude Opus 5.5 (cherry picked from commit 18de6bb328059703b5d1b0661610076e3c0fa2d4) * fix(server): GitHub PR lookups stop probing owner-qualified heads (#13200) Co-authored-by: Claude Opus 5.5 (cherry picked from commit eafb4a9340bd2c2271b7c8fac7eddd55beea3972) * fix(server): adapt quota tests to Pylon APIs * test(server): cover skipped terminal PR recheck * docs(upstream): link GitHub quota adoption * fix(server): avoid PR summary fallback on GraphQL quota failures * fix(server): preserve pending turn admission during provider bind (#752) A reused provider can still report its previous turn as running when a new exact turn is reserved. Bind that reservation as starting so matching turn.started can be accepted, reject malformed and terminal binds, and retain strict incarnation fences. * feat(chat): deliver buffered prose at natural boundaries (#757) * fix(server): show Claude subagent tool progress (#745) * fix(server): show Relay workers from installed CLI results (#744) * fix(relay): accept delegated thread IDs in activity routes (#756) * feat(prime): show child progress notes in Agents view (#748) * fix(server): bypass provider caches on explicit refresh (#743) * fix(server): bypass provider caches on explicit refresh * docs(upstream): record provider refresh disposition * style(upstream): format provider refresh decision row * fix(git): prevent stale ref checkout from restoring files (#751) * fix(shared): preserve final quoted empty CSV records (#761) --------- Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .agents/upstream-review.md | 1 + apps/server/src/git/GitManager.test.ts | 175 +++++++-------- apps/server/src/git/GitManager.ts | 46 +++- .../orchestration/PullRequestSyncReactor.ts | 4 +- .../ThreadSettlementReactor.test.ts | 7 +- .../orchestration/ThreadSettlementReactor.ts | 43 +++- .../pullRequest/GitHubPullRequestCli.test.ts | 190 ++++++++++++---- .../src/pullRequest/GitHubPullRequestCli.ts | 210 +++++++++++++----- .../pullRequest/gitHubPullRequestJson.test.ts | 53 +++++ .../src/pullRequest/gitHubPullRequestJson.ts | 133 +++++++++++ 10 files changed, 652 insertions(+), 210 deletions(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index a9411b334f..058a7b7dca 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -36,6 +36,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Group / bounded head | Sources | Outcome and remaining scope | Pylon PR / verification | | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Explicit provider refresh cache bypass / `f193a6863c494fdfa36a1ef3132e42a7d7b42926` | `f193a6863c494fdfa36a1ef3132e42a7d7b42926` (#13109) | Implemented in PR #743, pending merge: explicit refresh bypasses the model manifest freshness/retry timer and provider-owned capability, model and package-version caches before status probes. Background probes retain their timers; Pylon's installed-provider `refreshModels` path remains. No behavior excluded. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #743](https://github.com/pylon-code/pylon/pull/743); independent adversarial review, 246 focused tests, server typecheck and scoped lint/format. | +| Background GitHub PR quota / `eafb4a9340bd2c2271b7c8fac7eddd55beea3972` | `5975ec78b7eaff654e6183b99f8de7a05591e99d` (#13189), `18de6bb328059703b5d1b0661610076e3c0fa2d4` (#13198), `eafb4a9340bd2c2271b7c8fac7eddd55beea3972` (#13200) | Adapted all three: skip uncached recheck when a terminal link cannot settle, batch background PR summaries, and avoid owner-qualified head probes. Preserve Pylon credential scopes, GraphQL budget and per-environment routing; exclude upstream-only test fixture APIs. No cursor advance. | [GitHub quota #749](https://github.com/pylon-code/pylon/pull/749); focused server regressions, scoped lint and typecheck. | | OTLP log export, desktop main process telemetry, and per-signal observability / `fa1e1715538f6f8ac076761f7821f68479f616ab` | `3bb06ad910389fcfc886bd5156f41dc73f307f80` (#12493), `82059df1523d070b00ab6f45159115f626a4977e` (#12520), `a8693eb4ed3a66d72e6b814117e7beeaf0fb5f8b` (#12540), `fa1e1715538f6f8ac076761f7821f68479f616ab` (#12657) | Adopted all four in Cycle #689 (PR #690). Server exports structured log records over OTLP with trace/span ID correlation, replaces `tracerLogger` to eliminate duplicate in-span span-event exports, and applies dedicated flusher/serialization layers. Desktop exports main process traces and logs over OTLP while withholding metric emissions until metrics are instrumented. OTLP settings and HTTP header sets are isolated per signal (traces, metrics, logs) across server and desktop configurations. Adversarial review findings resolved: preserved `pylon-server` runtime and service identity defaults, verified `DEFAULT_SIGNAL_EXPORT` fallback, tested backward compatibility of `ServerObservability` schema decoding, and validated log deduplication. Cursor unchanged. | Cycle [#689](https://github.com/pylon-code/pylon/issues/689), [PR #690](https://github.com/pylon-code/pylon/pull/690); independent adversarial review, new ServerLogger test suite, 9 desktop observability tests, all CI checks green. | | Web chat/diff polish, editor discovery, diff cache eviction, ACP elicitation, and preview recovery / `c14f6015bfe479d313355cb234af1a5c16dbb15f` | `c14f6015bfe479d313355cb234af1a5c16dbb15f` (#12453), `0ff87f251dafb32703d5f531e7b248ad0a581ee2` (#10831), `4a560b4e4ebb37efb7f57805ba79e37f5500bdca` (#12438), `9accc567670da5096e9a694c1adb0d35adb51d9d` (#12571), `dfbb11bdd7c3f1a5575cb55d3e3abb12be025727` (#12377), `408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23` (#12439), `cb3d95c17487f3d834a5537f7c3aa0106bc9b5ec` (#12523), `de6a230db0bb71d3fa91d8de0ae7067b68253f82` (#11294), `efb96939fbb135b9f4de3bff19447200b178b23a` (#7861), `5378f87f99175bded2b9241876319567875aca03` (#12535) | Adopted all ten across Cycle #685 Group A (PR #686) and Group B (PR #687). Retained whitespace when copying diff contents, kept linebreaks in citation content, opened chat popovers with relative placement, discovered installed external editors on desktop outside PATH, wrapped long titles in confirmation dialogs, showed plain text in collapsed thought previews, released oversized pull request diff cache entries, accepted ACP SDK elicitation requests (`session/elicitation` & `elicitation/create`), surfaced Codex app permission requests as approvable, and recovered preview host registration after request timeouts via connection queue eviction. Hardening and adversarial review findings resolved: normalized `acceptAlways` approval decision in Codex session runtime to grant permissions, enforced retirement epoch and quarantine boundaries on approval requests, scoped event emission with `CodexNotificationEpoch`, wrapped diff cache invalidation in `Effect.uninterruptible`, cleaned up mobile icon maps, and rebranded pre-existing PR tool descriptions to Pylon. Preserved Pylon Effect services, session lifecycles, and UI layout authority. Cursor unchanged. | Cycle [#685](https://github.com/pylon-code/pylon/issues/685), [PR #686](https://github.com/pylon-code/pylon/pull/686) and [PR #687](https://github.com/pylon-code/pylon/pull/687); independent adversarial reviews, 260+ focused unit tests across web, server, and effect-acp, full workspace typecheck, scoped format/lint. | | Web interaction, ACP session startup stderr, Claude continuation, and GTK4 snapshot / `52d08a14b9e475371658fabbb12c056c3d3dad67` | `8dd02470b1e7603b8d36a21ae27c510480351f18` (#12552), `7445aa733ada33e45289e5aa5055f79142556513` (#11263), `599c9776eb887d4fff42da7d2daf8aa07ee9a9e2` (#11268), `dcf8942304cfef382af55c0068cb2f04c86757fc` (#12636), `e36725682bd2935f1bcfdf81660cb569b5f3df29` (#12577), `63ff33756c47f127391962458d8388a647e8ed44` (#12624), `d6f291303ddc0c9a14f570266a4d9eff6d431593` (#12625), `52d08a14b9e475371658fabbb12c056c3d3dad67` (#12635) | Adopted all eight across Cycle #682 Group A (PR #683) and Group B (PR #684). Preserved draft question input when clicking options, desktop CSP screenshot annotations, restored providers settings heading, aligned PR detail menu glyphs. Empty Claude homePath correctly resolves and shares continuation with `~/.claude` or `CLAUDE_CONFIG_DIR`, surfaced ACP stderr excerpt in session startup failures instead of generic closed session errors, and extracted desktop accessible text for Flatpak and GTK4 apps in SnapShot. Adversarial review findings resolved: ACP stderr excerpt sanitized against tokens/credentials and pending buffer bounded; defensive empty `homePath` trimming and `CLAUDE_CONFIG_DIR` resolution in OAuth usage; desktop accessibility timeout preserved without premature concurrency overlap. Preserved Pylon provider rate limits and error reporting. Cursor unchanged. | Cycle [#682](https://github.com/pylon-code/pylon/issues/682), [PR #683](https://github.com/pylon-code/pylon/pull/683) and [PR #684](https://github.com/pylon-code/pylon/pull/684); independent adversarial reviews, 225+ focused tests across desktop and server, full workspace typecheck, scoped format/lint. | diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index d4d22be1d2..e6a637bf78 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -15,6 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import type { @@ -1302,7 +1303,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/deleted-fork-branch": JSON.stringify([ + "feature/deleted-fork-branch": JSON.stringify([ { number: 218, title: "Deleted fork branch PR", @@ -1332,8 +1333,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-05T15:00:00.000Z", }); expect( - ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ghCalls.some((call) => call.includes("--head feature/deleted-fork-branch --state all")), ).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); }), ); @@ -1484,6 +1486,61 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const pullRequest = (number: number, headRefName: string, state: string) => ({ + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state, + updatedAt: "2026-04-07T15:00:00Z", + }); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), + "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), + }, + }, + }); + const lookupAll = Effect.forEach( + ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], + (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), + ); + const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list ")); + + yield* lookupAll; + expect(prListCalls()).toHaveLength(3); + + yield* TestClock.adjust("61 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(4); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just inside the 5-minute window only the open PR is asked again. + yield* TestClock.adjust("238 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(5); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just past it the settled answers expire too. + yield* TestClock.adjust("2 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(7); + expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr"); + }), + ); + it.effect("branch PR lookup propagates provider failures", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1780,8 +1837,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - "alice:feature": output, - "fork:feature": output, feature: output, }, }, @@ -1948,10 +2003,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -1987,7 +2038,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2019,7 +2070,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:main": JSON.stringify([ + main: JSON.stringify([ { number: 777, title: "Fork PR from main", @@ -2053,7 +2104,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2118,34 +2169,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), }, }, }); @@ -2356,7 +2379,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-plain": JSON.stringify([ + "feature/fork-plain": JSON.stringify([ { number: 89, title: "Fork PR pushed without -u", @@ -2376,9 +2399,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(89); - expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( - true, - ); + expect(ghCalls.some((call) => call.includes("--head feature/fork-plain"))).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); @@ -2413,7 +2435,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-settle": JSON.stringify([ + "feature/fork-settle": JSON.stringify([ { number: 91, title: "Fork PR to settle", @@ -3697,7 +3719,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", + "returns existing cross-repo PR metadata found under the bare branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3716,8 +3738,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -3749,7 +3769,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); expect( ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), + call.includes("pr list --head statemachine --state open --limit 100"), ), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); @@ -3815,30 +3835,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), }, }, }); @@ -3858,7 +3854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + "picks the fork PR among same-named branches from other repositories", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3890,9 +3886,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3909,8 +3902,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), }, }, }); @@ -3922,11 +3913,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect( + ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")), + ).toBe(false); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, @@ -3955,7 +3944,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ + statemachine: JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3973,11 +3962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([]), }, }, }); @@ -3990,10 +3975,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100")); expect(openLookupCalls).toHaveLength(1); expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", + "pr list --head statemachine --state open --limit 100", ); }), 12_000, @@ -4018,12 +4003,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, ], - "fork-seed:statemachine": ["[]"], - statemachine: ["[]"], }, }, }); @@ -4344,7 +4327,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -4366,10 +4349,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), ], - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": [JSON.stringify([])], - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: [JSON.stringify([])], }, }, }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8bec757d12..42e63304e0 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -31,6 +31,7 @@ import { ModelSelection, type ProjectId, SourceControlProviderError, + type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; @@ -143,6 +144,12 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); +// Answers without an open PR ("no PR yet", merged, closed) only change when +// someone opens a PR, and the paths that do that in-app (turn end, push, +// create PR, user refresh) bypass this cache. Re-asking every minute for each +// idle branch was the bulk of background GitHub quota use, so these wait out +// a longer TTL and a PR opened outside the app shows up within minutes. +const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; @@ -578,6 +585,26 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } +// Without the owner selector, a bare branch name also lists same-named +// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full +// page and let matchesBranchHeadContext pick the right head. gh fetches up to +// 100 in one request, and GitHub prices a first:100 connection like first:1. +const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; + +// `gh pr list --head` filters on the head ref name alone and accepts anything, so an +// `owner:branch` or `remote:branch` selector silently lists zero pull requests +// while spending a GraphQL call. Git branch names cannot contain ":", and the +// bare head branch is always among the selectors, so GitHub probes skip them and +// leave the owner check to matchesBranchHeadContext. +function probeableHeadSelectors( + providerKind: SourceControlProviderKind, + headSelectors: ReadonlyArray, +): ReadonlyArray { + return providerKind === "github" + ? headSelectors.filter((selector) => !selector.includes(":")) + : headSelectors; +} + function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -1126,7 +1153,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); - return PR_LOOKUP_CACHE_TTL; + return exit.value.latest?.state === "open" + ? PR_LOOKUP_CACHE_TTL + : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, @@ -1610,12 +1639,14 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); + for (const headSelector of headSelectors) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", - limit: 1, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -1640,12 +1671,13 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", - limit: 20, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, }); for (const pr of pullRequests.map(toPullRequestInfo)) { diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 0a39fa5d91..eb74dc3486 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -297,7 +297,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(logSkipped("pull request sync skipped", { key })), ) : Effect.void, - { concurrency: 8, discard: true }, + // As wide as one batched summary read, so the sweep's reads on a host arrive together and + // GitHub answers them in one request rather than one `gh pr view` apiece. + { concurrency: 25, discard: true }, ); }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 455cb37a61..03519e0fe3 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -417,12 +417,12 @@ describe("ThreadSettlementReactor", () => { makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }), makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }), makeThread("resumed-manual", { - branch: "main", + branch: "resumed", linkedPullRequest: previous, latestUserMessageAt: "2026-08-28T00:00:00.000Z", }), makeThread("resumed-detected", { - branch: "main", + branch: "resumed", branchPullRequest: previous, latestUserMessageAt: "2026-08-28T00:00:00.000Z", }), @@ -458,6 +458,9 @@ describe("ThreadSettlementReactor", () => { new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]), ); + assert.isFalse( + (yield* Ref.get(fixture.branchCalls)).some(({ branch }) => branch === "resumed"), + ); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index b9041d2976..193759d9c4 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -199,9 +199,30 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); + const wouldSettle = Effect.fn("ThreadSettlementReactor.wouldSettle")(function* ( + group: ReadonlyArray<(typeof candidates)[number]>, + pullRequest: SettlementPullRequest, + ) { + const currentSettings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + return group.some((thread) => { + const { settings } = resolveProjectSettings(currentSettings, thread.projectId); + return ( + resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) !== null + ); + }); + }); + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( - thread: (typeof candidates)[number], + group: ReadonlyArray<(typeof candidates)[number]>, ) { + const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; if (reference != null) { const matchesMerge = @@ -226,10 +247,21 @@ export const make = Effect.gen(function* () { }, { recoverTransientFailure: false }, ); + const terminal = { + state: summary.state, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + } satisfies SettlementPullRequest; const cwd = lookupCwdByThreadId.get(thread.id); if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { // A reused branch can already have a new open PR while discovery // is replacing its old link. Do not let settlement win that race. + // Only pay for the uncached lookup when this sweep would otherwise + // settle: a terminal link that settles nothing (resumed thread, + // settle-on-merge off) would re-query the host every minute. A + // group that becomes eligible after this check waits for the next + // sweep rather than settling on the unverified link. + if (!(yield* wouldSettle(group, terminal))) return undefined; const current = yield* git.branchPullRequest( { cwd, branch: thread.branch }, { refresh: true }, @@ -243,11 +275,7 @@ export const make = Effect.gen(function* () { return current; } } - return { - state: summary.state, - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - } satisfies SettlementPullRequest; + return terminal; } if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); @@ -261,7 +289,8 @@ export const make = Effect.gen(function* () { groups.values(), (group) => Effect.gen(function* () { - const pullRequest = yield* pullRequestFor(group[0]!); + const pullRequest = yield* pullRequestFor(group); + if (pullRequest === undefined) return; yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { discard: true, }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 8416083061..ed8710ee53 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,5 +1,6 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -193,77 +194,137 @@ afterEach(() => { }); layer("GitHubPullRequestCli.layer", (it) => { - it.effect("reads linked pull request status with the overview fields in one request", () => + it.effect("reads linked pull requests on one host together, filed back by position", () => Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); mockedExecute.mockReturnValueOnce( Effect.succeed( output( // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, - ], - body: "", + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const summary = yield* cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); assert.deepStrictEqual( { - number: summary.number, - state: summary.state, - headBranch: summary.headBranch, - isDraft: summary.isDraft, - author: summary.author?.login, - additions: summary.additions, - deletions: summary.deletions, - changedFiles: summary.changedFiles, - reviewDecision: summary.reviewDecision, - checksState: summary.checksState, - mergeability: summary.mergeability, + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, }, { number: 7, state: "open", - headBranch: "feat/summary", - isDraft: false, + headBranch: "feat/7", author: "octocat", - additions: 12, - deletions: 3, changedFiles: 2, reviewDecision: "approved", checksState: "passing", mergeability: "mergeable", }, ); + assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ "pr", "view", "7", @@ -272,7 +333,46 @@ layer("GitHubPullRequestCli.layer", (it) => { "--json", expect.stringContaining("statusCheckRollup"), ]); - expect(mockedGetPullRequest).not.toHaveBeenCalled(); + }), + ); + + it.effect("does not fan out summary reads after a rejected GraphQL batch", () => + Effect.gen(function* () { + for (const response of [ + Effect.succeed(output('{"data":null,"errors":[{"message":"API rate limit exceeded"}]}')), + Effect.fail( + new GitHubCli.GitHubCliRateLimitError({ + command: "gh", + cwd: "/w", + cause: new Error("API rate limit exceeded"), + }), + ), + ]) { + mockedExecute.mockReset().mockReturnValueOnce(response); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const reads = yield* Effect.all([ + cli + .getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.forkChild), + cli + .getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 8, + }) + .pipe(Effect.forkChild), + ]); + yield* TestClock.adjust("10 millis"); + const exits = yield* Effect.forEach(reads, (read) => Fiber.await(read)); + expect(exits.every((exit) => exit._tag === "Failure")).toBe(true); + expect(mockedExecute).toHaveBeenCalledOnce(); + } }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index d90d7378fa..b6c9dae750 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,7 +1,12 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { @@ -36,6 +41,7 @@ import { decodeActorAvatarsJson, decodePullRequestActivityJson, decodePullRequestDetailJson, + type GitHubPullRequestSummary, decodePullRequestFilesJson, decodePullRequestHeadsJson, decodePullRequestListJson, @@ -43,6 +49,8 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodePullRequestStatsJson, + decodePullRequestSummariesJson, + GitHubSummaryBatchUnavailableError, decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, @@ -53,6 +61,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, @@ -393,6 +402,23 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestCliError +> {} export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ @@ -1467,6 +1493,139 @@ export const make = Effect.gen(function* () { ), ); + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. + const viewPullRequestSummary = (input: PullRequestSummaryRead) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, + }), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), + ); + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists( + (error) => + error._tag === "SourceControlRateLimitPausedError" || + error._tag === "GitHubCliRateLimitError" || + (error._tag === "GitHubPullRequestReadError" && + error.cause instanceof GitHubSummaryBatchUnavailableError), + ), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + return GitHubPullRequestCli.of({ getViewerLogin: (input) => github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( @@ -1678,56 +1837,7 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - getPullRequestSummary: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ), + getPullRequestSummary, getPullRequestDetail, listWorkflowRunsRequiringApproval, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 2c3ac6f7c0..801c439a7d 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildPullRequestSummariesGraphQlQuery, + decodePullRequestSummariesJson, buildReviewSubmissionJson, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, @@ -1708,3 +1710,54 @@ describe("pull request stack membership batches", () => { ); }); }); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 1ebe34ae27..f45416bc91 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1725,6 +1725,139 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** + * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written + * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". + */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, + ); + } + return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + errors: Schema.optional(Schema.Array(Schema.Unknown)), + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +/** A failed GraphQL document is not a set of missing aliases to retry individually. */ +export class GitHubSummaryBatchUnavailableError extends Error {} + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result< + ReadonlyMap, + DecodeFailure | GitHubSummaryBatchUnavailableError +> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + if (decoded.success.data == null && (decoded.success.errors?.length ?? 0) > 0) { + return Result.fail(new GitHubSummaryBatchUnavailableError("GitHub rejected the summary batch")); + } + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + }); + } + return Result.succeed(summaries); +} + export function decodePullRequestDetailJson( raw: string, ): Result.Result { From b264cfb2dac2d8aacd1259be9e93f2e4eb3e2757 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:45:15 -0600 Subject: [PATCH 12/18] fix(cursor): preserve internal agent errors (#754) --- apps/server/src/provider/acp/CursorTransportFailure.test.ts | 2 ++ apps/server/src/provider/acp/CursorTransportFailure.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/CursorTransportFailure.test.ts b/apps/server/src/provider/acp/CursorTransportFailure.test.ts index dcd4f9996b..fdde19b934 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.test.ts +++ b/apps/server/src/provider/acp/CursorTransportFailure.test.ts @@ -38,6 +38,8 @@ describe("CursorTransportFailure", () => { "Error: ConnectError: [unauthenticated] sign in", "Error: ConnectError: [permission_denied] subscription required", "Error: HTTP 500 from the application being debugged", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", ])("preserves prose, code and non-transport errors: %s", (message) => { expect(failureFor([...message])).toBeUndefined(); }); diff --git a/apps/server/src/provider/acp/CursorTransportFailure.ts b/apps/server/src/provider/acp/CursorTransportFailure.ts index 99765c74eb..e6cadb23dc 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.ts +++ b/apps/server/src/provider/acp/CursorTransportFailure.ts @@ -1,6 +1,7 @@ const maxLineLength = 4096; +// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. const transportError = - /^Error: (?:RetriableError: .+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; + /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; const serverError = "Something went wrong communicating with the server. Please try again."; interface ReplyState { From e82750e5b075c96c31e725f7920bb6ee85f5c97c Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:45:33 -0600 Subject: [PATCH 13/18] fix(ci): compare transfer ceilings only within a scenario (#759) --- .github/scripts/thread-transfer-report.cjs | 4 ++-- .../scripts/thread-transfer-report.test.cjs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 94a02b7806..264a92b1c8 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -61,7 +61,7 @@ function validateResult(value) { } assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); - if (value.scenario.id !== "thread-transfer-v1") { + if (!["thread-transfer-v1", "thread-transfer-v2"].includes(value.scenario.id)) { throw new Error("result.scenario.id is not supported"); } for (const key of SCENARIO_KEYS.slice(1)) { @@ -157,7 +157,7 @@ function renderComment(input) { `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); - if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 4935864e46..3c8ab2578a 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -290,3 +290,21 @@ test("preserves a successful result when a same-SHA rerun has no artifact", asyn assert.equal(published, true); assert.equal(updatedComment, false); }); + +test("accepts V2 without comparing it to the V1 scenario", () => { + const current = result(); + current.scenario.id = "thread-transfer-v2"; + current.providers.codex.ceiling = { ...current.providers.codex.ceiling, totalWireBytes: 3000000 }; + assert.equal(validateResult(current), current); + const comment = renderComment({ + current, + baseline: result(), + currentRun: { sha: "bbbbbbbb", conclusion: "success", url: "https://example.com/current" }, + baselineRun: { sha: "aaaaaaaa", matchesBase: true, url: "https://example.com/baseline" }, + }); + assert.match(comment, /fixture changed/); + assert.doesNotMatch(comment, /This PR changes transfer ceilings/); + assert.doesNotMatch(comment, /[+-]\d+\.\d+%/); + current.scenario.id = "unrecognized"; + assert.throws(() => validateResult(current), /not supported/); +}); From 298d788a360903eff340fd0dc0504b954738dad0 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:45:58 -0600 Subject: [PATCH 14/18] fix(usage): retain saved totals after transcript cleanup (#762) * fix(shared): keep the newest shared usage scan (#10315) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> (cherry picked from commit d4d5d12e8ba086cfbf79ca3adeb4156b46ead665) * fix(usage): preserve saved totals after transcript cleanup (#12304) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 675869d2b0f9e059d5950b80390c0cad1cb1590e) * fix(usage): preserve Pylon native scan limits during retention * fix(usage): retain standalone Antigravity history after cleanup * fix(usage): prune retired source identities --------- Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: maria Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .agents/upstream-review.md | 2 + apps/server/src/usage/UsageService.test.ts | 261 +++++++++++++++++- apps/server/src/usage/UsageService.ts | 266 +++++++++++++++---- apps/server/src/usage/usageScanCache.test.ts | 77 +----- apps/server/src/usage/usageScanCache.ts | 45 +--- packages/shared/src/usageMerge.test.ts | 46 ++++ packages/shared/src/usageMerge.ts | 12 +- 7 files changed, 531 insertions(+), 178 deletions(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 058a7b7dca..00bfa6b5f3 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -221,3 +221,5 @@ The review cursor stays unchanged; unrelated sources in this range remain unclas | PR cache reuse and quota efficiency / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f4600d77dd7c2fa9f10e8f4500882e427fcc7e26` (#11888), `d612d12b8bb278af97b0029f8d48b2403a5f9ee6` (#12168) | Adopted applicable behavior: scoped cache revisions, canonical detail reuse, deduplicated refreshes and bounded quota probes. Preserve Pylon environment fences and observed GraphQL consumption. Exclude account-router-specific hooks and Forgejo tests: those dependencies are absent in Pylon; their independent source decisions remain open. No cross-environment credential routing introduced. | Cycle [#625](https://github.com/pylon-code/pylon/issues/625), [PR #626](https://github.com/pylon-code/pylon/pull/626); direct adversarial self-review, focused backend/client regressions and scoped types/lint. | | Authenticated GitHub PR media / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `32e8b2584556c0c55ce0d1d8f72b506cb771d60d` (#11706) | Adopted with Pylon path/context guards retained. Signed assets use the active server GitHub CLI account per request; bounded GitHub/CDN redirects strip credentials off credentialed hosts, stream ranges, constrain MIME and sandbox SVG. Older servers retain public fallback. GitHub Enterprise media unsupported. | Cycle [#625](https://github.com/pylon-code/pylon/issues/625), [PR #629](https://github.com/pylon-code/pylon/pull/629); direct adversarial self-review, credential/redirect/range regressions, scoped checks and browser evidence. | + +| Retained usage totals and newest shared scan / `7ed3f711caa36485001538bbe1cb0017b84aa7fe` | `d4d5d12e8ba086cfbf79ca3adeb4156b46ead665` (#10315), `675869d2b0f9e059d5950b80390c0cad1cb1590e` (#12304) | Adopted with Pylon adaptation: retain saved usage after transcript cleanup or moves and across restart; claim a shared fingerprint from the newest scan with a stable ID tie-break. Configured and standalone Antigravity homes retain their profile identity, partial status and per-directory SQLite limits. Stale source identities expire when no current or retained records use them. Existing provider-level wire buckets cannot split costs by individual home when two environments each own distinct and shared homes; that cross-environment overlap remains outside this port and needs source-attributed buckets. The 90-day retention bound remains. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #762](https://github.com/pylon-code/pylon/pull/762); 46 focused tests and scoped server checks. | diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index ec1c88ccfd..7b0dcb44df 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,7 +8,9 @@ import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; import { + EnvironmentId, ProviderDriverKind, ProviderInstanceId, ServerProviderInstancesMutationId, @@ -148,14 +150,15 @@ describe("UsageService", () => { [ { type: "session_meta", payload: { id: "codex-account-session" } }, { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { + // A-B-A at one timestamp must preserve both equal A events. + ...[11, 12, 11].map((outputTokens) => ({ type: "event_msg", timestamp: "2026-08-01T10:00:00Z", payload: { type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } }, + info: { last_token_usage: { input_tokens: 10, output_tokens: outputTokens } }, }, - }, + })), ] .map((line) => encodeUnknownJsonString(line)) .join("\n") + "\n", @@ -215,7 +218,21 @@ describe("UsageService", () => { ), ); const summary = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(summary), 36); + assert.strictEqual(totalOutputTokens(summary), 59); + yield* Effect.promise(() => + NodeFSP.rename( + NodePath.join(codexHome, "sessions", "rollout.jsonl"), + NodePath.join(codexHome, "sessions", "moved.jsonl"), + ), + ); + const moved = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(moved.buckets, summary.buckets); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(codexHome, "sessions"), { recursive: true }), + ); + const removed = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(removed.buckets, summary.buckets); + const sources = summary.sources.filter((source) => source.status === "ok"); assert.strictEqual(sources.length, 4); assert.strictEqual( @@ -458,6 +475,128 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const alias = NodePath.join(home, "claude-alias"); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(home, "claude"), alias, "junction"), + ); + const content = claudeLine(1, 5); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, content)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + assert.isAbove(first.buckets[0]?.costUsd ?? 0, 0); + + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const deleted = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(deleted.buckets, first.buckets); + assert.deepStrictEqual(deleted.sources, first.sources); + + const restarted = yield* UsageService.make; + const restored = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, first.buckets); + assert.deepStrictEqual(restored.sources, first.sources); + + // A moved transcript must not count the saved usage twice. + yield* Effect.promise(() => NodeFSP.writeFile(transcript + ".jsonl", content)); + const moved = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(moved.buckets, first.buckets); + assert.strictEqual(moved.sources[0]?.distinctSessions, 1); + + const replacementProjects = NodePath.join(home, "replacement-projects"); + yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects)); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + const afterRootCleanup = yield* UsageService.make; + const missingRoot = yield* afterRootCleanup.readSummary(WINDOW); + assert.deepStrictEqual(missingRoot.buckets, first.buckets); + assert.strictEqual(missingRoot.sources[0]?.distinctSessions, 1); + assert.strictEqual(missingRoot.sources[0]?.status, "ok"); + assert.deepStrictEqual(missingRoot.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + yield* Effect.promise(async () => { + const projects = NodePath.join(home, "claude", "projects"); + await NodeFSP.rename(replacementProjects, projects); + await NodeFSP.writeFile(NodePath.join(projects, "new.jsonl"), claudeLine(2, 7)); + }); + const recreated = yield* afterRootCleanup.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(recreated), 12); + assert.deepStrictEqual(recreated.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + + const merged = mergeUsage( + [ + { + environmentId: EnvironmentId.make("cleanup-test"), + label: "test", + summary: recreated, + }, + { + environmentId: EnvironmentId.make("other-environment"), + label: "before cleanup", + summary: first, + }, + ], + missingRoot.contractVersion, + ); + assert.strictEqual(merged.outputTokens, 12); + assert.strictEqual(merged.sessions, 1); + assert.strictEqual(merged.costUsd, recreated.buckets[0]?.costUsd); + + const outsideWindow = yield* restarted.readSummary({ + ...WINDOW, + sinceDay: UsageDay.make("2026-08-02"), + }); + assert.deepStrictEqual(outsideWindow.buckets, []); + assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cleanup-test", + home, + settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } }, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("drops stale source identities after an empty account home is replaced", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const replacement = NodePath.join(home, "replacement-claude"); + yield* Effect.promise(() => + NodeFSP.mkdir(NodePath.join(replacement, "projects"), { recursive: true }), + ); + yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + yield* service.readSummary(WINDOW); + const cachePath = NodePath.join(config.stateDir, "usage-scan-cache.json"); + const oldDir = NodePath.join(home, "claude", "projects"); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(cachePath, "utf8")), oldDir); + + yield* settingsService.updateSettings({ + providers: { claudeAgent: { homePath: replacement } }, + }); + yield* service.readSummary(WINDOW); + const persisted = yield* Effect.promise(() => NodeFSP.readFile(cachePath, "utf8")); + assert.notInclude(persisted, oldDir); + assert.include(persisted, NodePath.join(replacement, "projects")); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-source-prune-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -769,6 +908,120 @@ describe("UsageService", () => { ), ); + it.live("retains Antigravity usage after conversation cleanup and service restart", () => + Effect.gen(function* () { + yield* setup; + const config = yield* ServerConfig.ServerConfig; + const instanceId = ProviderInstanceId.make("antigravity-retained"); + const profileDir = resolveAntigravityProfileDirectory(config.stateDir, instanceId); + const convDir = NodePath.join(profileDir, "antigravity-acp", "conversations"); + yield* Effect.promise(() => NodeFSP.mkdir(convDir, { recursive: true })); + const dbPath = NodePath.join(convDir, "retained.db"); + seedAntigravityDb(dbPath, [ + { + idx: 0, + blob: encodeSyntheticGenMetadataBlob({ + modelName: "gemini-2.5-pro", + timestampSeconds: 1785578400n, + inputTokens: 100, + outputTokens: 40, + }), + }, + ]); + + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => NodeFSP.rm(convDir, { recursive: true })); + const afterCleanup = yield* service.readSummary(WINDOW); + const restarted = yield* UsageService.make; + const afterRestart = yield* restarted.readSummary(WINDOW); + + for (const [phase, summary] of [ + ["cleanup", afterCleanup], + ["restart", afterRestart], + ] as const) { + assert.deepStrictEqual(summary.buckets, first.buckets, phase); + const source = summary.sources.find( + (item) => + item.fingerprint.provider === "antigravity" && + item.fingerprint.resolvedHomePath === convDir, + ); + assert.isDefined(source); + assert.strictEqual(source.status, "ok"); + assert.strictEqual(source.scannedFiles, 1); + } + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-antigravity-retained-test", + home: "", + settings: { + providers: {}, + providerInstances: { + [ProviderInstanceId.make("antigravity-retained")]: { + driver: "antigravity", + enabled: true, + }, + }, + }, + }), + ), + Effect.scoped, + ), + ); + + it.live( + "retains standalone Antigravity history after its directory is removed and service restarts", + () => + Effect.gen(function* () { + yield* setup; + const config = yield* ServerConfig.ServerConfig; + const convDir = NodePath.join(config.stateDir, "standalone-antigravity", "conversations"); + yield* Effect.promise(() => NodeFSP.mkdir(convDir, { recursive: true })); + seedAntigravityDb(NodePath.join(convDir, "session.db"), [ + { + idx: 0, + blob: encodeSyntheticGenMetadataBlob({ + modelName: "gemini-2.5-pro", + timestampSeconds: 1785578400n, + inputTokens: 100, + outputTokens: 40, + }), + }, + ]); + + yield* Effect.gen(function* () { + const firstService = yield* UsageService.make; + const first = yield* firstService.readSummary(WINDOW); + assert.isTrue(first.buckets.some((bucket) => bucket.provider === "antigravity")); + yield* Effect.promise(() => NodeFSP.rm(convDir, { recursive: true })); + + for (const summary of [ + yield* firstService.readSummary(WINDOW), + yield* (yield* UsageService.make).readSummary(WINDOW), + ]) { + assert.deepStrictEqual(summary.buckets, first.buckets); + const source = summary.sources.find( + (item) => + item.fingerprint.provider === "antigravity" && + item.fingerprint.resolvedHomePath === convDir, + ); + assert.isDefined(source); + assert.strictEqual(source.status, "ok"); + } + }).pipe(Effect.provideService(UsageService.StandaloneAntigravityConversations, convDir)); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-standalone-antigravity-retained-test", + home: "", + settings: { providers: {}, providerInstances: {} }, + }), + ), + Effect.scoped, + ), + ); + it.live( "reports honest missing status when configured Antigravity instance has no conversations dir on disk", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 122b89436e..8d63a91b9d 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -14,6 +14,8 @@ * @module UsageService */ import * as NodeOS from "node:os"; +// @effect-diagnostics nodeBuiltinImport:off - Context.Reference defaults cannot request Path. +import * as NodePath from "node:path"; import { ClaudeSettings, @@ -90,6 +92,18 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +const MAX_ANTIGRAVITY_FILES_PER_DIR = 500; +const MAX_ANTIGRAVITY_BYTES_PER_DIR = 256 * 1024 * 1024; +const MAX_ANTIGRAVITY_RECORDS_PER_DIR = 50_000; + +/** The standalone Antigravity home belongs to this server environment. */ +export const StandaloneAntigravityConversations = Context.Reference( + "StandaloneAntigravityConversations", + { + defaultValue: () => + NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli", "conversations"), + }, +); const decodeCodexSettings = Schema.decodeOption(CodexSettings); const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); @@ -110,6 +124,11 @@ const encodeRatesCache = Schema.encodeEffect( const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); +const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); +const decodeCachedSources = Schema.decodeUnknownOption( + Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), +); export class UsageService extends Context.Service< UsageService, @@ -156,7 +175,12 @@ export const make = Effect.gen(function* () { const hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); + const sourceCache = new Map(); let cacheDirty = false; + const isWithinDirectory = (filePath: string, dir: string) => { + const relative = path.relative(dir, filePath); + return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); + }; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); @@ -250,9 +274,16 @@ export const make = Effect.gen(function* () { /** Resolves the transcript directory for each provider. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, + retentionCutoffMs: number, ) { - const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = []; + const dirs: Array<{ + provider: UsageProviderKind; + dir: string; + volumeId: string; + fileName?: string; + }> = []; const seen = new Set(); + const activeSourceKeys = new Set(); for (const driver of ["claudeAgent", "codex", "grok"] as const) { // Disabled accounts still have history. Explicit default slots replace // the legacy settings, just as they do in the provider registry. @@ -289,14 +320,42 @@ export const make = Effect.gen(function* () { ); } const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); - // Account aliases and Codex auth overlays can share the same history. + const sourceKey = provider + "\0" + directory; + const previous = sourceCache.get(sourceKey); + // Keep canonical paths and source fingerprints stable after root cleanup, + // including aliases and clients merging pre-cleanup environment summaries. const dir = yield* fileSystem .realPath(directory) - .pipe(Effect.orElseSucceed(() => directory)); + .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === provider && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + // A recreated directory still reports the retained history under its old identity. + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } const key = `${provider}\0${dir}`; + activeSourceKeys.add(sourceKey); if (seen.has(key)) continue; seen.add(key); - dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) }); + dirs.push({ + provider, + dir, + volumeId, + ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}), + }); } } // Antigravity profile roots: resolve configured instances @@ -324,21 +383,69 @@ export const make = Effect.gen(function* () { } // Standalone ~/.gemini/antigravity-cli/conversations when present - const standaloneDir = path.join( - NodeOS.homedir(), - ".gemini", - "antigravity-cli", - "conversations", - ); + const standaloneDir = yield* StandaloneAntigravityConversations; const standaloneExists = yield* fileSystem .exists(standaloneDir) .pipe(Effect.catchCause(() => Effect.succeed(false))); - if (standaloneExists) { + const hasStandaloneHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === "antigravity" && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, standaloneDir), + ); + if (standaloneExists || hasStandaloneHistory) { antigravityRoots.add(standaloneDir); } for (const root of antigravityRoots) { - dirs.push({ provider: "antigravity" as const, dir: root }); + const directory = path.resolve(root); + const sourceKey = "antigravity\0" + directory; + const previous = sourceCache.get(sourceKey); + // Pylon's Antigravity profile fingerprint is the configured profile + // path, not its realpath. Keep it stable across profile cleanup. + const dir = directory; + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === "antigravity" && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } + activeSourceKeys.add(sourceKey); + dirs.push({ provider: "antigravity", dir, volumeId }); + } + + // Old configured homes can disappear from settings. Keep their identity + // only while an in-retention cached transcript could use it again. + for (const [sourceKey, source] of sourceCache) { + if (activeSourceKeys.has(sourceKey)) continue; + const provider = sourceKey.split("\0", 1)[0]; + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === provider && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, source.dir), + ); + if (hasRetainedHistory) continue; + sourceCache.delete(sourceKey); + cacheDirty = true; } return dirs; @@ -359,6 +466,11 @@ export const make = Effect.gen(function* () { ); if (document === null) return; for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + const sources = decodeCachedSources(document); + if (Option.isSome(sources)) { + for (const [key, source] of Object.entries(sources.value.sources)) + sourceCache.set(key, source); + } }), ); @@ -366,7 +478,10 @@ export const make = Effect.gen(function* () { if (!cacheDirty) return; // Cleared only after the write lands, so a failed persist is retried on // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + yield* encodeScanCacheFile({ + ...encodeScanCache(fileCache), + sources: Object.fromEntries(sourceCache), + }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), Effect.map(() => { cacheDirty = false; @@ -417,7 +532,8 @@ export const make = Effect.gen(function* () { ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) return []; + if (parsed === null) + return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. One @@ -452,24 +568,25 @@ export const make = Effect.gen(function* () { readonly status?: "ok" | "missing" | "partial" | "failed"; readonly malformedRecords?: number; readonly message?: string | null; + readonly nativeBudget?: { + readonly files: number; + readonly bytes: number; + readonly records: number; + }; } const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, + retentionCutoffMs: number, ) { // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so the scan stays context-free. - const dirs = yield* resolveTranscriptDirs(settings).pipe( + const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( Effect.provideService(Path.Path, path), ); const scanned: ScannedDir[] = []; - // Bound native SQLite work across all instance roots, including warm-cache records. - let nativeFiles = 0; - let nativeBytes = 0; - let nativeRecords = 0; - for (const { provider, dir, fileName } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + for (const { provider, dir, volumeId, fileName } of dirs) { const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); @@ -479,6 +596,10 @@ export const make = Effect.gen(function* () { } if (provider === "antigravity") { + // Native SQLite limits apply to each profile directory independently. + let nativeFiles = 0; + let nativeBytes = 0; + let nativeRecords = 0; const dirEntries = yield* fileSystem .readDirectory(dir) .pipe(Effect.catchCause(() => Effect.succeed(null))); @@ -497,10 +618,6 @@ export const make = Effect.gen(function* () { const dbFileNames = dirEntries.filter((name) => name.endsWith(".db")).sort(); - const MAX_ANTIGRAVITY_FILES_PER_DIR = 500; - const MAX_ANTIGRAVITY_BYTES_PER_DIR = 256 * 1024 * 1024; - const MAX_ANTIGRAVITY_RECORDS_PER_DIR = 50_000; - const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; let dirMalformedRows = 0; let dirTruncated = false; @@ -683,6 +800,7 @@ export const make = Effect.gen(function* () { status, malformedRecords: dirMalformedRows, message, + nativeBudget: { files: nativeFiles, bytes: nativeBytes, records: nativeRecords }, }); continue; } @@ -749,11 +867,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. const [, scannedDirs] = yield* Effect.all( - [ensureRates(false), collectDirs(windowStartMs, settings)], + [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)], { concurrency: 2 }, ); @@ -768,8 +888,6 @@ export const make = Effect.gen(function* () { }); const sources: UsageSource[] = []; - const livePaths = new Set(); - const walkedRoots: string[] = []; for (const { provider, @@ -779,38 +897,72 @@ export const make = Effect.gen(function* () { status, malformedRecords, message, + nativeBudget, } of scannedDirs) { - if (files === null) { - sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "missing", - scannedFiles: 0, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 0, - message: "No transcript directory on this environment.", - }); - continue; + const retainedFiles = [...(files ?? [])]; + let retainedTruncated = false; + let retainedNativeFiles = nativeBudget?.files ?? 0; + let retainedNativeBytes = nativeBudget?.bytes ?? 0; + let retainedNativeRecords = nativeBudget?.records ?? 0; + const livePaths = new Set(retainedFiles.map((file) => file.path)); + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to this source. Keep the normal aggregation and dedupe path. + for (const [filePath, entry] of fileCache) { + if ( + entry.provider !== provider || + entry.mtimeMs < retentionCutoffMs || + livePaths.has(filePath) || + !isWithinDirectory(filePath, dir) + ) + continue; + if (provider === "antigravity") { + const recordCount = entry.records.length + entry.tailRecords.length; + if ( + retainedNativeFiles + 1 > MAX_ANTIGRAVITY_FILES_PER_DIR || + retainedNativeBytes + entry.size > MAX_ANTIGRAVITY_BYTES_PER_DIR || + retainedNativeRecords + recordCount > MAX_ANTIGRAVITY_RECORDS_PER_DIR + ) { + retainedTruncated = true; + continue; + } + retainedNativeFiles++; + retainedNativeBytes += entry.size; + retainedNativeRecords += recordCount; + } + retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); } - - walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); - for (const file of files) { - livePaths.add(file.path); + for (const file of retainedFiles) { if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; + const codexEventOccurrences = new Map(); for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { + let usageRecord = record; + if (record.provider === "codex" && record.sessionId.length > 0) { + // Match moved rollout copies without collapsing repeated equal events + // within one rollout (timestamps can have only second precision). + const key = encodeUsageRecordKey([ + record.provider, + record.sessionId, + record.timestampMs, + record.model, + record.totals, + ]); + const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; + codexEventOccurrences.set(key, occurrence); + usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; + } + // Only sessions contributing in-window count; the mtime slack can + // admit boundary files whose records fall outside the range. + if (aggregator.add(usageRecord) && record.sessionId.length > 0) { sessionIds.add(record.sessionId); } } @@ -818,21 +970,27 @@ export const make = Effect.gen(function* () { sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: status ?? "ok", + // Saved records remain available after cleanup. Preserve Pylon's native + // partial/failed scan status when an Antigravity read was incomplete. + status: retainedTruncated + ? "partial" + : files === null + ? scannedFiles === 0 + ? "missing" + : "ok" + : status === "failed" && scannedFiles > 0 + ? "partial" + : (status ?? "ok"), scannedFiles, skippedFiles, malformedRecords: malformedRecords ?? 0, distinctSessions: sessionIds.size, - message: message ?? null, + message: + files === null ? "No transcript directory on this environment." : (message ?? null), }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); + const pruned = pruneScanCache(fileCache, retentionCutoffMs); if (pruned > 0) cacheDirty = true; yield* persistScanCache(); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa..cc1bdbcc16 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -194,88 +194,17 @@ describe("pruneScanCache", () => { it("drops entries older than retention", () => { const cache = cacheWith([["/old.jsonl", 500, [record()]]]); - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 400, - retentionCutoffMs, - }); + const removed = pruneScanCache(cache, retentionCutoffMs); expect(removed).toBe(1); expect(cache.size).toBe(0); }); - it("drops in-window entries whose file has disappeared", () => { + it("keeps entries whose file has disappeared", () => { const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); - pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(cache.size).toBe(0); - }); - - it("keeps entries outside the walked window that are still within retention", () => { - // Viewing 7 days must not evict the 30-day entries, which that walk never - // looked for and so cannot prove are gone. - const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(removed).toBe(0); - expect(cache.size).toBe(1); - }); - - it("keeps entries the walk saw", () => { - const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); - - pruneScanCache(cache, { - livePaths: new Set(["/live.jsonl"]), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(cache.size).toBe(1); - }); -}); - -describe("pruneScanCache with an unwalked root", () => { - it("keeps in-window entries for a provider whose directory was not walked", () => { - // A missing provider root or failed settings read leaves livePaths without - // that provider's files. Its warm entries must survive the pass. - const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/claude/projects"], - windowStartMs: 4000, - retentionCutoffMs: 1000, - }); - - expect(removed).toBe(0); - expect(cache.size).toBe(1); - }); - - it("keeps entries under a sibling path that only shares the walked root prefix", () => { - const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/claude/projects"], - windowStartMs: 4000, - retentionCutoffMs: 1000, - }); + pruneScanCache(cache, retentionCutoffMs); - expect(removed).toBe(0); expect(cache.size).toBe(1); }); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 04f9597f42..8439d5ca74 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,9 +14,6 @@ * * @module usageScanCache */ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; - import type { UsageProviderKind } from "@t3tools/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; @@ -302,47 +299,11 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { }; } -export interface PruneOptions { - /** Files the walk just saw. Only meaningful inside the walked window. */ - readonly livePaths: ReadonlySet; - /** - * Roots the walk actually completed. Absence from `livePaths` only proves a - * file is gone when its root was walked: a provider whose directory failed to - * resolve this pass must not have its warm entries purged. - */ - readonly walkedRoots: readonly string[]; - /** Start of the walked window; entries older than this were not looked for. */ - readonly windowStartMs: number; - /** Entries older than this are dropped regardless. */ - readonly retentionCutoffMs: number; -} - -/** - * Drops aged-out entries, and entries for files that have disappeared. - * - * The walk only covers the requested window, so absence from `livePaths` only - * proves deletion for entries *inside* that window. Pruning everything the walk - * missed would evict the 30-day entries every time someone looked at 7 days. - * - * Replaces an earlier record cap that cleared the whole cache once exceeded, - * which meant a large enough window never warmed up at all. - */ -export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { +/** Keeps saved usage after transcript cleanup, until the reporting retention expires. */ +export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): number { let removed = 0; for (const [path, entry] of cache) { - const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => { - const relative = NodePath.relative(root, path); - return ( - relative === "" || - (relative !== ".." && - !relative.startsWith(`..${NodePath.sep}`) && - !NodePath.isAbsolute(relative)) - ); - }); - const deleted = - underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); - if (agedOut || deleted) { + if (entry.mtimeMs < retentionCutoffMs) { cache.delete(path); removed += 1; } diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3da3865022..9c1346b3ce 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -147,6 +147,52 @@ describe("mergeUsage", () => { ).toEqual({ claude: 1, codex: 1 }); }); + it("uses the newest scan when environments share the same transcript directory", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const environments = [ + environment("env-a", summary([bucket({ costUsd: 4, records: 2 })], [source])), + environment("env-b", { + ...summary([bucket()], [source]), + readAt: "2026-08-07T01:00:00.000Z", + }), + ]; + + for (const ordered of [environments, environments.toReversed()]) { + const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.records).toBe(5); + expect(merged.sessions).toBe(1); + expect(merged.contributingEnvironments).toEqual(["env-b"]); + expect(merged.duplicateSources).toEqual(["env-a: /home/theo/.claude"]); + } + }); + + it("uses stable environment ids for equal or invalid scan timestamps", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/shared/.claude" }; + const invalid = environment("env-a", { + ...summary([bucket({ costUsd: 4 })], [source]), + readAt: "invalid", + }); + const equallyNew = [ + environment("env-b", summary([bucket({ costUsd: 10 })], [source])), + environment("env-c", summary([bucket({ costUsd: 20 })], [source])), + ]; + for (const ordered of [[invalid, ...equallyNew], [...equallyNew, invalid].toReversed()]) { + const merged = mergeUsage(ordered, USAGE_CONTRACT_VERSION); + expect(merged.costUsd).toBe(10); + expect(merged.contributingEnvironments).toEqual(["env-b"]); + } + const bothInvalid = mergeUsage( + [ + environment("env-z", { ...summary([bucket({ costUsd: 9 })], [source]), readAt: "bad" }), + invalid, + ], + USAGE_CONTRACT_VERSION, + ); + expect(bothInvalid.costUsd).toBe(4); + expect(bothInvalid.contributingEnvironments).toEqual(["env-a"]); + }); + it("excludes an environment reporting an older contract version", () => { const merged = mergeUsage( [ diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index e0cb0510eb..fee8e9c956 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -118,9 +118,9 @@ function fingerprintKey(fingerprint: UsageSourceFingerprint): string { * * Several environments on one machine (worktree servers, for instance) resolve * the same provider home and would otherwise double count every token. The - * first environment in a stable order claims a fingerprint; the rest have that - * provider's buckets dropped. Environments are sorted by id so the winner does - * not change between renders. + * most recently read summary claims a fingerprint; the rest have that provider's + * buckets dropped. Environment ids break ties so the winner is stable when + * summaries have the same read time. */ function claimSources(environments: readonly EnvironmentUsage[]): { readonly ownerByFingerprint: ReadonlyMap; @@ -129,7 +129,11 @@ function claimSources(environments: readonly EnvironmentUsage[]): { const ownerByFingerprint = new Map(); const duplicates: string[] = []; - const ordered = [...environments].sort((a, b) => a.environmentId.localeCompare(b.environmentId)); + const ordered = [...environments].sort( + (a, b) => + (Date.parse(b.summary.readAt) || 0) - (Date.parse(a.summary.readAt) || 0) || + a.environmentId.localeCompare(b.environmentId), + ); for (const environment of ordered) { for (const source of environment.summary.sources) { From e4434e55955b5bbbab5e88c7fb947ba871422a82 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:46:20 -0600 Subject: [PATCH 15/18] fix(ssh): find Linuxbrew Node on WSL hosts (#769) --- packages/ssh/src/tunnel.test.ts | 1 + packages/ssh/src/tunnel.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 822f260a63..4cfa1c09e4 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -118,6 +118,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "remote_node_satisfies_engine()"); assert.include(script, "function satisfiesSemverRange"); assert.include(script, "satisfiesSemverRange(rawVersion, range)"); + assert.include(script, 'prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin"'); assert.include(script, 'prepend_path_if_dir "$VOLTA_HOME/bin"'); assert.include(script, 'prepend_path_if_dir "$HOME/.asdf/shims"'); assert.include(script, 'prepend_path_if_dir "$HOME/.local/share/mise/shims"'); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index b88872b087..32e5f4ffaf 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -336,6 +336,7 @@ ensure_remote_node_path() { prepend_path_if_dir "$HOME/.local/bin" prepend_path_if_dir "$HOME/bin" prepend_path_if_dir "/opt/homebrew/bin" + prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin" prepend_path_if_dir "/usr/local/bin" prepend_path_if_dir "/usr/bin" prepend_path_if_dir "/bin" From 08e6f51ddef04b85a26cf4bd900f95d182b80c5e Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 10:50:49 -0600 Subject: [PATCH 16/18] fix(server): generate PR diffs from branch changes (#13170) (#742) (cherry picked from commit f22331240ed1a97fae560e314bffb75fe39db8ca) Co-authored-by: Yash Singh --- apps/server/src/git/GitManager.test.ts | 17 ++++++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 10 ++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e6a637bf78..a1b1062666 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4174,7 +4174,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content from branch changes when the remote base advances", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4206,7 +4206,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], @@ -4214,6 +4222,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, @@ -4227,6 +4237,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1a872fc338..bec2aa39eb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2282,13 +2282,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", range], + ["log", "--oneline", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2297,7 +2299,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", range], + ["diff", "--stat", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2306,7 +2308,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, From dbc8eeb130b8d3b772f1b4cce0df801a6b4588d7 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 11:01:11 -0600 Subject: [PATCH 17/18] feat(observability): honor OpenTelemetry export kill switch (#746) * feat(observability): honor OpenTelemetry export kill switch * docs(upstream): record OTLP kill switch disposition * fix(server): release orphaned input after provider session stops (#739) * fix(server): release orphaned input after provider session stops * fix(server): repair cached orphaned request counts on upgrade * fix(server): preserve pending turn admission during provider bind (#752) A reused provider can still report its previous turn as running when a new exact turn is reserved. Bind that reservation as starting so matching turn.started can be accepted, reject malformed and terminal binds, and retain strict incarnation fences. * feat(chat): deliver buffered prose at natural boundaries (#757) * fix(server): show Claude subagent tool progress (#745) * fix(server): show Relay workers from installed CLI results (#744) * fix(relay): accept delegated thread IDs in activity routes (#756) * feat(prime): show child progress notes in Agents view (#748) * fix(server): bypass provider caches on explicit refresh (#743) * fix(server): bypass provider caches on explicit refresh * docs(upstream): record provider refresh disposition * style(upstream): format provider refresh decision row * fix(git): prevent stale ref checkout from restoring files (#751) * fix(shared): preserve final quoted empty CSV records (#761) * fix(server): reduce background GitHub PR quota use (#749) * fix(server): background PR checks spend less GitHub quota (#13189) Co-authored-by: Claude Opus 5.5 (cherry picked from commit 5975ec78b7eaff654e6183b99f8de7a05591e99d) * fix(server): background PR sync reads summaries in batches (#13198) Co-authored-by: Claude Opus 5.5 (cherry picked from commit 18de6bb328059703b5d1b0661610076e3c0fa2d4) * fix(server): GitHub PR lookups stop probing owner-qualified heads (#13200) Co-authored-by: Claude Opus 5.5 (cherry picked from commit eafb4a9340bd2c2271b7c8fac7eddd55beea3972) * fix(server): adapt quota tests to Pylon APIs * test(server): cover skipped terminal PR recheck * docs(upstream): link GitHub quota adoption * fix(server): avoid PR summary fallback on GraphQL quota failures * fix(server): preserve pending turn admission during provider bind (#752) A reused provider can still report its previous turn as running when a new exact turn is reserved. Bind that reservation as starting so matching turn.started can be accepted, reject malformed and terminal binds, and retain strict incarnation fences. * feat(chat): deliver buffered prose at natural boundaries (#757) * fix(server): show Claude subagent tool progress (#745) * fix(server): show Relay workers from installed CLI results (#744) * fix(relay): accept delegated thread IDs in activity routes (#756) * feat(prime): show child progress notes in Agents view (#748) * fix(server): bypass provider caches on explicit refresh (#743) * fix(server): bypass provider caches on explicit refresh * docs(upstream): record provider refresh disposition * style(upstream): format provider refresh decision row * fix(git): prevent stale ref checkout from restoring files (#751) * fix(shared): preserve final quoted empty CSV records (#761) --------- Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 * fix(cursor): preserve internal agent errors (#754) * fix(ci): compare transfer ceilings only within a scenario (#759) * fix(usage): retain saved totals after transcript cleanup (#762) * fix(shared): keep the newest shared usage scan (#10315) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> (cherry picked from commit d4d5d12e8ba086cfbf79ca3adeb4156b46ead665) * fix(usage): preserve saved totals after transcript cleanup (#12304) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> (cherry picked from commit 675869d2b0f9e059d5950b80390c0cad1cb1590e) * fix(usage): preserve Pylon native scan limits during retention * fix(usage): retain standalone Antigravity history after cleanup * fix(usage): prune retired source identities --------- Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: maria Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> * fix(ssh): find Linuxbrew Node on WSL hosts (#769) * fix(server): generate PR diffs from branch changes (#13170) (#742) (cherry picked from commit f22331240ed1a97fae560e314bffb75fe39db8ca) Co-authored-by: Yash Singh --------- Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: maria Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Co-authored-by: Yash Singh --- .agents/upstream-review.md | 1 + .../src/app/DesktopObservability.test.ts | 64 +++++++++++ apps/desktop/src/app/DesktopObservability.ts | 14 ++- .../DesktopBackendConfiguration.test.ts | 43 +++++++ .../backend/DesktopBackendConfiguration.ts | 3 + apps/server/src/bin.test.ts | 2 + apps/server/src/cli/config.test.ts | 107 +++++++++++++++++- apps/server/src/cli/config.ts | 33 ++++-- apps/server/src/cli/pair.ts | 2 + apps/server/src/config.ts | 3 + .../src/environment/ServerEnvironment.test.ts | 2 + .../src/observability/Layers/Observability.ts | 12 +- apps/server/src/server.test.ts | 2 + apps/server/src/serverLogger.test.ts | 2 + docs/operations/observability.md | 12 ++ packages/shared/package.json | 4 + packages/shared/src/otelEnvironment.test.ts | 82 ++++++++++++++ packages/shared/src/otelEnvironment.ts | 105 +++++++++++++++++ 18 files changed, 478 insertions(+), 15 deletions(-) create mode 100644 packages/shared/src/otelEnvironment.test.ts create mode 100644 packages/shared/src/otelEnvironment.ts diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 00bfa6b5f3..0a3b36ea40 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -37,6 +37,7 @@ Historical groups are indexed in the linked archive. This file migration changes | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Explicit provider refresh cache bypass / `f193a6863c494fdfa36a1ef3132e42a7d7b42926` | `f193a6863c494fdfa36a1ef3132e42a7d7b42926` (#13109) | Implemented in PR #743, pending merge: explicit refresh bypasses the model manifest freshness/retry timer and provider-owned capability, model and package-version caches before status probes. Background probes retain their timers; Pylon's installed-provider `refreshModels` path remains. No behavior excluded. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #743](https://github.com/pylon-code/pylon/pull/743); independent adversarial review, 246 focused tests, server typecheck and scoped lint/format. | | Background GitHub PR quota / `eafb4a9340bd2c2271b7c8fac7eddd55beea3972` | `5975ec78b7eaff654e6183b99f8de7a05591e99d` (#13189), `18de6bb328059703b5d1b0661610076e3c0fa2d4` (#13198), `eafb4a9340bd2c2271b7c8fac7eddd55beea3972` (#13200) | Adapted all three: skip uncached recheck when a terminal link cannot settle, batch background PR summaries, and avoid owner-qualified head probes. Preserve Pylon credential scopes, GraphQL budget and per-environment routing; exclude upstream-only test fixture APIs. No cursor advance. | [GitHub quota #749](https://github.com/pylon-code/pylon/pull/749); focused server regressions, scoped lint and typecheck. | +| OTLP export kill switch / `e67abcf798f8c4d8458755e3b4dde02c2c1f628b` | `e67abcf798f8c4d8458755e3b4dde02c2c1f628b` (#13355) | Implemented in PR #746, pending merge: `OTEL_SDK_DISABLED` and the inherited `T3CODE_OTEL_SDK_DISABLED` override suppress server and desktop main-process OTLP traces, metrics, and logs from environment, bootstrap, or Settings endpoints; WSL inherits the flags. Local trace files and local logs remain active, and Pylon service identity is retained. No behavior excluded. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #746](https://github.com/pylon-code/pylon/pull/746); independent adversarial review, 80 focused tests, server/desktop/shared typechecks and scoped lint/format. | | OTLP log export, desktop main process telemetry, and per-signal observability / `fa1e1715538f6f8ac076761f7821f68479f616ab` | `3bb06ad910389fcfc886bd5156f41dc73f307f80` (#12493), `82059df1523d070b00ab6f45159115f626a4977e` (#12520), `a8693eb4ed3a66d72e6b814117e7beeaf0fb5f8b` (#12540), `fa1e1715538f6f8ac076761f7821f68479f616ab` (#12657) | Adopted all four in Cycle #689 (PR #690). Server exports structured log records over OTLP with trace/span ID correlation, replaces `tracerLogger` to eliminate duplicate in-span span-event exports, and applies dedicated flusher/serialization layers. Desktop exports main process traces and logs over OTLP while withholding metric emissions until metrics are instrumented. OTLP settings and HTTP header sets are isolated per signal (traces, metrics, logs) across server and desktop configurations. Adversarial review findings resolved: preserved `pylon-server` runtime and service identity defaults, verified `DEFAULT_SIGNAL_EXPORT` fallback, tested backward compatibility of `ServerObservability` schema decoding, and validated log deduplication. Cursor unchanged. | Cycle [#689](https://github.com/pylon-code/pylon/issues/689), [PR #690](https://github.com/pylon-code/pylon/pull/690); independent adversarial review, new ServerLogger test suite, 9 desktop observability tests, all CI checks green. | | Web chat/diff polish, editor discovery, diff cache eviction, ACP elicitation, and preview recovery / `c14f6015bfe479d313355cb234af1a5c16dbb15f` | `c14f6015bfe479d313355cb234af1a5c16dbb15f` (#12453), `0ff87f251dafb32703d5f531e7b248ad0a581ee2` (#10831), `4a560b4e4ebb37efb7f57805ba79e37f5500bdca` (#12438), `9accc567670da5096e9a694c1adb0d35adb51d9d` (#12571), `dfbb11bdd7c3f1a5575cb55d3e3abb12be025727` (#12377), `408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23` (#12439), `cb3d95c17487f3d834a5537f7c3aa0106bc9b5ec` (#12523), `de6a230db0bb71d3fa91d8de0ae7067b68253f82` (#11294), `efb96939fbb135b9f4de3bff19447200b178b23a` (#7861), `5378f87f99175bded2b9241876319567875aca03` (#12535) | Adopted all ten across Cycle #685 Group A (PR #686) and Group B (PR #687). Retained whitespace when copying diff contents, kept linebreaks in citation content, opened chat popovers with relative placement, discovered installed external editors on desktop outside PATH, wrapped long titles in confirmation dialogs, showed plain text in collapsed thought previews, released oversized pull request diff cache entries, accepted ACP SDK elicitation requests (`session/elicitation` & `elicitation/create`), surfaced Codex app permission requests as approvable, and recovered preview host registration after request timeouts via connection queue eviction. Hardening and adversarial review findings resolved: normalized `acceptAlways` approval decision in Codex session runtime to grant permissions, enforced retirement epoch and quarantine boundaries on approval requests, scoped event emission with `CodexNotificationEpoch`, wrapped diff cache invalidation in `Effect.uninterruptible`, cleaned up mobile icon maps, and rebranded pre-existing PR tool descriptions to Pylon. Preserved Pylon Effect services, session lifecycles, and UI layout authority. Cursor unchanged. | Cycle [#685](https://github.com/pylon-code/pylon/issues/685), [PR #686](https://github.com/pylon-code/pylon/pull/686) and [PR #687](https://github.com/pylon-code/pylon/pull/687); independent adversarial reviews, 260+ focused unit tests across web, server, and effect-acp, full workspace typecheck, scoped format/lint. | | Web interaction, ACP session startup stderr, Claude continuation, and GTK4 snapshot / `52d08a14b9e475371658fabbb12c056c3d3dad67` | `8dd02470b1e7603b8d36a21ae27c510480351f18` (#12552), `7445aa733ada33e45289e5aa5055f79142556513` (#11263), `599c9776eb887d4fff42da7d2daf8aa07ee9a9e2` (#11268), `dcf8942304cfef382af55c0068cb2f04c86757fc` (#12636), `e36725682bd2935f1bcfdf81660cb569b5f3df29` (#12577), `63ff33756c47f127391962458d8388a647e8ed44` (#12624), `d6f291303ddc0c9a14f570266a4d9eff6d431593` (#12625), `52d08a14b9e475371658fabbb12c056c3d3dad67` (#12635) | Adopted all eight across Cycle #682 Group A (PR #683) and Group B (PR #684). Preserved draft question input when clicking options, desktop CSP screenshot annotations, restored providers settings heading, aligned PR detail menu glyphs. Empty Claude homePath correctly resolves and shares continuation with `~/.claude` or `CLAUDE_CONFIG_DIR`, surfaced ACP stderr excerpt in session startup failures instead of generic closed session errors, and extracted desktop accessible text for Flatpak and GTK4 apps in SnapShot. Adversarial review findings resolved: ACP stderr excerpt sanitized against tokens/credentials and pending buffer bounded; defensive empty `homePath` trimming and `CLAUDE_CONFIG_DIR` resolution in OAuth usage; desktop accessibility timeout preserved without premature concurrency overlap. Preserved Pylon provider rate limits and error reporting. Cursor unchanged. | Cycle [#682](https://github.com/pylon-code/pylon/issues/682), [PR #683](https://github.com/pylon-code/pylon/pull/683) and [PR #684](https://github.com/pylon-code/pylon/pull/684); independent adversarial reviews, 225+ focused tests across desktop and server, full workspace typecheck, scoped format/lint. | diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index d7ccfc43b1..215998631e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -427,6 +428,36 @@ describe("DesktopObservability", () => { ); }); + it.effect("exports kill switch warnings through the configured logger", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.void.pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })), + ), + ), + ); + }); + it.effect("reads every signal endpoint from Settings when the environment names none", () => { const requests: Array = []; return Effect.gen(function* () { @@ -491,4 +522,37 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), ); }); + + it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + yield* writeObservabilitySettings(environmentLayer, { + otlpTracesUrl: "https://settings.example.com/v1/traces", + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop log stays local when disabled").pipe( + Effect.withSpan("desktop-disabled-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + ), + ), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 9665721574..f8fd73a636 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -4,6 +4,7 @@ import { makeTraceSink, otlpSerializationLayer, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -353,12 +354,18 @@ const readPersistedObservabilitySettings: Effect.Effect< * resolve traces against one revision of the file and logs against another. */ const resolveOtlpEndpoints = Effect.gen(function* () { + const otel = yield* OtelEnvironment.load; + if (otel.disabled) { + return { traces: undefined, metrics: undefined, logs: undefined, warnings: otel.warnings }; + } + const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; return { traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + warnings: otel.warnings, }; }); @@ -671,7 +678,12 @@ const telemetryLayer = Layer.unwrap( // resource, // }).pipe(Layer.provide(serializationLayer)); - return Layer.mergeAll(loggerLayer, tracerLayer); + // Logged once the loggers above are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe(Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 62a11381fe..de63e91b10 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1055,6 +1055,49 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resolveWsl carries the kill switch into the distro", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const previousWslEnv = process.env.WSLENV; + const previousDisabled = process.env.OTEL_SDK_DISABLED; + try { + delete process.env.WSLENV; + process.env.OTEL_SDK_DISABLED = "true"; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.env.OTEL_SDK_DISABLED, "true"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + restoreEnv("WSLENV", previousWslEnv); + restoreEnv("OTEL_SDK_DISABLED", previousDisabled); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 711405002f..8df5f799e1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -96,6 +96,9 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. + "T3CODE_OTEL_SDK_DISABLED", + "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", ] as const; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 42fbb7815c..d6163bb463 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -16,6 +16,7 @@ import { import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -106,6 +107,7 @@ const makeCliTestServerConfig = (baseDir: string) => otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index b15787efeb..20fcf45891 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; @@ -58,7 +59,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -582,6 +584,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("zeroes an endpoint stored in Settings when the SDK is disabled", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-off-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + NetService.layer, + ), + ), + ); + + // The switch beats every source, including an endpoint stored in Settings. + expect(resolved.otlpTracesUrl).toBeUndefined(); + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + expect(resolved.otelEnvironment.disabled).toBe(true); + }), + ); + + it.effect("lets T3CODE_OTEL_SDK_DISABLED=false override an ambient OTEL_SDK_DISABLED=true", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-on-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otelEnvironment.disabled).toBe(false); + expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + }), + ); + it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index c876450505..3e7d685492 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -4,6 +4,7 @@ import { OtlpProtocol, type SignalExport, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -106,7 +107,9 @@ const EnvServerConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), + otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe( + Config.withDefault("pylon-server"), + ), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -372,7 +375,9 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - // Pylon's own OTLP variables name no signal, so the one answer they give + const otel = yield* OtelEnvironment.load; + + // Pylon's inherited OTLP variables name no signal, so the one answer they give // is the answer for all three. const signalExport: SignalExport = { protocol: env.otlpProtocol, @@ -387,20 +392,24 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: - env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl, - otlpLogsUrl: - env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, + otlpTracesUrl: otel.disabled + ? undefined + : (env.otlpTracesUrl ?? + bootstrap?.otlpTracesUrl ?? + persistedObservabilitySettings.otlpTracesUrl), + otlpMetricsUrl: otel.disabled + ? undefined + : (env.otlpMetricsUrl ?? + bootstrap?.otlpMetricsUrl ?? + persistedObservabilitySettings.otlpMetricsUrl), + otlpLogsUrl: otel.disabled + ? undefined + : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl), otlpTracesExport: signalExport, otlpMetricsExport: signalExport, otlpLogsExport: signalExport, otlpServiceName: env.otlpServiceName, + otelEnvironment: otel, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 801d877c9c..6fc0da6a5c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, @@ -326,6 +327,7 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 573d61432e..b6738325ac 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -17,6 +17,7 @@ import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; export const DEFAULT_PORT = 3773; @@ -81,6 +82,7 @@ export class ServerConfig extends Context.Service< readonly otlpMetricsExport: SignalExport; readonly otlpLogsExport: SignalExport; readonly otlpServiceName: string; + readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -215,6 +217,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a1b9493b13..71f228b3dc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { @@ -60,6 +61,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 8f7b607745..a754933e5d 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -20,6 +20,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const traces = config.otlpTracesExport; const metrics = config.otlpMetricsExport; // The trace serializer stays in the returned context because the browser @@ -86,6 +87,15 @@ export const ObservabilityLive = Layer.unwrap( resource, }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); - return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); + // Logged once the server's loggers are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe( + Layer.provideMerge( + Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer), + ), + ); }), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 75ba2db39f..04c71a54ac 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -231,6 +231,7 @@ import { } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -576,6 +577,7 @@ const buildAppUnderTest = (options?: { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index d6abdfd197..c259526a2a 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -9,6 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerConfig from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; @@ -57,6 +58,7 @@ const configLayer = (overrides: Partial) = otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, ...derivedPaths, diff --git a/docs/operations/observability.md b/docs/operations/observability.md index 807a2bd3bc..3659377718 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -555,6 +555,18 @@ OTLP export: If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. +### The Kill Switch + +`T3CODE_OTEL_SDK_DISABLED` and `OTEL_SDK_DISABLED` turn off every OTLP export in both the server and +the desktop main process, overriding any endpoint from the environment or Settings. Local trace +files and stdout logs are unaffected. + +`T3CODE_OTEL_SDK_DISABLED` wins when set, so `T3CODE_OTEL_SDK_DISABLED=false` re-enables export on a +machine that sets `OTEL_SDK_DISABLED` for everything else. It accepts the usual boolean spellings +(`true`/`false`, `yes`/`no`, `on`/`off`, `1`/`0`, `y`/`n`). `OTEL_SDK_DISABLED` follows the +OpenTelemetry specification and only `true` disables export, so `OTEL_SDK_DISABLED=1` does not. +Values are case-insensitive and trimmed. An unrecognized value is ignored with a startup warning. + ### What Is Instrumented Today Current high-value span and metric boundaries include: diff --git a/packages/shared/package.json b/packages/shared/package.json index 73bbd85c31..ee4fc811b2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -55,6 +55,10 @@ "types": "./src/observability.ts", "import": "./src/observability.ts" }, + "./otelEnvironment": { + "types": "./src/otelEnvironment.ts", + "import": "./src/otelEnvironment.ts" + }, "./httpObservability": { "types": "./src/httpObservability.ts", "import": "./src/httpObservability.ts" diff --git a/packages/shared/src/otelEnvironment.test.ts b/packages/shared/src/otelEnvironment.test.ts new file mode 100644 index 0000000000..3b6dac4b50 --- /dev/null +++ b/packages/shared/src/otelEnvironment.test.ts @@ -0,0 +1,82 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; + +import * as OtelEnvironment from "./otelEnvironment.ts"; + +const load = (env: Record) => + OtelEnvironment.load.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); + +const SPEC_OFF = + "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway"; +const T3_OFF = + "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it"; +const specIgnored = (value: string) => + `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`; + +describe("OtelEnvironment", () => { + it.effect.each([ + { name: "nothing set", env: {}, disabled: false, warnings: [] }, + // OTEL_SDK_DISABLED follows the specification: only `true`, case-insensitively. + { name: "spec true", env: { OTEL_SDK_DISABLED: "true" }, disabled: true, warnings: [SPEC_OFF] }, + { name: "spec True", env: { OTEL_SDK_DISABLED: "True" }, disabled: true, warnings: [SPEC_OFF] }, + { + name: "spec padded", + env: { OTEL_SDK_DISABLED: " true " }, + disabled: true, + warnings: [SPEC_OFF], + }, + { name: "spec false", env: { OTEL_SDK_DISABLED: "false" }, disabled: false, warnings: [] }, + { + name: "spec 1", + env: { OTEL_SDK_DISABLED: "1" }, + disabled: false, + warnings: [specIgnored("1")], + }, + { + name: "spec padded yes", + env: { OTEL_SDK_DISABLED: " yes " }, + disabled: false, + warnings: [specIgnored("yes")], + }, + // T3CODE_OTEL_SDK_DISABLED takes Config.Boolean's values, case-insensitively. + { name: "t3 1", env: { T3CODE_OTEL_SDK_DISABLED: "1" }, disabled: true, warnings: [T3_OFF] }, + { + name: "t3 TRUE", + env: { T3CODE_OTEL_SDK_DISABLED: "TRUE" }, + disabled: true, + warnings: [T3_OFF], + }, + { name: "t3 n", env: { T3CODE_OTEL_SDK_DISABLED: "n" }, disabled: false, warnings: [] }, + { + name: "t3 false overrides spec true", + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" }, + disabled: false, + warnings: [], + }, + { + name: "blank t3 falls through", + env: { T3CODE_OTEL_SDK_DISABLED: " ", OTEL_SDK_DISABLED: "true" }, + disabled: true, + warnings: [SPEC_OFF], + }, + { + name: "unreadable t3 warns and falls through", + env: { T3CODE_OTEL_SDK_DISABLED: "maybe", OTEL_SDK_DISABLED: "true" }, + disabled: true, + warnings: ["T3CODE_OTEL_SDK_DISABLED=maybe is not a yes or a no and was ignored", SPEC_OFF], + }, + { + name: "bad spec value still warns when t3 answered", + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "yes" }, + disabled: false, + warnings: [specIgnored("yes")], + }, + ])("$name", ({ env, disabled, warnings }) => + Effect.gen(function* () { + const resolved = yield* load(env); + assert.strictEqual(resolved.disabled, disabled); + assert.deepStrictEqual(resolved.warnings, warnings); + }), + ); +}); diff --git a/packages/shared/src/otelEnvironment.ts b/packages/shared/src/otelEnvironment.ts new file mode 100644 index 0000000000..8bbf50198c --- /dev/null +++ b/packages/shared/src/otelEnvironment.ts @@ -0,0 +1,105 @@ +/** + * otelEnvironment: the OpenTelemetry kill switch, shared by the server and the + * desktop main process so both agree on what turns export off. + * + * `T3CODE_OTEL_SDK_DISABLED` is read first, so a machine that sets + * `OTEL_SDK_DISABLED` for everything else can still opt Pylon back in. + * + * @module otelEnvironment + */ +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +export interface OtelEnvironment { + /** Whether OTLP export is off, whatever endpoint is configured. */ + readonly disabled: boolean; + /** Messages for the caller to log once at startup. */ + readonly warnings: ReadonlyArray; +} + +interface Flag { + /** `undefined` when the variable is unset, blank, or unreadable. */ + readonly value: boolean | undefined; + readonly warning?: string; +} + +const TrimmedLowercase = Schema.String.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.trim().compose(SchemaTransformation.toLowerCase()), + ), +); + +/** + * Reads a boolean that accepts `truthy` and `falsy`, ignoring case and padding. + * Any other value is ignored with a warning rather than failing startup. + */ +const flag = ( + name: string, + truthy: ReadonlyArray, + falsy: ReadonlyArray, + invalid: (value: string) => string, +) => + Config.schema( + TrimmedLowercase.pipe(Schema.decodeTo(Schema.Literals([...truthy, ...falsy]))), + name, + ).pipe( + Config.map((value): Flag => ({ value: truthy.includes(value) })), + Config.orElse(() => + Config.string(name).pipe( + Config.map((raw): Flag => { + const value = raw.trim(); + return value === "" + ? { value: undefined } + : { value: undefined, warning: invalid(value) }; + }), + ), + ), + Config.withDefault({ value: undefined }), + ); + +// `Config.Boolean`'s literals, which effect does not export on their own. +const T3CODE_TRUE = ["true", "yes", "on", "1", "y"]; +const T3CODE_FALSE = ["false", "no", "off", "0", "n"]; + +export const load: Effect.Effect = Config.all({ + t3: flag( + "T3CODE_OTEL_SDK_DISABLED", + T3CODE_TRUE, + T3CODE_FALSE, + (value) => `T3CODE_OTEL_SDK_DISABLED=${value} is not a yes or a no and was ignored`, + ), + // The specification: a boolean it defines is true "only by the + // case-insensitive string `true`", implementations "MUST NOT" accept other + // values as true, and should warn about unrecognized ones. + spec: flag( + "OTEL_SDK_DISABLED", + ["true"], + ["false"], + (value) => + `OTEL_SDK_DISABLED=${value} was read as false; the OpenTelemetry specification recognizes only the string true, so use OTEL_SDK_DISABLED=true or T3CODE_OTEL_SDK_DISABLED to say it any other way`, + ), +}).pipe( + Effect.map(({ t3, spec }) => { + const disabled = t3.value ?? spec.value ?? false; + const warnings = [t3.warning, spec.warning].filter((warning) => warning !== undefined); + if (disabled) { + warnings.push( + t3.value + ? "T3CODE_OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it" + : "OTEL_SDK_DISABLED is set, so no telemetry is exported, whatever configured it; set T3CODE_OTEL_SDK_DISABLED=false to export anyway", + ); + } + return { disabled, warnings }; + }), + // Every read above falls back instead of failing, so this cannot happen. + Effect.orDie, +); + +/** An environment that asked for nothing, for tests and for the pairing CLI. */ +export const none: OtelEnvironment = { + disabled: false, + warnings: [], +}; From 961c4dc2b621d803e47367ed8439a3a317dcc958 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 24 Sep 2026 11:04:05 -0600 Subject: [PATCH 18/18] fix(grok): show monitor and background shell lifecycle (#753) --- apps/server/scripts/acp-mock-agent.ts | 104 +++++++ .../src/provider/Layers/GrokAdapter.test.ts | 64 +++- .../server/src/provider/Layers/GrokAdapter.ts | 35 +++ .../provider/acp/XAiBackgroundTasks.test.ts | 274 ++++++++++++++++++ .../src/provider/acp/XAiBackgroundTasks.ts | 183 ++++++++++++ 5 files changed, 658 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/acp/XAiBackgroundTasks.test.ts create mode 100644 apps/server/src/provider/acp/XAiBackgroundTasks.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 1aea931038..651eaf302a 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -38,6 +38,8 @@ const emitXAiAskUserQuestionThenHang = const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; +const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; +const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const emitPrivateThoughtChunk = process.env.T3_ACP_EMIT_PRIVATE_THOUGHT_CHUNK === "1"; const assistantMessageIdMode = process.env.T3_ACP_ASSISTANT_MESSAGE_IDS; @@ -935,6 +937,108 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitGrokMonitorPostTurnPoll) { + const monitorCallId = "call-monitor-1"; + const pollCallId = "call-monitor-poll-1"; + const taskId = "01a05f41-5107-7550-821e-79e8d1cd7687"; + const description = "Watch count-sheet Typst unit until done"; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: monitorCallId, + title: "monitor", + kind: "other", + status: "pending", + rawInput: { description }, + _meta: { + "x.ai/tool": { version: 1, name: "monitor", kind: "task", namespace: "grok_build" }, + }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: monitorCallId, + status: "completed", + rawInput: { description }, + rawOutput: { + type: "Monitor", + taskId, + timeoutMs: 36_000_000, + }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + yield* Effect.sleep("120 millis"); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: pollCallId, + title: "get_command_or_subagent_output", + kind: "other", + status: "completed", + rawInput: { variant: "TaskOutput", task_ids: [taskId], timeout_ms: 0 }, + rawOutput: { + type: "TaskOutput", + Result: { + task_id: taskId, + command: `[monitor] ${description}`, + status: "completed", + exit_code: 0, + output: "Monitor finished.", + }, + }, + }, + }); + return yield* Effect.never; + } + + if (emitGrokBackgroundTaskStarted) { + const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; + const command = "sleep 40; echo done-a"; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "run_terminal_command", + kind: "execute", + status: "in_progress", + rawInput: { command }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + type: "BackgroundTaskStarted", + task_id: toolCallId, + task_type: "bash", + status: "running", + command, + }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + return yield* Effect.never; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index b14edfcc0b..e38de31156 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -25,8 +25,10 @@ import { TurnId, type ProviderRuntimeEvent, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../../config.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import { grokPromptSettlementBelongsToContext, isGrokEnterPlanModeToolCall, @@ -34,8 +36,7 @@ import { nextGrokPlanModeActive, selectGrokPermissionOptionId, } from "./GrokAdapter.ts"; -import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -251,6 +252,65 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + for (const taskType of ["monitor", "shell"] as const) { + it.effect(`emits the ${taskType} background lifecycle`, () => + Effect.gen(function* () { + const threadId = ThreadId.make(`grok-background-${taskType}`); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + [taskType === "monitor" + ? "T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL" + : "T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED"]: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const events: ProviderRuntimeEvent[] = []; + const finished = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === (taskType === "monitor" ? "task.completed" : "turn.completed")) { + yield* Deferred.succeed(finished, undefined); + } + }), + ).pipe(Effect.forkChild); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "watch the unit" }); + yield* Deferred.await(finished).pipe(Effect.timeout("3 seconds")); + + const started = events.find((event) => event.type === "task.started"); + const taskId = + taskType === "monitor" + ? "01a05f41-5107-7550-821e-79e8d1cd7687" + : "call-fb9d0000-0000-0000-0000-000000000026"; + assert.equal(started?.payload.taskType, taskType); + assert.equal(started?.payload.taskId, taskId); + if (taskType === "monitor") { + const completed = events.find((event) => event.type === "task.completed"); + const turnEnd = events.findIndex((event) => event.type === "turn.completed"); + assert.equal(completed?.payload.status, "completed"); + assert.equal(completed?.payload.taskId, taskId); + assert.equal(completed?.turnId, undefined); + assert.isAtLeast(turnEnd, 0); + assert.isAbove( + events.findIndex((event) => event.type === "task.completed"), + turnEnd, + ); + assert.deepEqual( + events + .slice(turnEnd + 1) + .filter((event) => event.type === "item.updated" || event.type === "item.completed"), + [], + ); + } else { + assert.equal(started?.payload.description, "sleep 40; echo done-a"); + } + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + } + it.effect("sends runtime context with the current model without changing saved prompts", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-runtime-context"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 1f0053da34..2a92dba3d3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -68,6 +68,10 @@ import { normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { + buildGrokBackgroundTaskEvents, + type GrokBackgroundTaskRecord, +} from "../acp/XAiBackgroundTasks.ts"; import { extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, @@ -174,6 +178,10 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + /** Live monitor and shell identities, with their originating turns. */ + readonly backgroundTasks: Map; + readonly completedBackgroundTaskIds: Map; + readonly ambiguousBackgroundTaskIds: Set; } function settlePendingApprovalsAsCancelled( @@ -1337,6 +1345,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + backgroundTasks: new Map(), + completedBackgroundTaskIds: new Map(), + ambiguousBackgroundTaskIds: new Set(), }; const nf = yield* Stream.runDrain( @@ -1359,6 +1370,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + event._tag === "ToolCallUpdated" && + !ctx.stopped && + (notificationTurnId === undefined || + !ctx.interruptedTurnIds.has(notificationTurnId)) + ) { + for (const taskEvent of buildGrokBackgroundTaskEvents({ + tasks: ctx.backgroundTasks, + completedTaskIds: ctx.completedBackgroundTaskIds, + ambiguousTaskIds: ctx.ambiguousBackgroundTaskIds, + toolCallId: event.toolCall.toolCallId, + rawInput: event.toolCall.data.rawInput, + rawOutput: event.toolCall.data.rawOutput, + toolCallStatus: event.toolCall.status, + turnId: notificationTurnId, + })) { + yield* offerRuntimeEvent(ctx.sessionIncarnationId, { + ...taskEvent, + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + }); + } + } if ( notificationTurnId === undefined || ctx.interruptedTurnIds.has(notificationTurnId) diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts b/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts new file mode 100644 index 0000000000..3f77ca451a --- /dev/null +++ b/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vite-plus/test"; +import { TurnId } from "@t3tools/contracts"; + +import { + buildGrokBackgroundTaskEvents, + type GrokBackgroundTaskRecord, +} from "./XAiBackgroundTasks.ts"; + +const turnId = TurnId.make("turn-1"); +const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; +const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; + +function mapper() { + const tasks = new Map(); + const completedTaskIds = new Map(); + const ambiguousTaskIds = new Set(); + const update = ( + rawOutput: unknown, + overrides: Partial[0]> = {}, + ) => + buildGrokBackgroundTaskEvents({ + tasks, + completedTaskIds, + ambiguousTaskIds, + toolCallId: "call-1", + rawInput: { description: "Watch" }, + rawOutput, + toolCallStatus: "completed", + turnId, + ...overrides, + }); + return { tasks, completedTaskIds, ambiguousTaskIds, update }; +} + +describe("Grok background tasks", () => { + it.each([ + [monitor, "monitor-1", "monitor", "Watch"], + [shell, "shell-1", "shell", "sleep 40"], + ] as const)("starts and deduplicates %j", (output, id, taskType, description) => { + const { tasks, update } = mapper(); + expect(update(output)).toEqual([ + { + type: "task.started", + turnId, + payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" }, + }, + ]); + expect(update(output)).toEqual([]); + expect(tasks.size).toBe(1); + }); + + it("accepts automatic backgrounding before the tool becomes terminal", () => { + const { update } = mapper(); + expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started"); + expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]); + expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]); + }); + + it.each([ + ["running", null, "task.progress", undefined], + ["pending", null, "task.progress", undefined], + ["completed", 0, "task.completed", "completed"], + ["success", 0, "task.completed", "completed"], + ["succeeded", 0, "task.completed", "completed"], + ["failed", 1, "task.completed", "failed"], + ["error", 1, "task.completed", "failed"], + ["stopped", 137, "task.completed", "stopped"], + ["killed", 137, "task.completed", "stopped"], + ["cancelled", 137, "task.completed", "stopped"], + [undefined, 0, "task.completed", "completed"], + [undefined, 1, "task.completed", "failed"], + ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => { + const { tasks, update } = mapper(); + update(monitor); + const events = update({ + type: "TaskOutput", + Result: { + task_id: "monitor-1", + command: "[monitor:Watch]", + status, + exit_code, + output: "\n result\nmore", + }, + }); + expect(events).toEqual([ + { + type, + turnId, + payload: { + taskId: "monitor-1", + taskType: "monitor", + description: "Watch", + title: "Watch", + toolUseId: "call-1", + summary: "result", + ...(expectedStatus ? { status: expectedStatus } : {}), + }, + }, + ]); + expect(tasks.size).toBe(type === "task.progress" ? 1 : 0); + }); + + it.each([undefined, TurnId.make("turn-2")])( + "does not attribute old tasks to a later turn: %s", + (laterTurnId) => { + const { tasks, update } = mapper(); + update(shell); + const events = update( + { + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }, + { turnId: laterTurnId }, + ); + expect(events).toHaveLength(1); + expect(events[0]?.turnId).toBeUndefined(); + expect(tasks.size).toBe(0); + }, + ); + + it("starts unknown poll tasks before progress or completion and ignores subagents", () => { + const { tasks, update } = mapper(); + const events = update({ + type: "TaskOutput", + MultiResult: { + results: [ + { task_id: "shell-1", command: "sleep 40", status: "running" }, + { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" }, + { task_id: "agent-1", command: "[subagent:executor] work", status: "running" }, + ], + }, + }); + expect(events.map(({ type }) => type)).toEqual([ + "task.started", + "task.progress", + "task.started", + "task.completed", + ]); + expect(events.map(({ payload }) => payload.taskType)).toEqual([ + "shell", + "shell", + "monitor", + "monitor", + ]); + expect([...tasks.keys()]).toEqual(["shell-1"]); + expect(events.map((event) => event.turnId)).toEqual([ + undefined, + undefined, + undefined, + undefined, + ]); + }); + + it("retires only successfully killed tasks, including mixed results", () => { + const { tasks, update } = mapper(); + update(shell); + update(monitor); + const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } }; + expect(update(result, { toolCallStatus: "failed" })).toEqual([]); + expect(tasks.size).toBe(2); + const events = update({ + type: "KillTask", + MultiResult: { + results: [ + result.Result, + { task_id: "monitor-1", outcome: "error" }, + { task_id: "unknown", outcome: "killed" }, + ], + }, + }); + expect(events).toEqual([ + { + type: "task.completed", + turnId, + payload: { + taskId: "shell-1", + taskType: "shell", + description: "sleep 40", + title: "sleep 40", + toolUseId: "call-1", + status: "stopped", + }, + }, + ]); + expect([...tasks.keys()]).toEqual(["monitor-1"]); + }); + + it("ignores repeated terminal polls and gives a reused provider id a new runtime identity", () => { + const { tasks, completedTaskIds, update } = mapper(); + update(shell); + const terminal = { + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }; + expect(update(terminal).map((event) => event.type)).toEqual(["task.completed"]); + expect(update(terminal)).toEqual([]); + expect(completedTaskIds.get("shell-1")).toBe("call-1"); + expect(update(shell, { toolCallId: "call-2", turnId: TurnId.make("turn-2") })).toMatchObject([ + { type: "task.started", payload: { taskId: "shell-1#call-2", toolUseId: "call-2" } }, + ]); + expect(tasks.get("shell-1")?.payload.taskId).toBe("shell-1#call-2"); + }); + + it("remembers terminal identities for the session even after many other completions", () => { + const { completedTaskIds, update } = mapper(); + const completed = (id: string) => ({ + type: "TaskOutput", + Result: { task_id: id, command: "echo done", status: "completed" }, + }); + update(completed("old-task")); + for (let index = 0; index < 513; index += 1) { + update(completed(`task-${index}`)); + } + expect(completedTaskIds.size).toBe(514); + expect(update(completed("old-task"))).toEqual([]); + }); + + it("fails closed when two live tool calls claim the same provider task id", () => { + const { tasks, ambiguousTaskIds, update } = mapper(); + update(shell); + expect(update(shell, { toolCallId: "call-2" })).toMatchObject([ + { type: "task.completed", payload: { taskId: "shell-1", status: "stopped" } }, + ]); + expect(ambiguousTaskIds.has("shell-1")).toBe(true); + expect(tasks.has("shell-1")).toBe(false); + expect( + update({ + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }), + ).toEqual([]); + }); + + it("bounds long descriptions and output lines before projecting them", () => { + const { update } = mapper(); + const [started] = update(monitor, { + rawInput: { description: "a".repeat(2_000) }, + }); + expect(started?.type).toBe("task.started"); + if (started?.type !== "task.started") throw new Error("expected task.started"); + expect(started?.payload.description).toHaveLength(200); + const [progress] = update({ + type: "TaskOutput", + Result: { + task_id: "monitor-1", + command: "[monitor] Watch", + status: "running", + output: "b".repeat(2_000), + }, + }); + expect(progress?.type).toBe("task.progress"); + if (progress?.type !== "task.progress") throw new Error("expected task.progress"); + expect(progress?.payload.summary).toHaveLength(512); + }); + + it.each([ + null, + [], + {}, + { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" }, + { type: "Monitor", taskId: " " }, + { type: "BackgroundTaskStarted", task_id: "shell-1" }, + { + type: "TaskOutput", + MultiResult: { + results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }], + }, + }, + ])("ignores malformed or unrelated outputs: %j", (output) => { + const { tasks, update } = mapper(); + expect(update(output)).toEqual([]); + expect(tasks.size).toBe(0); + }); +}); diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.ts b/apps/server/src/provider/acp/XAiBackgroundTasks.ts new file mode 100644 index 0000000000..85dbd5b767 --- /dev/null +++ b/apps/server/src/provider/acp/XAiBackgroundTasks.ts @@ -0,0 +1,183 @@ +import { + RuntimeTaskId, + type ProviderRuntimeTaskStartedEvent, + type ProviderRuntimeTaskProgressEvent, + type ProviderRuntimeTaskCompletedEvent, + type TurnId, +} from "@t3tools/contracts"; + +type TaskEvent = + | Pick + | Pick + | Pick; + +export interface GrokBackgroundTaskRecord { + readonly rawTaskId: string; + readonly payload: { + readonly taskId: RuntimeTaskId; + readonly taskType: "monitor" | "shell"; + readonly description: string; + readonly title: string; + readonly toolUseId?: string; + }; + readonly turnId: TurnId | undefined; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function lifecycle(status: unknown, exitCode: unknown) { + switch (text(status)?.toLowerCase()) { + case "pending": + case "running": + return "running"; + case "completed": + case "success": + case "succeeded": + return "completed"; + case "failed": + case "error": + return "failed"; + case "stopped": + case "killed": + case "cancelled": + return "stopped"; + default: + return typeof exitCode === "number" && Number.isFinite(exitCode) + ? exitCode === 0 + ? "completed" + : "failed" + : undefined; + } +} + +/** Map Grok's discriminated tool results, including notifications after the turn ends. */ +export function buildGrokBackgroundTaskEvents(input: { + readonly tasks: Map; + readonly completedTaskIds: Map; + readonly ambiguousTaskIds: Set; + readonly toolCallId: string; + readonly rawInput: unknown; + readonly rawOutput: unknown; + readonly toolCallStatus: string | undefined; + readonly turnId?: TurnId | undefined; +}): TaskEvent[] { + const { tasks, completedTaskIds, ambiguousTaskIds, toolCallId, toolCallStatus, turnId } = input; + const output = record(input.rawOutput); + const events: TaskEvent[] = []; + if ( + toolCallStatus !== "completed" && + toolCallStatus !== "failed" && + output.type !== "BackgroundTaskStarted" + ) { + return events; + } + const attribution = (task: GrokBackgroundTaskRecord) => + task.turnId !== undefined && task.turnId === turnId ? { turnId } : {}; + const start = ( + id: string, + taskType: "monitor" | "shell", + description: string, + toolUseId?: string, + ) => { + if (ambiguousTaskIds.has(id)) return undefined; + const known = tasks.get(id); + if (known) { + if (!toolUseId || known.payload.toolUseId === toolUseId) return known; + // Polls identify only the provider ID. Once two live tool calls reuse it, + // their later results cannot be attributed safely to either activation. + tasks.delete(id); + completedTaskIds.set(id, known.payload.toolUseId); + ambiguousTaskIds.add(id); + events.push({ + type: "task.completed", + payload: { ...known.payload, status: "stopped" }, + ...attribution(known), + }); + return undefined; + } + const previousToolUseId = completedTaskIds.get(id); + if (completedTaskIds.has(id) && (!toolUseId || previousToolUseId === toolUseId)) { + return undefined; + } + const task: GrokBackgroundTaskRecord = { + rawTaskId: id, + payload: { + taskId: RuntimeTaskId.make(completedTaskIds.has(id) ? `${id}#${toolUseId}` : id), + taskType, + description: description.slice(0, 200), + title: description.slice(0, 200), + ...(toolUseId ? { toolUseId } : {}), + }, + // Polls can rediscover older tasks without establishing their originating turn. + turnId: toolUseId ? turnId : undefined, + }; + completedTaskIds.delete(id); + tasks.set(id, task); + events.push({ type: "task.started", payload: task.payload, ...attribution(task) }); + return task; + }; + const complete = ( + task: GrokBackgroundTaskRecord, + status: "completed" | "failed" | "stopped", + summary?: string, + ) => { + tasks.delete(task.rawTaskId); + completedTaskIds.delete(task.rawTaskId); + completedTaskIds.set(task.rawTaskId, task.payload.toolUseId); + events.push({ + type: "task.completed", + payload: { ...task.payload, status, ...(summary ? { summary } : {}) }, + ...attribution(task), + }); + }; + + if (output.type === "Monitor" && toolCallStatus === "completed") { + const id = text(output.taskId); + if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId); + } else if (output.type === "BackgroundTaskStarted") { + const id = text(output.task_id) ?? text(output.taskId); + const command = text(output.command); + if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId); + } else if (output.type === "TaskOutput" || output.type === "KillTask") { + const results = record(output.MultiResult).results; + for (const value of Array.isArray(results) ? results : [output.Result]) { + const result = record(value); + const id = text(result.task_id); + if (!id) continue; + if (output.type === "KillTask") { + const task = tasks.get(id); + if (task && toolCallStatus === "completed" && result.outcome === "killed") + complete(task, "stopped"); + continue; + } + const command = text(result.command); + const status = lifecycle(result.status, result.exit_code); + if (!command || !status || command.startsWith("[subagent:")) continue; + const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command); + if (!task) continue; + const summary = text(result.output) + ?.split("\n") + .find((line) => line.trim()) + ?.trim() + .slice(0, 512); + if (status === "running") { + events.push({ + type: "task.progress", + payload: { ...task.payload, ...(summary ? { summary } : {}) }, + ...attribution(task), + }); + } else { + complete(task, status, summary); + } + } + } + return events; +}