Skip to content
Merged
31 changes: 30 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { createInboxReturnTracker } from "@t3tools/client-runtime/state/thread-inbox";
import { presentThreadShell } from "@t3tools/client-runtime/state/models";
import * as DateTime from "effect/DateTime";
import * as Schema from "effect/Schema";
Expand All @@ -24,7 +25,7 @@ import {
RunId,
ThreadId,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures";
Expand Down Expand Up @@ -1562,6 +1563,34 @@ describe("Working section beta", () => {
expect(off.workingCount).toBe(0);
});

it("keeps an inactive row in Working during the shared grace and returns it on expiry", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW));
const tracker = createInboxReturnTracker();
const working = makeThread({ id: ThreadId.make("grace"), title: "grace", runtime: running });
const inactive = { ...working, runtime: null };
const render = () =>
build({
threads: [inactive],
workingShelfExpanded: true,
isWorking: tracker.isWorking,
inboxReturnAt: tracker.returnedAt,
});
try {
tracker.observe([working]);
tracker.observe([inactive]);
expect(render().workingCount).toBe(1);
expect(render().items[0]?.thread.runtime).toBeNull();
vi.advanceTimersByTime(2_000);
expect(render().workingCount).toBe(0);
expect(ids(render())).toEqual(["grace"]);
expect(tracker.returnedAt(inactive)).toBe(Date.parse(NOW) + 2_000);
} finally {
tracker.observe(null);
vi.useRealTimers();
}
});

it("orders the inbox by the latest return this device observed", () => {
const layout = build({
inboxReturnAt: (thread) =>
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ export function buildThreadListV2Items(input: {
readonly now: string;
readonly workingShelfEnabled?: boolean;
readonly workingShelfExpanded?: boolean;
readonly isWorking?: (thread: EnvironmentThreadShell) => boolean;
readonly inboxReturnAt?: (thread: EnvironmentThreadShell) => number | undefined;
readonly snoozedShelfExpanded?: boolean;
readonly settledShelfExpanded?: boolean;
Expand Down Expand Up @@ -659,7 +660,7 @@ export function buildThreadListV2Items(input: {
settled.push(thread);
} else if (thread.pinnedAt != null) {
pinned.push(thread);
} else if (workingShelfEnabled && isThreadWorking(thread)) {
} else if (workingShelfEnabled && (input.isWorking ?? isThreadWorking)(thread)) {
working.push(thread);
} else {
active.push(thread);
Expand Down
16 changes: 11 additions & 5 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { useMemo } from "react";
import { useMemo, useSyncExternalStore } from "react";

import { scopedProjectKey } from "../../lib/scopedEntities";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
Expand Down Expand Up @@ -54,10 +54,15 @@ export function useThreadListV2(input: {
readonly selectedThreadKey: string | null;
}) {
const { threads, workingShelfEnabled } = input;
// Observed once per change to the threads: that is when a thread can stop working.
const inboxReturnAt = useMemo(
// A thread can stop working when the threads change, or when its exit grace expires.
const inboxVersion = useSyncExternalStore(
threadListInboxReturns.subscribe,
threadListInboxReturns.getSnapshot,
);
const inbox = useMemo(
() => threadListInboxReturns.observe(workingShelfEnabled ? threads : null),
[threads, workingShelfEnabled],
// oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace
[threads, workingShelfEnabled, inboxVersion],
);
// Up/down menu availability for every card, computed once per section per
// rebuild (see computeThreadMoveAvailability): per-thread planner calls made
Expand Down Expand Up @@ -97,7 +102,8 @@ export function useThreadListV2(input: {
now: input.now,
workingShelfEnabled,
workingShelfExpanded: input.workingShelfExpanded,
inboxReturnAt,
inboxReturnAt: inbox.returnedAt,
isWorking: inbox.isWorking,
snoozedShelfExpanded: input.snoozedShelfExpanded,
settledShelfExpanded: input.settledShelfExpanded,
selectedThreadKey: input.selectedThreadKey,
Expand Down
90 changes: 89 additions & 1 deletion apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,7 +1340,7 @@ describe("PullRequestSyncReactor", () => {
"gh pr merge 7 --squash 2>&1 | tail -3",
"cafe pr merge 7 --squash",
"az repos pr update --id 7 --status completed",
])("reads open links fresh only when a run that ran `%s` ends", (mergeCommand) =>
])("reads open links fresh only for a thread that ran `%s`", (mergeCommand) =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
Expand Down Expand Up @@ -1392,4 +1392,92 @@ describe("PullRequestSyncReactor", () => {
}),
),
);

it.effect("reads open links again when the run ends, after `merge --auto` left them open", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const state = yield* Ref.make<PullRequestSummary["state"]>("open");
const fixture = yield* makeHarness({
snapshot: makeSnapshot([
makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }),
]),
summary: (input) =>
Ref.get(state).pipe(
Effect.map((current) =>
makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}),
),
),
});

yield* Effect.gen(function* () {
const reactor = yield* startAndSweep(fixture);
yield* Queue.offer(
fixture.domainEvents,
commandRan(ThreadId.make("agent"), "gh pr merge 7 --auto"),
);
yield* Queue.take(fixture.snapshotReads);
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
yield* Ref.set(state, "merged");
yield* Ref.set(fixture.syncCommands, []);

yield* Queue.offer(fixture.domainEvents, runUpdated(ThreadId.make("agent"), "completed"));
yield* Queue.take(fixture.snapshotReads);
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;

assert.deepStrictEqual(
(yield* Ref.get(fixture.syncCommands)).map((command) => [
command.number,
command.snapshot.state,
]),
[[7, "merged"]],
);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("reads open links fresh as soon as a merge command ends, before its run ends", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const state = yield* Ref.make<PullRequestSummary["state"]>("open");
const fixture = yield* makeHarness({
snapshot: makeSnapshot([
makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }),
]),
summary: (input) =>
Ref.get(state).pipe(
Effect.map((current) =>
makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}),
),
),
});

yield* Effect.gen(function* () {
const reactor = yield* startAndSweep(fixture);
yield* Ref.set(state, "merged");
yield* Ref.set(fixture.summaryCalls, []);

yield* Queue.offer(
fixture.domainEvents,
commandRan(ThreadId.make("agent"), "gh pr merge 7"),
);
yield* Queue.take(fixture.snapshotReads);
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;

assert.deepStrictEqual(
(yield* Ref.get(fixture.syncCommands)).map((command) => [
command.number,
command.snapshot.state,
]),
[[7, "merged"]],
);
}).pipe(Effect.provide(fixture.layer));
}),
),
);
});
30 changes: 17 additions & 13 deletions apps/server/src/orchestration-v2/PullRequestSyncReactor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CommandId,
isOrchestrationV2WorkActive,
type PullRequestSummary,
type ThreadId,
type ThreadPullRequestKey,
Expand Down Expand Up @@ -481,7 +482,8 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))),
);

const closeCommandThreads = new Set<ThreadId>();
// Threads that ran a close command this run, with the commands already read for.
const closeCommands = new Map<ThreadId, Set<string>>();
const refreshOpenLinks = (threadId: ThreadId) =>
projections.getThreadsWithPullRequests(threadId).pipe(
Effect.flatMap((threads) =>
Expand Down Expand Up @@ -525,20 +527,22 @@ export const make = Effect.gen(function* () {
{ discard: true },
);
// An agent can merge or close its pull request from a shell with its host's CLI, which
// sends no merge notification. When a run that ran such a command ends, read the
// thread's open links fresh, so settlement does not wait for the next sweep and the
// cached summary. Other runs add no host reads.
case "turn-item.updated":
if (
event.payload.type === "command_execution" &&
isChangeRequestCloseCommand(event.payload.input)
) {
closeCommandThreads.add(event.threadId);
// sends no merge notification. Read the thread's open links fresh once that command
// ends, while the agent usually still replies, so settlement can land before the run
// ends. Read again when the run ends, since `merge --auto` returns before it merges.
case "turn-item.updated": {
const item = event.payload;
if (item.type !== "command_execution" || !isChangeRequestCloseCommand(item.input)) {
return Effect.void;
}
return Effect.void;
const ended = closeCommands.get(event.threadId) ?? new Set<string>();
closeCommands.set(event.threadId, ended);
if (isOrchestrationV2WorkActive(item.status) || ended.has(item.id)) return Effect.void;
ended.add(item.id);
return refreshOpenLinks(event.threadId);
}
case "run.updated":
return isTerminalRunStatus(event.payload.status) &&
closeCommandThreads.delete(event.threadId)
return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId)
? refreshOpenLinks(event.threadId)
: Effect.void;
default:
Expand Down
Loading
Loading