Skip to content
Closed
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
11 changes: 11 additions & 0 deletions packages/host/src/compose-coding-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ export interface CodingAgentsDependencies {
/** The account signed in now, by its id, which every live read names; none where nobody is. */
accountId: () => string | undefined;
openLive?: (url: string) => LiveClient | PromiseLike<LiveClient>;
/**
* Waits for a write's transaction to arrive on the task list followed
* live, which holds every agent's row, so a Start, a rename, an archive,
* or a pin is confirmed once the list carries it; none waits for nothing.
*/
listed?: (txid: string | undefined) => Effect.Effect<void>;
}

export interface CodingAgentsComposer extends Composer {
Expand Down Expand Up @@ -225,6 +231,7 @@ function composed(
const { kernel, account, client } = dependencies;
const gate = () => kernel.runMode.sendsNetwork && account.capabilitiesActive();
const asked = askedOf(dependencies, http);
const listed = dependencies.listed ?? (() => Effect.void);

/** The params read against the method's own schema, or the invalid-params refusal worded. */
const read = <Params>(
Expand Down Expand Up @@ -375,6 +382,7 @@ function composed(
"starting an agent names a task and the press's own key",
);
const answer = yield* asked(client.start(taskId, request));
if ("txid" in answer) yield* listed(answer.txid);
return carried<CodingAgentAgentAnswer>(answer);
}),
[GATEWAY_METHOD.CODING_AGENTS_MESSAGES]: (params) =>
Expand Down Expand Up @@ -419,6 +427,7 @@ function composed(
"renaming an agent names the agent and its title",
);
const answer = yield* asked(client.rename(agentId, request));
if ("txid" in answer) yield* listed(answer.txid);
return carried<CodingAgentAgentAnswer>(answer);
}),
[GATEWAY_METHOD.CODING_AGENTS_SERVERS]: (params) =>
Expand All @@ -439,6 +448,7 @@ function composed(
"archiving an agent names the agent and whether it is archived",
);
const answer = yield* asked(client.archive(agentId, request));
if ("txid" in answer) yield* listed(answer.txid);
return carried<CodingAgentAgentAnswer>(answer);
}),
[GATEWAY_METHOD.CODING_AGENTS_PIN]: (params) =>
Expand All @@ -449,6 +459,7 @@ function composed(
"pinning an agent names the agent and whether it is pinned",
);
const answer = yield* asked(client.pin(agentId, request));
if ("txid" in answer) yield* listed(answer.txid);
return carried<CodingAgentAgentAnswer>(answer);
}),
[GATEWAY_METHOD.CODING_AGENTS_STOP]: (params) =>
Expand Down
1 change: 1 addition & 0 deletions packages/host/src/compose-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export function hostAssemblyLayer<const Chosen extends HostPlatformConcern>(
accountId,
client: codingAgentClient,
openLive,
listed: tasks.listed,
});
const automations = yield* composeAutomations({
kernel,
Expand Down
58 changes: 55 additions & 3 deletions packages/host/src/compose-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ interface FakeService extends TasksClient {
tasklessAgents: TasklessAgent[];
/** What the service answers when the list is asked for sealed, to follow over Neon Realtime; sealed by default. */
liveList: TaskCallResult<TaskListLiveAnswer, typeof TASK_CALL_FAILURE.UNANSWERED>;
/** The transaction a rename commits in, where the test says it changed a row. */
renameTxid: string | undefined;
/** Whether a delete is refused, as a service that did not answer refuses it. */
deleteFails: boolean;
createAnswer: TaskCallResult<TaskStarted, RepositoryCallFailure>;
Expand Down Expand Up @@ -333,6 +335,7 @@ function fakeService(tasks: Task[]): FakeService {
agents: {},
tasklessAgents: [],
liveList: { ok: true, answer: SEALED_LIST },
renameTxid: undefined,
deleteFails: false,
createAnswer: { ok: false, failure: TASK_CALL_FAILURE.UNANSWERED },
repositoriesAnswer: { ok: false, failure: TASK_CALL_FAILURE.UNANSWERED },
Expand Down Expand Up @@ -824,7 +827,9 @@ function fakeService(tasks: Task[]): FakeService {
service.tasks = service.tasks.map((candidate) =>
candidate.id === taskId ? renamed : candidate,
);
return { task: renamed };
return service.renameTxid === undefined
? { task: renamed }
: { task: renamed, txid: service.renameTxid };
}),
// The service sets the status and the priority named, and leaves the other standing.
setStanding: (taskId, standing) =>
Expand Down Expand Up @@ -3419,18 +3424,29 @@ function fakeRealtime() {
string,
(snapshot: { readonly status: string; readonly data: readonly unknown[] | undefined }) => void
>();
// The transactions the list has seen, and the waits for one it has not seen yet.
const seen = new Set<string>();
const waiting: { txid: string; settle: () => void }[] = [];
const open = (): LiveClient => ({
subscribe: (query) => ({
onChange: (listener) => {
listeners.set(query.capability, listener);
return () => listeners.delete(query.capability);
},
awaitTxId: () => Promise.resolve(),
awaitTxId: (txid) =>
seen.has(txid)
? Promise.resolve()
: new Promise<void>((resolve) => waiting.push({ txid, settle: resolve })),
renew: () => Promise.resolve(),
unsubscribe: () => void listeners.delete(query.capability),
}),
close: () => undefined,
});
/** A transaction arriving on the list, settling every wait for it. */
const arrive = (txid: string) => {
seen.add(txid);
for (const wait of waiting.filter((each) => each.txid === txid)) wait.settle();
};
/** Waits for both parts to be subscribed, as the follow forked from a refresh subscribes them. */
const subscribed = Effect.gen(function* () {
for (let turn = 0; turn < 100 && listeners.size < 2; turn += 1) yield* Effect.yieldNow;
Expand All @@ -3446,7 +3462,7 @@ function fakeRealtime() {
const settled = Effect.gen(function* () {
for (let turn = 0; turn < 20; turn += 1) yield* Effect.yieldNow;
});
return { open, subscribed, deliver, fail, settled };
return { open, subscribed, deliver, fail, settled, arrive };
}

/** One plan as the plans statement delivers its row. */
Expand Down Expand Up @@ -3627,3 +3643,39 @@ it.effect(
assert.equal(last()?.tasks[0]?.name, "Teammate invitations, again");
}),
);

it.effect(
"a rename that changed a row is answered once its transaction arrives on the list followed live, and one that changed nothing at once",
() =>
Effect.gen(function* () {
const service = fakeService([task(INVITES, "Teammate invitations", "# Invites", 10)]);
const realtime = fakeRealtime();
const { call } = yield* subject(service, { openLive: realtime.open });
yield* call(GATEWAY_METHOD.TASKS_REFRESH);
yield* realtime.subscribed;
realtime.deliver("plans", [taskRow(INVITES, "Teammate invitations")]);
realtime.deliver("agents", []);
yield* realtime.settled;
service.renameTxid = "4242";
let answered = false;

yield* Effect.forkChild(
Effect.tap(call(GATEWAY_METHOD.TASKS_RENAME, { taskId: INVITES, name: "Invites" }), () =>
Effect.sync(() => {
answered = true;
}),
),
);
yield* realtime.settled;
assert.equal(answered, false, "the rename waits for its transaction");
realtime.arrive("4242");
yield* realtime.settled;
assert.equal(answered, true);

service.renameTxid = undefined;
assert.deepEqual(
yield* call(GATEWAY_METHOD.TASKS_RENAME, { taskId: INVITES, name: "Invitations" }),
{ renamed: true },
);
}),
);
40 changes: 36 additions & 4 deletions packages/host/src/compose-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ import { boardMoves } from "./board-follow.js";
import type { AccountComposer } from "./compose-account.js";
import type { Composer } from "./composer.js";
import type { HostKernel } from "./host-kernel.js";
import { type LiveClient, openRealtimeClient } from "./live-follow.js";
import { type LiveClient, openRealtimeClient, type TxWait } from "./live-follow.js";
import type { RunMode } from "./run-mode.js";
import { codeExcerpt, highlightCode, highlightFile } from "./task-code.js";
import { followTaskList } from "./task-list-follow.js";
Expand Down Expand Up @@ -327,6 +327,9 @@ type TaskMarkStamps = Pick<Task, "pinnedAt" | "pinPosition" | "archivedAt">;
*/
const WORKSPACES_READ_EVERY = Duration.minutes(1);

/** How long a write waits for its transaction to arrive on the list followed live before it is answered all the same. */
const WRITE_CONFIRMED_WITHIN = Duration.seconds(10);

/** A GitHub page asked behind a closed gate, or of a service that did not answer, which opens nothing. */
const GITHUB_PAGE_UNANSWERED = { failure: TASK_CALL_FAILURE.UNANSWERED } as const;

Expand Down Expand Up @@ -512,6 +515,8 @@ export interface TasksComposer extends Composer {
* call's end keeps it.
*/
showWork: (taskId: string, turn: TaskWorkTurn) => void;
/** Waits for a write's transaction to arrive on the list followed live; none, or a list not subscribed, waits for nothing. */
listed: (txid: string | undefined) => Effect.Effect<void>;
/** The call about `taskId` ended: its words are read back from the record. */
callEnded: (taskId: string) => void;
}
Expand Down Expand Up @@ -713,6 +718,22 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
/** The one follow of the list, started on the first refresh and stopped by a sign-out. */
const listFollow = yield* FiberHandle.make<void>();

/** The list's wait for a write's transaction, while the list stands subscribed. */
let listWait: TxWait | undefined;

/**
* A write's transaction waited for on the list followed live, so the write
* is confirmed once the list carries it, as Neon's mutations guide says; a
* write that changed nothing, or a list not yet subscribed, waits for
* nothing, and one not confirmed in time is answered all the same.
*/
const listed = (txid: string | undefined): Effect.Effect<void> =>
Effect.suspend(() =>
txid === undefined || listWait === undefined
? Effect.void
: Effect.ignore(listWait(txid, WRITE_CONFIRMED_WITHIN)),
);

const followList = followTaskList({
// The account the follow starts for, which every seal names, so one under another's session is refused as signed out and ends.
live: (poll) =>
Expand All @@ -728,10 +749,13 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
}),
openLive: dependencies.openLive ?? openRealtimeClient,
now: kernel.now,
publish: (listed) => {
onTxWait: (wait) => {
listWait = wait;
},
publish: (answered) => {
write({
tasks: listed.plans,
tasklessAgents: listed.planlessAgents ?? [],
tasks: answered.plans,
tasklessAgents: answered.planlessAgents ?? [],
listStatus: TASKS_READ.READY,
});
// A name the service gave the open task reaches its document as the list carries it.
Expand Down Expand Up @@ -1368,6 +1392,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const marked = yield* served(client.mark(taskId, marks));
if (marked === undefined) return { changed: false };
yield* listed(marked.txid);
takeMarks(marked.task);
return { changed: true };
}),
Expand Down Expand Up @@ -1829,6 +1854,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const started = yield* served(client.create(request));
if (!started.ok) return carried<TasksStartAnswer>({ failure: started.failure });
yield* listed(started.answer.txid);
const { task, agent } = started.answer;
// A task just started has had no call. A call about another task
// is left to the start's own Talk press, which moves it here.
Expand Down Expand Up @@ -1861,6 +1887,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const deleted = yield* served(client.delete(taskId));
if (deleted === undefined) return { deleted: false };
yield* listed(deleted.txid);
const tasks = view.tasks.filter((task) => task.id !== taskId);
if (view.activeTaskId === taskId) {
yield* FiberHandle.clear(conversationRead);
Expand All @@ -1885,6 +1912,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const written = yield* served(client.rename(taskId, { name }));
if (written === undefined) return { renamed: false };
yield* listed(written.txid);
const renamed = written.task;
const tasks = view.tasks.map((task) =>
task.id === taskId ? { ...task, name: renamed.name } : task,
Expand Down Expand Up @@ -1919,6 +1947,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const changed = yield* served(client.setStanding(taskId, standing));
if (changed === undefined) return { changed: false };
yield* listed(changed.txid);
takeAnswered(changed.task);
return { changed: true };
}),
Expand Down Expand Up @@ -2018,6 +2047,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const changed = yield* served(client.setRepository(taskId, repository));
if (!changed.ok) return carried<TasksSetRepositoryAnswer>({ failure: changed.failure });
yield* listed(changed.answer.txid);
takeAnswered(changed.answer.task);
return carried<TasksSetRepositoryAnswer>({
repository: changed.answer.task.repository,
Expand All @@ -2041,6 +2071,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
Effect.gen(function* () {
const moved = yield* served(client.move(taskId, workspaceId));
if (!moved.ok) return carried<TasksMoveAnswer>({ failure: moved.failure });
yield* listed(moved.answer.txid);
takeAnswered(moved.answer.task);
return carried<TasksMoveAnswer>({ workspaceId: moved.answer.task.workspaceId });
}),
Expand Down Expand Up @@ -2423,6 +2454,7 @@ export const composeTasks = /* @__PURE__ */ Effect.fn("host/composeTasks")(funct
showCode,
showWork,
callEnded,
listed,
reset: Effect.gen(function* () {
yield* endTaskCall(undefined);
yield* Cache.invalidateAll(pullRequestReads);
Expand Down
5 changes: 4 additions & 1 deletion packages/host/src/task-list-follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "@luke/features/task-list-live";
import type { TaskListAnswer } from "@luke/features/task-wire";
import { Clock, Duration, Effect, Option, Schema, Stream } from "effect";
import { LiveBroken, type LiveClient, liveRows } from "./live-follow.js";
import { LiveBroken, type LiveClient, liveRows, type TxWait } from "./live-follow.js";

/**
* task-list-follow.ts -- the task list followed over Neon Realtime as Neon's guide says to, built again whenever its rows move and whenever the clock alone moves an agent's status.
Expand Down Expand Up @@ -41,6 +41,8 @@ export interface TaskListFollowDependencies {
readonly openLive: (url: string) => LiveClient | PromiseLike<LiveClient>;
/** Now, by the host's own clock, which renewals are timed by. */
readonly now: () => number;
/** Hears the list's wait for a write's transaction once it is subscribed, and none once it is let go. */
readonly onTxWait?: (wait: TxWait | undefined) => void;
/** The list, each time its rows or the clock move it. */
readonly publish: (list: TaskListAnswer) => void;
}
Expand Down Expand Up @@ -73,6 +75,7 @@ function liveLists(
reseal: () => dependencies.live(false),
poll: () => dependencies.live(true),
now: dependencies.now,
...(dependencies.onTxWait === undefined ? undefined : { onTxWait: dependencies.onTxWait }),
},
first,
).pipe(
Expand Down
Loading