From 5af8ea378a82db7f4ed7515a6f46e0c7d98ea648 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:52:57 +1000 Subject: [PATCH 1/3] fix(mobile): rebuild pending-move fixtures with the shared shell factory The "hide subagent threads from v2 lists" change made getThreadListV2OrderedSection read thread.lineage, but the pending-move suite still hand-cast partial EnvironmentThreadShell rows, so all five cases crashed with "Cannot read properties of undefined". Build the rows through makeThreadShellFixture so the fixtures track every field the shared section helper reads; assertions are unchanged. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/mobile/src/state/thread-order.test.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts index 92db3a89b2f4..b30447a01907 100644 --- a/apps/mobile/src/state/thread-order.test.ts +++ b/apps/mobile/src/state/thread-order.test.ts @@ -4,6 +4,7 @@ import type { Atom } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { createPendingThreadOrder } from "../features/threads/threadOrder"; +import { makeThreadShellFixture } from "../test-fixtures"; import { appAtomRegistry } from "./atom-registry"; import { beginPendingThreadOrder, @@ -36,17 +37,14 @@ const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< >; function fixture() { - // Only section membership and order fields are read by this coordinator. - const rows = ["a", "b"].map( - (id, index) => - ({ - id: ThreadId.make(id), - environmentId: EnvironmentId.make("env"), - createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, - archivedAt: null, - pinnedAt: null, - activeOrderKey: null, - }) as EnvironmentThreadShell, + // The shared section helper also reads lineage/settled/snooze fields, so + // partial casts break when it grows — build complete shells instead. + const rows = ["a", "b"].map((id, index) => + makeThreadShellFixture({ + id: ThreadId.make(id), + environmentId: EnvironmentId.make("env"), + createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, + }), ); appAtomRegistry.set(shellsAtom, rows); const pending = createPendingThreadOrder({ From 82b753431d817acdc181e83efd19e90d4782f04b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:35:57 +1000 Subject: [PATCH 2/3] fix(ci): register the live background verifier and drop dead v2 exports knip:check reported apps/server/scripts/verify-background-live.ts as an unused file even though docs/operations/background-verification.md documents it as the live provider verification entry point. Register it as an apps/server knip entry alongside the other runnable scripts. The exports pass also flagged two exports that lost their consumers in the v2 port: autoSettlementSettingsKey lost the unit tests that used it (restored from main's ThreadSettlementReactor.test.ts), and foldUserInputActivities plus projectQuestionToolInput became dead once the server started attaching questionAnswer to resolved turn items. Model: SWE-2 High via T3 Code (Cursor harness). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ThreadSettlementService.test.ts | 33 ++++ knip.jsonc | 1 + .../client-runtime/src/work-log/userInput.ts | 152 +----------------- packages/shared/src/toolActivity.ts | 33 ---- 4 files changed, 35 insertions(+), 184 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index e800e0b9f151..600cc7727d91 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -38,6 +38,7 @@ import { ProjectionStoreV2 } from "./ProjectionStore.ts"; import * as ThreadSettlementService from "./ThreadSettlementService.ts"; import { + autoSettlementSettingsKey, isAutoSettlementCandidate, QUEUED_TURN_START_GRACE_MS, resolveAutoSettlementAt, @@ -282,6 +283,38 @@ describe("resolveAutoSettlementAt", () => { const NOW = "2026-08-28T12:00:00.000Z"; const PROJECT_ID = ProjectId.make("settlement-project"); +const LINKED_PROJECT_ID = ProjectId.make("linked-settlement-project"); + +describe("autoSettlementSettingsKey", () => { + it("distinguishes a project that inherits the threshold from one that disables it", () => { + const inherits = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } }, + }); + const never = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null }, + }, + }); + assert.notStrictEqual(inherits, never); + }); + + it("ignores project overrides that do not touch settlement", () => { + const base = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } }, + }); + const unrelated = autoSettlementSettingsKey({ + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" }, + [PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true }, + }, + }); + assert.strictEqual(base, unrelated); + }); +}); type AutoSettleCommand = Extract; diff --git a/knip.jsonc b/knip.jsonc index 2c7cadb56642..7c63ac1dcc34 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -24,6 +24,7 @@ "src/claudeHistoryWorker.ts!", "scripts/cli.ts", "scripts/probe-claude-fork-local-rollback-replay.ts", + "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", ], // Keep the transitive Effect runtime pinned for standalone npm installs. diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index 18dea7dd83c2..19e927d9dab4 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -1,9 +1,4 @@ -import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; -import { - type OrchestrationThreadActivity, - UserInputAttachmentAnswerPayload, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; +import { type UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; function record(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -11,151 +6,6 @@ function record(value: unknown): Record | undefined { : undefined; } -const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload); - -function displayOptionAnswer(value: unknown, labels: ReadonlyMap): unknown { - if (typeof value === "string") return labels.get(value) ?? value; - if (Array.isArray(value)) return value.map((answer) => displayOptionAnswer(answer, labels)); - const nested = record(value); - return nested && "answers" in nested - ? { ...nested, answers: displayOptionAnswer(nested.answers, labels) } - : value; -} - -function questionFingerprint( - turnId: string, - questions: ReadonlyArray, -): string | undefined { - const texts = questions.map((question) => (typeof question === "string" ? question.trim() : "")); - // Sort the fresh array in place because Hermes does not provide toSorted. - return texts.length > 0 && texts.every(Boolean) - ? JSON.stringify([turnId, texts.sort()]) - : undefined; -} - -function withoutDuplicateQuestionTools( - activities: ReadonlyArray, -): ReadonlyArray { - const questions = new Set(); - for (const activity of activities) { - if (activity.kind !== "user-input.answer-submitted" || !activity.turnId) continue; - const payload = record(activity.payload); - const texts = Object.values(record(payload?.questionTextById) ?? {}); - const fingerprint = questionFingerprint(activity.turnId, texts); - if (fingerprint) questions.add(fingerprint); - } - if (questions.size === 0) return activities; - const duplicateToolIds = new Set(); - for (const activity of activities) { - if (!activity.kind.startsWith("tool.") || !activity.turnId) continue; - const payload = record(activity.payload); - if (typeof payload?.toolCallId !== "string") continue; - const input = projectQuestionToolInput(record(payload.data) ?? {}, payload.title).input; - if (!input) continue; - const fingerprint = questionFingerprint( - activity.turnId, - input.questions.map((question) => record(question)?.question), - ); - if (fingerprint && questions.has(fingerprint)) { - duplicateToolIds.add(JSON.stringify([activity.turnId, payload.toolCallId])); - } - } - return activities.filter((activity) => { - const payload = record(activity.payload); - const toolCallId = payload?.toolCallId; - return ( - activity.tone === "error" || - /^(failed|declined|stopped|cancelled)$/.test(String(payload?.status)) || - !activity.kind.startsWith("tool.") || - typeof toolCallId !== "string" || - !duplicateToolIds.has(JSON.stringify([activity.turnId, toolCallId])) - ); - }); -} - -/** Keep a question and its answer at the original tool position in the work log. */ -export function foldUserInputActivities( - activities: ReadonlyArray, -): ReadonlyArray { - const requests = new Map(); - for (const activity of activities) { - if ( - activity.kind !== "user-input.requested" && - activity.kind !== "user-input.resolved" && - activity.kind !== "user-input.answer-submitted" - ) - continue; - const requestId = record(activity.payload)?.requestId; - if (typeof requestId !== "string" || !requestId) continue; - const group = requests.get(requestId) ?? []; - group.push(activity); - requests.set(requestId, group); - } - const replacements = new Map(); - for (const [requestId, group] of requests) { - const payloads = group.map((activity) => record(activity.payload)!); - const questions = new Map>(); - const texts = new Map(); - for (const payload of payloads) { - for (const [id, text] of Object.entries(record(payload.questionTextById) ?? {})) - texts.set(id, text); - for (const value of Array.isArray(payload.questions) ? payload.questions : []) { - const question = record(value); - if (typeof question?.id !== "string") continue; - questions.set(question.id, question); - if (typeof question.question === "string") texts.set(question.id, question.question); - } - } - const questionTextById = Object.fromEntries(texts); - const submitted = group.findLast( - (activity) => - activity.kind === "user-input.answer-submitted" && - record(record(activity.payload)?.answers), - ); - const rawAnswers = - record(record(submitted?.payload)?.answers) ?? - payloads.map((payload) => record(payload.answers)).findLast(Boolean) ?? - {}; - const answers = Object.fromEntries( - Object.entries(rawAnswers).map(([id, value]) => { - const options = questions.get(id)?.options; - const labels = new Map(); - for (const candidate of Array.isArray(options) ? options : []) { - const option = record(candidate); - if (typeof option?.value === "string" && typeof option.label === "string") - labels.set(option.value, option.label); - } - return [id, displayOptionAnswer(value, labels)]; - }), - ); - const attachmentsByQuestionId = Object.fromEntries( - payloads.flatMap((payload) => Object.entries(record(payload.attachmentsByQuestionId) ?? {})), - ); - const answer = { requestId, questionTextById, answers, attachmentsByQuestionId }; - if (!isQuestionAnswer(answer)) continue; - const submittedAnswer = - Object.keys(answers).length > 0 || Object.keys(attachmentsByQuestionId).length > 0; - for (const activity of group) replacements.set(activity, null); - replacements.set(group[0]!, { - ...group[0]!, - kind: "user-input.answer-submitted", - tone: "tool", - summary: submittedAnswer - ? "User input submitted" - : group.some((activity) => activity.kind === "user-input.resolved") - ? "User input dismissed" - : "User input requested", - payload: answer, - }); - } - return withoutDuplicateQuestionTools( - activities.flatMap((activity) => { - const replacement = replacements.get(activity); - return replacement === null ? [] : [replacement ?? activity]; - }), - ); -} - export function getQuestionAnswerText(value: unknown): string { if (typeof value === "string") return value; if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", "); diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 8c1c65565f51..2fd04e76696f 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -259,36 +259,3 @@ export function deriveToolActivityPresentation( summary: title ?? fallbackSummary, }; } - -export function projectQuestionToolInput(data: Record, title: unknown) { - const item = asRecord(data.item); - const toolName = data.toolName ?? data.tool ?? item?.tool ?? title; - if (typeof toolName !== "string") return {}; - const name = toolName - .split(/__|[./]/) - .at(-1) - ?.replace(/[_\s]/g, "") - .toLowerCase(); - if (!name || !/^(askuserquestion|requestuserinput(?:async)?|askquestion|question)$/.test(name)) - return {}; - const input = asRecord( - data.input ?? data.rawInput ?? asRecord(data.state)?.input ?? item?.arguments, - ); - const questions = input?.questions ?? asRecord(input?.params)?.questions; - if (!Array.isArray(questions)) return {}; - // Clients match native tools to the canonical question; choices and answers - // already live on the user-input activities and need not cross the wire twice. - return { - toolName, - input: { - questions: questions.map((value) => { - const question = asRecord(value); - return { - question: asTrimmedString( - question?.question ?? question?.question_text ?? question?.prompt ?? question?.title, - ), - }; - }), - }, - }; -} From 73a032f98771dcad03f2f9fda27fa67fc668ab8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:52:26 +1000 Subject: [PATCH 3/3] fix(mobile): pin expo-audio so the release smoke patch stays in use apps/mobile declares expo-audio as ~57.0.4 while patchedDependencies carries expo-audio@57.0.4. The release smoke regenerates the lockfile in an isolated root, where ~57.0.4 resolves to the newer 57.0.5 and pnpm fails with ERR_PNPM_UNUSED_PATCH. Pin the dependency to 57.0.4, matching the upstream fix in pingdotgg/t3code#11426, so the still-needed patch (error reporting in AudioRecorder.didFinish, not present upstream in 57.0.5) stays applied and frozen installs stay reproducible. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/mobile/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2a5c66c0e6e8..985dbc7333d3 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -75,7 +75,7 @@ "effect": "catalog:", "expo": "~57.0.18", "expo-asset": "~57.0.15", - "expo-audio": "~57.0.4", + "expo-audio": "57.0.4", "expo-auth-session": "~57.0.10", "expo-blur": "~57.0.2", "expo-build-properties": "~57.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d99ffbb6ceb..5d7f35a1cd86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,7 +322,7 @@ importers: specifier: ~57.0.15 version: 57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2) expo-audio: - specifier: ~57.0.4 + specifier: 57.0.4 version: 57.0.4(patch_hash=fa9a3e0442ed395d4071bb406e08c3a471c9a84700bdfa0b9ad7ff144c96041a)(expo-asset@57.0.15(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@7.0.2))(expo@57.0.18)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-auth-session: specifier: ~57.0.10