Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 45 additions & 34 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -231,44 +231,52 @@ export function useThreadListActions(
action: "Settled" | "Snoozed" | "Unpinned",
claim: ReturnType<typeof ThreadUndo.begin>,
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",
Expand All @@ -279,15 +287,18 @@ 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<typeof ThreadUndo.begin> | undefined;
const succeeded = await executeAction("settle", thread, () => {
ThreadUndo.invalidate("pin", key);
ThreadUndo.invalidate("snooze", key);
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;
Expand Down
1 change: 0 additions & 1 deletion apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -723,7 +723,6 @@ function ThreadRouteContent(
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
runId: composer.interruptibleRunId,
},
});
};
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
16 changes: 7 additions & 9 deletions apps/server/src/orchestration-v2/EventSink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,11 @@ export interface EventSinkV2Shape {
readonly acceptedAt: DateTime.Utc;
readonly events: ReadonlyArray<OrchestrationV2DomainEvent>;
readonly effects: ReadonlyArray<EffectOutbox.PendingOrchestrationEffectV2>;
readonly cancelUnsettledEffects?: {
readonly cancelUnsettledEffects?: ReadonlyArray<{
readonly threadId: ThreadId;
readonly effectTypes: ReadonlyArray<EffectOutbox.OrchestrationEffectRequestV2["type"]>;
readonly reason: string;
};
}>;
/** Like writeWithEffects' updateDelegatedCompletionOnly, for this run's run.updated only. */
readonly updateDelegatedCompletionOnlyRunId?: RunId;
}) => Effect.Effect<
Expand Down Expand Up @@ -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) =>
Expand Down
94 changes: 80 additions & 14 deletions apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
(
[
Expand 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)),
);
Loading
Loading