Skip to content
Open
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
34 changes: 33 additions & 1 deletion apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,40 @@ describe("resolveAutoSettlementAt", () => {
expect(
ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null }),
).toBeNull();
expect(ThreadSettlementService.resolveAutoSettlementAt({ ...input, thread: shell() })).toEqual(
shell().createdAt,
);
});

it("uses creation time for empty threads only after the inactivity window", () => {
const input = {
thread: shell({ createdAt: at(-3 * DAY_MS) }),
pullRequest: null,
nowMs: NOW_MS,
autoSettleAfterDays: 2,
autoSettleOnMerge: true,
};
expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(input.thread.createdAt);
expect(
ThreadSettlementService.resolveAutoSettlementAt({ ...input, thread: shell() }),
ThreadSettlementService.resolveAutoSettlementAt({
...input,
thread: shell({ createdAt: at(-DAY_MS) }),
}),
).toBeNull();
expect(
ThreadSettlementService.resolveAutoSettlementAt({
...input,
thread: shell({ createdAt: at(-2 * DAY_MS) }),
}),
).toBeNull();
expect(
ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleAfterDays: null }),
).toBeNull();
expect(
ThreadSettlementService.resolveAutoSettlementAt({
...input,
thread: shell({ createdAt: at(-3 * DAY_MS), pinnedAt: at(-DAY_MS) }),
}),
).toBeNull();
});

Expand Down
17 changes: 9 additions & 8 deletions apps/server/src/orchestration-v2/ThreadSettlementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,16 +193,17 @@ export function resolveAutoSettlementAt(input: {
};
}
if (!isAutoSettlementCandidate(thread, input.nowMs)) return null;
const activityAtMs = latestMillis([
toMillis(thread.latestUserMessageAt),
toMillis(thread.latestRunRequestedAt),
toMillis(thread.latestRunStartedAt),
toMillis(thread.latestRunCompletedAt),
]);
const activityAtMs =
latestMillis([
toMillis(thread.latestUserMessageAt),
toMillis(thread.latestRunRequestedAt),
toMillis(thread.latestRunStartedAt),
toMillis(thread.latestRunCompletedAt),
]) ?? DateTime.toEpochMillis(thread.createdAt);
if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {
return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
return DateTime.makeUnsafe(activityAtMs);
}
if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
if (input.autoSettleAfterDays === null) return null;
return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS
? DateTime.makeUnsafe(activityAtMs)
: null;
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { shellStreamItemFromThreadShell } from "./ShellStream.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as ThreadManagementService from "./ThreadManagementService.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
import * as ThreadSettlementService from "./ThreadSettlementService.ts";

const layerPlatformTest = Layer.merge(
NodeServices.layer,
Expand Down Expand Up @@ -1674,6 +1675,64 @@ it.layer(layerLegacyImportTest)("OrchestrationV2 legacy import", (it) => {
});

it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
it.effect("persists creation time when automatically settling a zero-activity thread", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const projections = yield* ProjectionStore.ProjectionStoreV2;
const threadId = ThreadId.make("runtime-zero-activity-settlement");
const projectId = ProjectId.make("runtime-zero-activity-settlement-project");
yield* seedProject({
projectId,
title: "Settlement project",
workspaceRoot: "/workspace/settlement",
defaultModelSelection: null,
createdAt: "2026-09-07T00:00:00.000Z",
});
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("runtime-zero-activity-settlement-create"),
threadId,
projectId,
title: "Empty thread",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdBy: "user",
creationSource: "web",
});
const [candidate] = yield* projections.getSettlementCandidates(threadId);
assert.isDefined(candidate);
assert.isNull(candidate!.latestUserMessageAt);
assert.isNull(candidate!.latestRunRequestedAt);
const settledAt = ThreadSettlementService.resolveAutoSettlementAt({
thread: candidate!,
pullRequest: null,
nowMs: DateTime.toEpochMillis(candidate!.createdAt) + 4 * 24 * 60 * 60 * 1_000,
autoSettleAfterDays: 3,
autoSettleOnMerge: true,
});
assert.deepEqual(settledAt, candidate!.createdAt);
yield* orchestrator.dispatch({
type: "thread.auto-settle",
commandId: CommandId.make("runtime-zero-activity-settlement-auto"),
threadId,
snapshotAt: candidate!.updatedAt,
settledAt: settledAt!,
});
const settled = yield* orchestrator.getThreadProjection(threadId);
assert.equal(settled.thread.settledOverride, "settled");
assert.deepEqual(settled.thread.settledAt, settled.thread.createdAt);
assert.isEmpty(settled.messages);
assert.isEmpty(settled.runs);
const shell = (yield* orchestrator.getShellSnapshot()).threads.find(
(thread) => thread.id === threadId,
);
assert.deepEqual(shell?.settledAt, settled.thread.createdAt);
}),
);

it.effect("applies lifecycle commands idempotently and emits archive/removal shell deltas", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
Expand Down
2 changes: 1 addition & 1 deletion docs/internals/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ and trigger a check. A merge outside T3, such as an agent running `gh pr merge`,
notification, so the [PR sync reactor](../../apps/server/src/orchestration-v2/PullRequestSyncReactor.ts)
re-reads a thread's open links when a run that ran a merge or close command ends. The guarded
`thread.auto-settle` command rejects newer activity, explicit settlement overrides, and live or
blocked work. It records the activity timestamp for stable
blocked work. It records the activity timestamp, or creation time for a thread with no activity, for stable
sorting and detaches idle provider sessions. Clients render the persisted result; they do not
derive settlement from their own clocks or PR caches.

Expand Down
3 changes: 2 additions & 1 deletion docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ Press `Escape` while dragging to cancel.

By default, environments settle inactive threads after three days and settle
threads whose pull request merged. A closed pull request can also settle an idle
thread. Work in progress, pending questions or approvals, and live background work
thread. A thread with no messages or runs uses its creation time as the settlement timestamp.
Work in progress, pending questions or approvals, and live background work
prevent automatic settlement. An open pull request does not prevent inactivity
settlement, but an old closed or merged pull request does not settle work you
resumed after it closed. Only your own messages count as resuming. A turn that
Expand Down
Loading