diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index d40f231e0c6c4..262a4795c144a 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -177,7 +177,7 @@ function confirmThreadDeletion( ) { if (!checkThreadOperationPermission(thread, actionFailureTitle("delete"))) return; const title = "Delete thread?"; - const message = `“${thread.title}” will be permanently deleted, including its terminal history.`; + const message = `“${thread.title}” and all nested child threads will be permanently deleted, including their terminal history.`; Dialog.confirm({ title, message, @@ -231,44 +231,52 @@ export function useThreadListActions( action: "Settled" | "Snoozed" | "Unpinned", claim: ReturnType, plan: ThreadUndo.ThreadUndoPlan, + groupPlans = [{ thread, plan }], ) => showThreadUndoNotice({ action, claim, undo: async () => { - try { - await ThreadUndo.executeThreadUndo( - plan, - async (step) => { - switch (step.action) { - case "unsettle": - return unsettleThread(thread); - case "unsnooze": - return unsnoozeThread(thread); - case "pin": - return pinThread(thread, step.orderKey); - case "snooze": { - const failureTitle = - action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle"; - if (!checkThreadOperationPermission(thread, failureTitle)) return false; - const result = await snoozeMutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id, snoozedUntil: step.snoozedUntil }, - }); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Dialog.alert( - "Could not restore snooze", - error instanceof Error ? error.message : "The thread could not be snoozed.", - ); - return false; + const restoreGroup = async () => { + for (const entry of groupPlans) { + const { thread, plan } = entry; + const succeeded = await ThreadUndo.executeThreadUndo( + plan, + async (step) => { + switch (step.action) { + case "unsettle": + return unsettleThread(thread); + case "unsnooze": + return unsnoozeThread(thread); + case "pin": + return pinThread(thread, step.orderKey); + case "snooze": { + const failureTitle = + action === "Unpinned" ? "Could not undo unpin" : "Could not undo settle"; + if (!checkThreadOperationPermission(thread, failureTitle)) return false; + const result = await snoozeMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, snoozedUntil: step.snoozedUntil }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not restore snooze", + error instanceof Error ? error.message : "The thread could not be snoozed.", + ); + return false; + } + return true; } - return true; } - } - }, - (result) => result, - ); + }, + (result) => result, + ); + if (!succeeded) return; + } + }; + try { + await restoreGroup(); } catch (error) { Dialog.alert( "Could not undo thread action", @@ -279,7 +287,10 @@ export function useThreadListActions( }); const settleThread = async (thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); - const plan = ThreadUndo.planThreadUndo("settle", thread); + const plans = ThreadUndo.planGroupSettlementUndo( + thread, + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + ); let claim: ReturnType | undefined; const succeeded = await executeAction("settle", thread, () => { ThreadUndo.invalidate("pin", key); @@ -287,7 +298,7 @@ export function useThreadListActions( claim = ThreadUndo.begin("settle", key); }); if (claim) { - if (succeeded) offerUndo(thread, "Settled", claim, plan); + if (succeeded) offerUndo(thread, "Settled", claim, plans[0]!.plan, plans); else claim.finish(); } return succeeded; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0ae3171bd1601..ffbdcd4a8457b 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -723,7 +723,6 @@ function ThreadRouteContent( environmentId: selectedThread.environmentId, input: { threadId: selectedThread.id, - runId: composer.interruptibleRunId, }, }); }; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index ec2f96d7fdb6a..217860ccfbd77 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -253,7 +253,7 @@ const ThreadReadTool = Tool.make("t3_thread_read", { const ThreadUpdateTool = Tool.make("t3_thread_update", { description: - "Rename a thread (`action:'rename'` with `title`), regenerate its title, or organize it (`action:'set_parent'` with `groupingParentThreadId`; null moves to top level). Grouping does not change agent lifecycle. Omit `threadId` for this thread. Workspace and branch cannot change here.", + "Rename a thread (`action:'rename'` with `title`), regenerate its title, or organize it (`action:'set_parent'` with `groupingParentThreadId`; null moves to top level). Deleting, settling, archiving, snoozing, or stopping a parent also affects nested children. Un-settling affects only the selected thread. Omit `threadId` for this thread. Workspace and branch cannot change here.", // Keeps the input's action/field check, which rebuilding the struct would drop. parameters: ThreadMetadataMcpUpdateInput.mapFields( Struct.assign({ environmentId: OrchestratorMcpEnvironmentTarget }), diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 81e64ddb70920..b3da4cb29ffeb 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -170,10 +170,11 @@ export interface EventSinkV2Shape { readonly acceptedAt: DateTime.Utc; readonly events: ReadonlyArray; readonly effects: ReadonlyArray; - readonly cancelUnsettledEffects?: { + readonly cancelUnsettledEffects?: ReadonlyArray<{ + readonly threadId: ThreadId; readonly effectTypes: ReadonlyArray; readonly reason: string; - }; + }>; /** Like writeWithEffects' updateDelegatedCompletionOnly, for this run's run.updated only. */ readonly updateDelegatedCompletionOnlyRunId?: RunId; }) => Effect.Effect< @@ -714,13 +715,10 @@ const layerBase: Layer.Layer< error: null, }; yield* commandReceipts.upsert(receipt); - const cancelledEffectIds = - input.cancelUnsettledEffects === undefined - ? [] - : yield* effectOutbox.cancelUnsettled({ - threadId: input.threadId, - ...input.cancelUnsettledEffects, - }); + const cancelledEffectIds = (yield* Effect.forEach( + input.cancelUnsettledEffects ?? [], + (cancellation) => effectOutbox.cancelUnsettled(cancellation), + )).flat(); return { receipt, storedEvents, outcomes, committed: true as const, cancelledEffectIds }; }), (result) => diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 0a2ace6b9c1a2..1dbe727099089 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1699,7 +1699,7 @@ it.effect("dispatch records the command outcome and its duration", () => ); it.effect( - "organizes independent threads without changing their execution lineage or lifecycle", + "organizes threads without changing execution lineage and cascades explicit lifecycle actions", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -1882,19 +1882,6 @@ it.effect( threadId: childId, groupingParentThreadId: parentId, }); - yield* orchestrator.dispatch({ - type: "thread.settle", - commandId: CommandId.make("settle-group-parent"), - threadId: parentId, - }); - assert.isNull((yield* projections.getThread(childId)).settledOverride); - yield* orchestrator.dispatch({ - type: "thread.delete", - commandId: CommandId.make("delete-group-parent"), - threadId: parentId, - }); - assert.isNull((yield* projections.getThread(childId)).deletedAt); - assert.equal((yield* projections.getThreadShell(childId))?.groupingParentThreadId, parentId); const concurrentMoves = yield* Effect.all( ( [ @@ -1915,5 +1902,84 @@ it.effect( ); assert.equal(concurrentMoves.filter(Result.isSuccess).length, 1); assert.equal(concurrentMoves.filter(Result.isFailure).length, 1); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("restore-group-child"), + threadId: childId, + groupingParentThreadId: parentId, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("restore-group-grandchild"), + threadId: siblingId, + groupingParentThreadId: childId, + }); + const snoozedUntil = "2099-07-25T09:00:00.000Z"; + for (const action of [ + { type: "thread.settle" }, + { type: "thread.snooze", snoozedUntil }, + { type: "thread.unsnooze", reason: "user" }, + { type: "thread.archive" }, + { type: "thread.unarchive" }, + ] as const) { + const command = { + ...action, + commandId: CommandId.make(`group:${action.type}`), + threadId: parentId, + }; + const result = yield* orchestrator.dispatch(command); + assert.deepEqual( + new Set(result.storedEvents.map(({ event }) => event.threadId)), + new Set([parentId, childId, siblingId]), + ); + const parent = yield* projections.getThread(parentId); + for (const threadId of [childId, siblingId]) { + const child = yield* projections.getThread(threadId); + assert.deepEqual(child.archivedAt, parent.archivedAt); + assert.deepEqual(child.settledOverride, parent.settledOverride); + assert.deepEqual(child.snoozedUntil, parent.snoozedUntil); + } + const replayed = yield* orchestrator.dispatch(command); + assert.isTrue(replayed.replayed); + assert.deepEqual(replayed.storedEvents, result.storedEvents); + } + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("resume-group-parent"), + threadId: parentId, + reason: "user", + }); + assert.equal((yield* projections.getThread(parentId)).settledOverride, "active"); + assert.equal((yield* projections.getThread(childId)).settledOverride, "settled"); + assert.equal((yield* projections.getThread(siblingId)).settledOverride, "settled"); + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("discard-unused-group-parent"), + threadId: parentId, + onlyIfUnused: true, + }); + assert.isNull((yield* projections.getThread(parentId)).deletedAt); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("archive-child-before-group-delete"), + threadId: childId, + }); + const deletion = yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("delete-group-parent"), + threadId: parentId, + }); + assert.deepEqual( + new Set(deletion.storedEvents.map(({ event }) => event.threadId)), + new Set([parentId, childId, siblingId]), + ); + for (const threadId of [parentId, childId, siblingId]) { + assert.isNotNull((yield* projections.getThread(threadId)).deletedAt); + } + const unrelated = yield* projections.getThread(otherId); + assert.isNull(unrelated.deletedAt); + assert.isNull(unrelated.archivedAt); + assert.isNull(unrelated.settledOverride); + assert.isNull(unrelated.snoozedUntil); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a1679faea5aff..b32a4f2cbe089 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -556,6 +556,33 @@ export function threadHasUnfinishedWork(records: { ); } +function isThreadGroupCommand(command: OrchestrationV2ServerCommand): command is Extract< + OrchestrationV2ServerCommand, + { + readonly type: + | "thread.delete" + | "thread.settle" + | "thread.archive" + | "thread.unarchive" + | "thread.snooze" + | "thread.unsnooze" + | "thread.stop"; + } +> { + switch (command.type) { + case "thread.delete": + case "thread.settle": + case "thread.archive": + case "thread.unarchive": + case "thread.snooze": + case "thread.unsnooze": + case "thread.stop": + return true; + default: + return false; + } +} + function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { switch (command.type) { case "thread.create": @@ -13743,10 +13770,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio { readonly events: ReadonlyArray; readonly effects: ReadonlyArray; - readonly cancelUnsettledEffects?: { + readonly cancelUnsettledEffects?: ReadonlyArray<{ + readonly threadId: ThreadId; readonly effectTypes: ReadonlyArray; readonly reason: string; - }; + }>; readonly updateDelegatedCompletionOnlyRunId?: RunId; }, OrchestratorV2Error @@ -14191,7 +14219,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { events: decidedEvents, effects: yield* Ref.get(effects), - ...(cancelUnsettledEffects === undefined ? {} : { cancelUnsettledEffects }), + ...(cancelUnsettledEffects === undefined + ? {} + : { + cancelUnsettledEffects: [ + { threadId: commandThreadId(command), ...cancelUnsettledEffects }, + ], + }), ...(updateDelegatedCompletionOnlyRunId === undefined ? {} : { updateDelegatedCompletionOnlyRunId }), @@ -14246,10 +14280,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; }); - /** `sideChatIds` are the live side chats a `thread.delete` cascades to, read under their locks. */ + // All cascade targets are locked by dispatchWithReceipt until the shared commit finishes. const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, sideChatIds: ReadonlyArray = [], + groupedThreadIds: ReadonlyArray = [], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -14276,14 +14311,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const limit = yield* DispatchModeLimit; if (limit !== undefined) { // Fork and merge-back sources are unlocked; validate the modes copied by their dispatch. - const threadId = commandThreadId(command); - const shell = yield* projectionStore - .getThreadShell(threadId) - .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); - if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); + for (const threadId of [commandThreadId(command), ...groupedThreadIds, ...sideChatIds]) { + const shell = yield* projectionStore + .getThreadShell(threadId) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); + } } - const plan = yield* dispatchOnce(command, sideChatIds).pipe( + const plan = yield* Effect.gen(function* () { + // Draft cleanup must not remove a group, even when the parent has no messages yet. + if ( + command.type === "thread.delete" && + command.onlyIfUnused === true && + groupedThreadIds.length > 0 + ) { + return { events: [], effects: [] }; + } + if (command.type === "thread.delete") { + return yield* dispatchOnce(command, [...groupedThreadIds, ...sideChatIds]); + } + const primary = yield* dispatchOnce(command, sideChatIds); + if (!isThreadGroupCommand(command) || groupedThreadIds.length === 0) return primary; + const children = yield* Effect.forEach(groupedThreadIds, (threadId) => { + const childCommand = { + ...command, + threadId, + commandId: CommandId.make(`${command.commandId}:group:${threadId}`), + }; + // The caller's stale-run guard describes only the selected thread. + if (childCommand.type === "thread.settle") delete childCommand.expectedLatestRunId; + return dispatchOnce(childCommand, []); + }); + const plans = [primary, ...children]; + return { + ...primary, + events: plans.flatMap((plan) => plan.events), + effects: plans.flatMap((plan) => plan.effects), + cancelUnsettledEffects: plans.flatMap((plan) => plan.cancelUnsettledEffects ?? []), + }; + }).pipe( Effect.tap((planned) => refuseAboveRuntimeModeCeiling(command, planned.events)), Effect.filterOrFail( (planned) => @@ -14501,18 +14568,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? dispatch : threadDispatch.withLock(command.sourceThreadId, dispatch); } - if (command.type === "thread.delete") - return yield* threadDispatch.withLock( - command.threadId, - liveSideChatIds(command.threadId).pipe( - Effect.flatMap((sideChatIds) => - sideChatIds.reduce( - (locked, sideChatId) => threadDispatch.withLock(sideChatId, locked), - dispatchWithReceiptEffect(command, sideChatIds), - ), - ), - ), + if (isThreadGroupCommand(command)) { + return yield* groupingChanges.withPermit( + Effect.gen(function* () { + const groupedThreadIds = yield* projectionStore + .getThreadGroupingDescendants(command.threadId) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + const threadIds = [command.threadId, ...groupedThreadIds]; + const decide = Effect.gen(function* () { + const sideChatIds = + command.type === "thread.delete" + ? [...new Set((yield* Effect.forEach(threadIds, liveSideChatIds)).flat())] + : []; + return yield* sideChatIds.reduceRight( + (locked, threadId) => threadDispatch.withLock(threadId, locked), + dispatchWithReceiptEffect(command, sideChatIds, groupedThreadIds), + ); + }); + // Group mutations share the reparenting gate; execution parents precede their side chats. + return yield* threadIds.reduceRight( + (locked, threadId) => threadDispatch.withLock(threadId, locked), + decide, + ); + }), ); + } // Answering a message-mode question can also start a follow-up. if ( command.type !== "runtime-request.respond" && diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index c0ad36a956d70..e7f2559fd86ea 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -570,6 +570,9 @@ export interface ProjectionStoreV2Shape { readonly getThreadsWithPullRequests: ( threadId?: ThreadId, ) => Effect.Effect, ProjectionStoreV2Error>; + readonly getThreadGroupingDescendants: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionStoreV2Error>; readonly listThreadGroupingParents: ( input: OrchestrationV2ListThreadGroupingParentsInput, ) => Effect.Effect; @@ -7346,6 +7349,37 @@ export const layer: Layer.Layer = })); }, Effect.mapError(shellSnapshotReadError)); + const getThreadGroupingDescendants = Effect.fn("ProjectionStore.getThreadGroupingDescendants")( + function* (threadId: ThreadId) { + const rows = yield* sql<{ + readonly thread_id: string; + readonly parent_id: string; + }>` + SELECT thread_id, json_extract(payload_json, '$.groupingParentThreadId') AS parent_id + FROM orchestration_v2_projection_threads + WHERE project_id = ( + SELECT project_id FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} + ) AND deleted_at IS NULL + AND json_extract(payload_json, '$.groupingParentThreadId') IS NOT NULL + AND json_extract(payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(payload_json, '$.lineage.sideChat') IS NOT 1 + AND json_extract(payload_json, '$.delegatedFrom') IS NULL + `; + const children = Map.groupBy(rows, (row) => row.parent_id); + const visited = new Set([threadId]); + const ids: Array = [threadId]; + for (let index = 0; index < ids.length; index++) { + for (const row of children.get(ids[index]!) ?? []) { + if (visited.has(row.thread_id)) continue; + visited.add(row.thread_id); + ids.push(ThreadId.make(row.thread_id)); + } + } + return ids.slice(1); + }, + Effect.mapError(shellSnapshotReadError), + ); + const listThreadGroupingParents = Effect.fn("ProjectionStore.listThreadGroupingParents")( function* (input: OrchestrationV2ListThreadGroupingParentsInput) { const rows = yield* sql<{ @@ -7607,6 +7641,7 @@ export const layer: Layer.Layer = getThreadsWithPullRequests: readOnReader(getThreadsWithPullRequests), getThreadTitles: readOnReader(getThreadTitles), listThreadGroupingParents: readOnReader(listThreadGroupingParents), + getThreadGroupingDescendants: readOnReader(getThreadGroupingDescendants), getThreadWorktreePaths: readOnReader(getThreadWorktreePaths), getOpenRemoteDelegatedTasks: getOpenRemoteDelegatedTasks.pipe(NodeSqliteClient.readOnly), getThreadProjection: readOnReader(getThreadProjection), diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 642a642a80df1..2c52fcd2b47e6 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -770,7 +770,7 @@ it.effect( assert.equal(summary.terminalizedRuns, 1); assert.equal(summary.stoppedSessions, 1); assert.equal(summary.retiredEffects, 2); - assert.deepEqual(committedInput?.cancelUnsettledEffects?.effectTypes, [ + assert.deepEqual(committedInput?.cancelUnsettledEffects?.[0]?.effectTypes, [ "provider-turn.start", "provider-turn.interrupt", "provider-turn.steer", diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 89e43e5b1ede4..82bc68f8ad19b 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -852,10 +852,13 @@ export const make = Effect.gen(function* () { acceptedAt: now, events, effects, - cancelUnsettledEffects: { - effectTypes: EffectOutbox.PROCESS_BOUND_EFFECT_TYPES, - reason: detail, - }, + cancelUnsettledEffects: [ + { + threadId: projection.thread.id, + effectTypes: EffectOutbox.PROCESS_BOUND_EFFECT_TYPES, + reason: detail, + }, + ], }) .pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 54f018273d837..bb9dc133af78d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -222,6 +222,7 @@ it.effect( getThreadShell: () => Effect.die("unused getThreadShell"), getThreadTitles: () => Effect.die("unused getThreadTitles"), listThreadGroupingParents: () => Effect.die("unused listThreadGroupingParents"), + getThreadGroupingDescendants: () => Effect.die("unused getThreadGroupingDescendants"), findGuestShareThreadId: () => Effect.die("unused findGuestShareThreadId"), getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 6b3ea08a86449..9b58761fc17b5 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -161,6 +161,60 @@ const threadState = (threadId: ThreadId) => }; }); +it.effect("Stop on a group cancels descendant starts without stopping unrelated threads", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const parent = ThreadId.make("thread:group-stop-parent"); + const child = ThreadId.make("thread:group-stop-child"); + const grandchild = ThreadId.make("thread:group-stop-grandchild"); + const unrelated = ThreadId.make("thread:group-stop-unrelated"); + for (const [index, threadId] of [parent, child, grandchild, unrelated].entries()) { + yield* createWatchingThread(threadId, index + 1); + } + for (const [threadId, groupingParentThreadId] of [ + [child, parent], + [grandchild, child], + ] as const) { + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`nest:${threadId}`), + threadId, + groupingParentThreadId, + }); + } + for (const threadId of [child, grandchild, unrelated]) { + yield* send(threadId, "start", "start_immediately"); + } + const before = (yield* orchestrator.getThreadProjection(parent)).thread; + const refused = yield* orchestrator + .dispatch({ + type: "thread.settle", + commandId: CommandId.make("settle-running-group"), + threadId: parent, + }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(refused)); + assert.deepEqual((yield* orchestrator.getThreadProjection(parent)).thread, before); + const command = { + type: "thread.stop" as const, + commandId: CommandId.make("stop-group"), + threadId: parent, + }; + yield* orchestrator.dispatch(command); + for (const threadId of [child, grandchild, unrelated]) { + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.runs[0]?.status, threadId === unrelated ? "starting" : "interrupted"); + const starts = (yield* outbox.listByCommandId( + CommandId.make(`send:${threadId}:start`), + )).filter((effect) => effect.request.type === "provider-turn.start"); + assert.lengthOf(starts, 1); + assert.equal(starts[0]?.status, threadId === unrelated ? "pending" : "cancelled"); + } + assert.isTrue((yield* orchestrator.dispatch(command)).replayed); + }).pipe(Effect.provide(layerTest)), +); + it.effect.each([ ["idle", "thread.stop"], ["idle", "run.interrupt"], diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 46bf33ad26793..04b393c59e674 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -621,27 +621,27 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }), ); - it.effect( - "deletes a side chat with its parent only after the side chat's in-flight command", - () => + it.effect.each(["side-chat", "nested-thread", "nested-side-chat"] as const)( + "deletes a %s with its parent only after the child's in-flight command", + (kind) => Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; const orchestrator = yield* Orchestrator.OrchestratorV2; const eventSink = yield* EventSink.EventSinkV2; const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; - const projectId = ProjectId.make("runtime-side-chat-delete"); - const parentId = ThreadId.make("runtime-side-chat-delete-parent"); - const sideChatId = ThreadId.make("runtime-side-chat-delete-child"); - const updateCommandId = CommandId.make("runtime-side-chat-delete-update"); + const projectId = ProjectId.make(`runtime-side-chat-delete:${kind}`); + const parentId = ThreadId.make(`runtime-side-chat-delete-parent:${kind}`); + const sideChatId = ThreadId.make(`runtime-side-chat-delete-child:${kind}`); + const updateCommandId = CommandId.make(`runtime-side-chat-delete-update:${kind}`); yield* projects.create({ - commandId: CommandId.make("runtime-side-chat-delete-project"), + commandId: CommandId.make(`runtime-side-chat-delete-project:${kind}`), projectId, title: "Side chat deletion", - workspaceRoot: "/work/side-chat-deletion", + workspaceRoot: `/work/side-chat-deletion/${kind}`, }); yield* orchestrator.dispatch({ type: "thread.create", - commandId: CommandId.make("runtime-side-chat-delete-create"), + commandId: CommandId.make(`runtime-side-chat-delete-create:${kind}`), createdBy: "user", creationSource: "web", threadId: parentId, @@ -653,16 +653,37 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i branch: null, worktreePath: null, }); - yield* orchestrator.dispatch({ - type: "thread.fork", - commandId: CommandId.make("runtime-side-chat-delete-fork"), - sourceThreadId: parentId, - targetThreadId: sideChatId, - sourcePoint: { type: "latest_stable" }, - sideChat: true, - createdBy: "user", - creationSource: "web", - }); + const nestedId = + kind === "nested-thread" ? sideChatId : ThreadId.make(`runtime-nested-parent:${kind}`); + if (kind !== "side-chat") { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`runtime-nested-create:${kind}`), + createdBy: "user", + creationSource: "web", + threadId: nestedId, + projectId, + title: "Nested child", + groupingParentThreadId: parentId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + } + if (kind !== "nested-thread") { + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`runtime-side-chat-delete-fork:${kind}`), + sourceThreadId: kind === "side-chat" ? parentId : nestedId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + } const updateReady = yield* Deferred.make(); const releaseUpdate = yield* Deferred.make(); @@ -710,7 +731,7 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i const deleteFiber = yield* orchestrator .dispatch({ type: "thread.delete", - commandId: CommandId.make("runtime-side-chat-delete-parent"), + commandId: CommandId.make(`runtime-side-chat-delete-parent:${kind}`), threadId: parentId, }) .pipe(Effect.forkChild({ startImmediately: true })); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 9402cc966e967..5d27c55e06e37 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5481, "codexAdditionalContextBytes": 7211, - "mcpToolListJsonBytes": 49055 + "mcpToolListJsonBytes": 49151 } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b29b996a8cc76..38676d237fb48 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2543,7 +2543,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve api.dialogs.confirm( [ `Delete ${count} thread${count === 1 ? "" : "s"}?`, - "This permanently clears conversation history for these threads.", + "This permanently clears conversation history for these threads and all nested child threads.", ].join("\n"), { variant: "destructive" }, ), @@ -2924,7 +2924,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve api.dialogs.confirm( [ `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", + "This permanently clears conversation history for this thread and all nested child threads.", ].join("\n"), { variant: "destructive" }, ), diff --git a/apps/web/src/components/sidebar/SidebarThreadGroup.tsx b/apps/web/src/components/sidebar/SidebarThreadGroup.tsx index 1c464d36c1445..c3a9d7b35af65 100644 --- a/apps/web/src/components/sidebar/SidebarThreadGroup.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadGroup.tsx @@ -51,7 +51,7 @@ export function useSidebarThreadGroups(input: { >(); for (const [key, children] of input.childrenByParent) { const state = stored[key] ?? { - expanded: true, + expanded: false, working: input.defaultWorkingExpanded, snoozed: false, settled: false, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 9b6f11e9fe1d5..7c9c519cce33d 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -351,7 +351,7 @@ export function useThreadActionMenu(input: { api.dialogs.confirm( [ `Delete thread "${thread.title}"?`, - "This permanently clears conversation history for this thread.", + "This permanently clears conversation history for this thread and all nested child threads.", ].join("\n"), { variant: "destructive" }, ), diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 1b8c3001eac48..eee234c7f3b87 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -758,7 +758,19 @@ export function useThreadActions() { // oxlint-disable-next-line react/purity -- runs in an event handler (oxc#27309) const nowIso = new Date().toISOString(); const wokeAt = resolved ? threadWokeAt(resolved.thread, { now: nowIso }) : null; - const undoPlan = ThreadUndo.planThreadUndo("settle", resolved?.thread); + const undoPlans = resolved + ? ThreadUndo.planGroupSettlementUndo( + resolved.thread, + readEnvironmentThreadRefs(target.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell ? [shell] : []; + }), + ).map(({ thread, plan }) => ({ + target: + thread === resolved.thread ? target : scopeThreadRef(thread.environmentId, thread.id), + plan, + })) + : [{ target, plan: ThreadUndo.planThreadUndo("settle") }]; // An older unpin/snooze Undo would re-pin or re-snooze, and the server // treats either as a promotion that un-settles; settling supersedes them. ThreadUndo.invalidate("pin", scopedThreadKey(target)); @@ -778,7 +790,14 @@ export function useThreadActions() { showThreadUndoNotice({ action: "Settled", claim: action, - undo: () => runUndoPlan(target, undoPlan), + undo: async () => { + let result = await runUndoPlan(undoPlans[0]!.target, undoPlans[0]!.plan); + for (const entry of undoPlans.slice(1)) { + if (result._tag === "Failure") return result; + result = await runUndoPlan(entry.target, entry.plan); + } + return result; + }, failureTitle: "Failed to undo settle", }); return result; @@ -933,7 +952,7 @@ export function useThreadActions() { localApi.dialogs.confirm( [ `Delete thread "${title}"?`, - "This permanently clears conversation history for this thread.", + "This permanently clears conversation history for this thread and all nested child threads.", ].join("\n"), { variant: "destructive" }, ), diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 072507a749af0..823b710c50416 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -70,13 +70,16 @@ Snoozed, and Settled sections as the main list; finished work stays in the unlabeled active list until it settles. Search still finds individual children. Grouping is saved on the server and follows you across devices. -Grouping does not give the parent control over its children's runs. Stopping or -deleting the parent does not stop or delete these independent threads. +Deleting, settling, archiving, snoozing, or stopping a parent applies to all nested +children, including deeper descendants. Unarchiving and waking a parent also apply +to its children. Un-settling affects only the selected thread, so resuming a parent +leaves completed children settled. Move a child to the top level before acting on +the parent if you want to keep it separate. The parent stays with its children and appears once. A group with an approval, question, or finished result to review stays in the main list. Otherwise, if any member is working, the group appears in Working when that section is enabled. Pinned parents stay pinned. Once the children settle, the parent returns to the -section for its own state; grouping does not change anyone's saved settled state. +section for its own state. Automatic settlement still applies to each thread separately. Collapsing a group keeps its activity summary visible and does not change its section. Children appear at the top level if their parent is archived or unavailable in the current view. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index f813ece95dc45..e8a7a30e218ca 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -85,7 +85,7 @@ export interface ThreadCommandInput extends CommandMetadata { } export interface DeleteThreadInput extends ThreadCommandInput { - /** The server deletes only a thread with no runs and no messages. */ + /** The server deletes only a thread with no runs, messages, or nested children. */ readonly onlyIfUnused?: boolean; } export type ArchiveThreadInput = ThreadCommandInput; diff --git a/packages/client-runtime/src/state/threadUndo.test.ts b/packages/client-runtime/src/state/threadUndo.test.ts new file mode 100644 index 0000000000000..ed1f35d2c98ff --- /dev/null +++ b/packages/client-runtime/src/state/threadUndo.test.ts @@ -0,0 +1,42 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { planGroupSettlementUndo } from "./threadUndo.ts"; + +function thread(id: string, parent: string | null = null) { + return { + id: ThreadId.make(id), + environmentId: EnvironmentId.make("local"), + projectId: ProjectId.make("project"), + groupingParentThreadId: parent === null ? null : ThreadId.make(parent), + settledAt: null, + settledOverride: null, + pinnedAt: null, + pinOrderKey: null, + snoozedUntil: null, + deletedAt: null, + lineage: { rootThreadId: ThreadId.make(id), parentThreadId: null, relationshipToParent: null }, + }; +} + +it("undoes group settlement without reopening children that were already settled", () => { + const parent = thread("parent"); + const active = { ...thread("active", "parent"), pinnedAt: "2026-11-07", pinOrderKey: "a0" }; + const finished = { ...thread("finished", "parent"), settledAt: "2026-11-06" }; + const grandchild = thread("grandchild", "finished"); + const plans = planGroupSettlementUndo(parent, [ + parent, + active, + finished, + grandchild, + thread("unrelated"), + { ...thread("remote", "parent"), environmentId: EnvironmentId.make("remote") }, + { ...thread("foreign", "parent"), projectId: ProjectId.make("other") }, + { ...thread("deleted", "parent"), deletedAt: "2026-11-07" }, + ]); + expect(plans.map(({ thread }) => thread.id)).toEqual(["parent", "active", "grandchild"]); + expect(plans.find(({ thread }) => thread.id === "active")?.plan).toEqual([ + { action: "unsettle" }, + { action: "pin", orderKey: "a0" }, + ]); +}); diff --git a/packages/client-runtime/src/state/threadUndo.ts b/packages/client-runtime/src/state/threadUndo.ts index c9a8ac402bee5..5c57617c8a859 100644 --- a/packages/client-runtime/src/state/threadUndo.ts +++ b/packages/client-runtime/src/state/threadUndo.ts @@ -1,3 +1,5 @@ +import type { EnvironmentThreadShell } from "./models.ts"; + // Shared across hook instances so sidebar, header and menu actions invalidate each other. const currentActions = new Map(); const listeners = new Set<() => void>(); @@ -65,6 +67,55 @@ export function planThreadUndo( return [{ action: "unsettle" }, ...(thread?.pinnedAt != null ? [pin] : []), ...snooze]; } +export function planGroupSettlementUndo< + T extends Pick< + EnvironmentThreadShell, + | "id" + | "environmentId" + | "projectId" + | "groupingParentThreadId" + | "settledAt" + | "settledOverride" + | "pinnedAt" + | "pinOrderKey" + | "snoozedUntil" + | "deletedAt" + | "lineage" + >, +>(parent: T, threads: readonly T[]) { + const children = new Map(); + for (const thread of threads) { + if ( + thread.environmentId !== parent.environmentId || + thread.projectId !== parent.projectId || + thread.deletedAt != null || + thread.groupingParentThreadId == null || + thread.lineage.relationshipToParent === "subagent" || + thread.lineage.sideChat === true + ) + continue; + const siblings = children.get(thread.groupingParentThreadId); + if (siblings) siblings.push(thread); + else children.set(thread.groupingParentThreadId, [thread]); + } + const seen = new Set([parent.id]); + const descendants: T[] = [parent]; + for (let index = 0; index < descendants.length; index++) { + for (const child of children.get(descendants[index]!.id) ?? []) { + if (seen.has(child.id)) continue; + seen.add(child.id); + descendants.push(child); + } + } + return descendants + .filter( + (thread) => + thread.id === parent.id || + (thread.settledOverride !== "settled" && thread.settledAt === null), + ) + .map((thread) => ({ thread, plan: planThreadUndo("settle", thread) })); +} + export async function executeThreadUndo( plan: ThreadUndoPlan, execute: (step: ThreadUndoStep) => Promise, diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index d00ae843953a0..510e6b18d3bea 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3027,7 +3027,7 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.delete"), commandId: CommandId, threadId: ThreadId, - /** Delete only a thread with no runs and no messages; otherwise succeed without a change. */ + /** Delete only a thread with no runs, messages, or nested children; otherwise succeed without a change. */ onlyIfUnused: Schema.optional(Schema.Boolean), }), Schema.Struct({