Skip to content
Merged
72 changes: 61 additions & 11 deletions docs/machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,30 @@ handshake does not finish within twelve seconds can be retried from Machines.
Once the machine has said hello, loading its lists is waited for however long
it takes on a slow link; the connection is never dropped for it.

The client detects silent remote sockets (`packages/ui/src/lib/client.ts`).
On a remote host, 25 seconds without any
frame while no call waits for its answer sends a `hello`, which the core
The client detects silent remote sockets (`packages/ui/src/lib/ws-client.ts`).
On a remote host, 25 seconds without any frame sends a `hello`, which the core
answers on an open connection with its info and nothing else. That `hello`
carries no token and no device signature, since the core reads none of its
params once the socket is authenticated. If no frame of
any kind arrives within 15 seconds, the socket is replaced and the calls it
carried fail with "connection lost; check the conversation before resending".
While a call waits, silence may be its answer still downloading, one frame of
up to 16 MB behind which the probe's answer would queue, so only that call's
own 120 second timeout starts the check, on any host. Returning to the page, an `online` event or
params once the socket is authenticated. A call that hears no frame at all for
3 seconds sends the same `hello`, so a click on a conversation of a machine
whose link died shows that machine offline within 13 seconds. The call counts
frames rather than reading the clock, since a frame and a call can share a
millisecond. If no frame of any kind arrives within 10 seconds of a `hello`,
the socket is replaced and the calls it carried fail as dropped. A remote
client asks the core to slice every answer (`progress`,
`features.chunkedAnswers`): each 64 KiB slice is a frame, so a long answer on a
slow link keeps the socket alive while a dead link stays silent. Requests are
not sliced, and the `hello` queues behind what the client still uploads: past
the first 64 KiB sent since the last frame arrived, the deadline grows by the
time those bytes take at 32 KiB/s, so a prompt with 2 MiB of pictures gets 62
seconds more. A core without sliced answers may still be sending one frame of up to
16 MB: there, while a call waits, only that call's own 120 second timeout
starts the check. Until 2026-10-22 that rule held on every core, and a VPN
that dropped under an open conversation left the machine reading as connected
for over two minutes while every call made there waited. A failure that only
says the machine went away is not shown in red: the machine's status, its
greyed rows and the composer already say it, and a conversation opened as the
link went shows offline until the reconnect opens it again. Returning to the page, an `online` event or
a page restored from the back/forward cache also send the `hello` first, and
replace the socket only if it stays silent for 4 seconds, so a tab switch no
longer drops a healthy connection or reloads the lists. An `offline` event asks
Expand Down Expand Up @@ -314,10 +327,47 @@ its reason and holds the entries written after it; Send again retries it under
the same id, and the cross removes it. A tap takes it back into the box, and
sending it from there draws a new id. A thread that moved to another model
meanwhile is put back on the written one first; a choice for another agent or
account is ignored. A new thread needs the core, so a
draft keeps its text until the machine returns. A machine offline since the app
account is ignored. A machine offline since the app
started has no cards to open: the client keeps no copy of the thread list.

A new conversation waits the same way. Send on a draft of a machine that is
offline puts the prompt in the draft's own queue with a request id and the
chips' choice, and the box says the conversation starts when the machine
reconnects. The draft's row in the sidebar shows a clock and the prompt, and
its tooltip names the machine it waits for. Its menu has **Cancel the start**,
which puts the words back in the box, where they wait as an ordinary draft;
the cross on the queued prompt removes it. The queue is kept on the device
like an outbox, so it survives closing the app. When the machine is back, the
client starts each queued draft in the order it was sent: `threads.create`
under the prompt's request id (`clientRequestId`), then the draft's queue
becomes the new conversation's outbox, which sends it under the same ids. The
core answers a creation it already made under that id with the conversation
it made (`packages/core/src/threads/creation-requests.ts`, kept for an hour in
memory), so a creation whose answer the link lost is not made twice. The draft
goes unless its box holds new text, and a draft on screen gives way to its
conversation. A refusal stays on the queued prompt with its reason and Send
again. A start on its way can be neither deleted nor taken back, since the
core may already hold the conversation. The thread's outbox is written to the
device before the draft leaves it, so a crash between the two leaves the prompt
in both, which the core takes once under its ids, never in neither. The first
start writes down the project and the worktree and incognito switches it asked
for, and a retry asks for exactly that, since the core answers a request id
that may already hold its conversation only when the parameters match. Once
asked, a start the core did not refuse is retried until it lands and is no
longer taken back or removed from the queue, only deleted with its draft: the
same words sent again under a new id could make a second conversation. A
refusal made nothing, so a refused start can be taken back, edited or sent
again, and its next ask reads the draft afresh. The exception is a refusal
about the request id itself (`field: 'clientRequestId'`): the core holds a
conversation under that id, so the start keeps what it asked and stays locked.
When that conversation was deleted since (`reason: 'deleted'`), nothing holds
the id any more: the start unlocks, and Send again goes under a fresh id. A start
that gets no answer on a live link, or finds no drafts project, is tried again
15 seconds later rather than at once. A start taken back before it was ever
asked, then sent again, goes at once under its new id. An incognito draft is never kept on the
device, so it still waits for its machine before it can be sent
(`packages/ui/src/lib/store/draft-launch.svelte.ts`).

Archiving or restoring a project of a machine that is offline still works: the
project moves at once and the choice is kept on this device, per core, in
localStorage (`boite.pendingArchive.v1`). The machine's next project list is
Expand Down
3 changes: 2 additions & 1 deletion docs/phone.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,8 @@ devices you intend to give that control.
journal is on the core. Prompts
written in a thread that was open before the connection went wait in the
device's outbox (`docs/machines.md`), and go out once it is back, even after
the PWA was closed in between; a new thread still needs the core.
the PWA was closed in between. A new conversation sent from a draft waits
there too, and starts once the core is back.
- A phone is added with a QR code or a link somebody shows it, within ten
minutes. There is no discovery on the network. Adding it
once is enough for every machine of its [group](groups.md).
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export interface MessagesRpcMethods {
'push.test': { params: Record<string, never>; result: { ok: true } };
/** Reserve the final workspace and initialize a native session without publishing a conversation or sending text. */
'drafts.prepare': {
params: Omit<RpcMethods['threads.create']['params'], 'title' | 'preparationId' | 'subscribe'> & { preparationId: string };
params: Omit<RpcMethods['threads.create']['params'], 'title' | 'preparationId' | 'subscribe' | 'clientRequestId'> & { preparationId: string };
result: { preparationId: string; threadId: ThreadId };
};
/** Release only the named reservation owned by this connection, including preparation still in flight. */
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/src/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,17 @@ export interface ThreadsRpcMethods {
preparationId?: string;
/** Subscribe this connection before returning the newly created conversation. */
subscribe?: boolean;
/**
* The client's id for this creation, 1 to 128 characters. Asked again
* under the same id within an hour, with the same parameters (project,
* provider, account, title, folder, model, effort, speed, mode,
* worktree, incognito, context), the core answers with the conversation
* the first call made instead of making another; with other ones it
* refuses. A
* conversation queued while its machine was away is started this way.
* Kept in memory: a core restart forgets it. An older core ignores it.
*/
clientRequestId?: string;
};
result: ThreadSummary;
};
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/threads/creation-requests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { RpcParams, ThreadId, ThreadSummary } from '@boite/contracts';
import { invalidParams, refused } from '../errors.ts';

/** How long a creation's request id answers with the thread it made: well past a reconnect. */
const RECEIPT_MS = 60 * 60_000;
/** Receipts kept at most; the oldest leaves first. */
const RECEIPT_LIMIT = 512;
const REQUEST_ID_MAX = 128;

type Receipt = { expiresAt: number; key: string; created: Promise<ThreadId> };

/**
* `threads.create` under a `clientRequestId`. A client that lost the answer
* with its connection asks again under the same id and gets the conversation
* the first call made, not a second one: a conversation queued on a device
* while its machine was away starts once however often the link drops on the
* way. Kept in memory, so a core restart forgets them.
*/
export class CreationRequests {
private readonly receipts = new Map<string, Receipt>();

async once(params: RpcParams<'threads.create'>, create: () => Promise<ThreadSummary>, lookup: (threadId: ThreadId) => ThreadSummary | null): Promise<ThreadSummary> {
const id = params.clientRequestId;
if (id === undefined) return create();
if (typeof id !== 'string' || id.length === 0 || id.length > REQUEST_ID_MAX) {
throw invalidParams(`clientRequestId must be a string of 1 to ${REQUEST_ID_MAX} characters`, { field: 'clientRequestId', expected: `1 to ${REQUEST_ID_MAX} characters` });
}
// Everything that shapes the conversation: a repeat that differs is another request, refused.
const key = JSON.stringify([params.projectId, params.providerId, params.accountId, params.title ?? null, params.cwd ?? null,
params.model ?? null, params.effort ?? null, params.speed ?? null, params.permissionMode ?? 'default', params.worktree ?? null,
params.incognito === true, params.context ?? null]);
Comment thread
klNuno marked this conversation as resolved.
const now = Date.now();
for (const [stale, receipt] of this.receipts) if (receipt.expiresAt <= now) this.receipts.delete(stale);
const previous = this.receipts.get(id);
if (previous) {
if (previous.key !== key) throw refused('clientRequestId was already used for another conversation', { field: 'clientRequestId', expected: 'a new id for a different conversation' });
const threadId = await previous.created;
const thread = lookup(threadId);
if (thread === null) throw refused('the conversation this request started was deleted since', { field: 'clientRequestId', threadId, reason: 'deleted' });
return thread;
}
const made = create();
const receipt: Receipt = { expiresAt: now + RECEIPT_MS, key, created: made.then(thread => thread.id) };
this.receipts.set(id, receipt);
while (this.receipts.size > RECEIPT_LIMIT) this.receipts.delete(this.receipts.keys().next().value!);
// A creation that failed made nothing: the same id may try again.
receipt.created.catch(() => { if (this.receipts.get(id) === receipt) this.receipts.delete(id); });
return made;
}
}
18 changes: 11 additions & 7 deletions packages/core/src/threads/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { agentShape } from './agent-shape.ts';
import { steerUser } from './user-steering.ts';
import { readToolOutput, readToolPart } from './records.ts';
import { readMessageAttachment } from './attachment-read.ts';
import { CreationRequests } from './creation-requests.ts';

/**
* A turn in a folder that is gone is refused by that folder. An archived
Expand Down Expand Up @@ -64,6 +65,7 @@ async function watchTarget(linked: LinkedPullRequests, threadId: ThreadId, input
}

export function registerThreadMethods(core: Core, probe: ProviderProbe): void {
const creations = new CreationRequests();
async function discover(providerId: ProviderId, accountId: AccountId, model: string | null, effort: string | null, speed: string | null): Promise<void> {
const provider = core.providers.require(providerId);
const account = core.accounts.require(accountId);
Expand Down Expand Up @@ -130,13 +132,15 @@ export function registerThreadMethods(core: Core, probe: ProviderProbe): void {
return { ok: true };
});
core.router.register('threads.create', async (params, ctx) => {
// A thread made in a folder that is gone could never run a turn: refused here, by the folder.
const project = core.projects.require(params.projectId);
await core.projects.requireFolder(project.id);
const provider = core.providers.require(params.providerId);
await discover(provider.id, params.accountId, params.model ?? defaultModel(provider), params.effort ?? null, params.speed ?? null);
const adopted = await core.drafts.claim(ctx.connection.id, params);
const thread = adopted ?? (params.worktree === undefined ? core.threads.create(params) : await core.threads.createInWorktree(params));
const thread = await creations.once(params, async () => {
// A thread made in a folder that is gone could never run a turn: refused here, by the folder.
const project = core.projects.require(params.projectId);
await core.projects.requireFolder(project.id);
const provider = core.providers.require(params.providerId);
await discover(provider.id, params.accountId, params.model ?? defaultModel(provider), params.effort ?? null, params.speed ?? null);
const adopted = await core.drafts.claim(ctx.connection.id, params);
return adopted ?? (params.worktree === undefined ? core.threads.create(params) : await core.threads.createInWorktree(params));
}, threadId => core.journal.getThread(threadId));
if (params.subscribe === true) ctx.connection.subscriptions.add(thread.id);
return thread;
});
Expand Down
69 changes: 69 additions & 0 deletions packages/core/test/creation-requests.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { RpcErrorCode, type RpcParams } from '@boite/contracts';
import type { CoreClient } from '../src/client.ts';
import { startTestCore, type TestCore } from './harness.ts';
import { rejection } from './settle.ts';

let h: TestCore;
let choice: RpcParams<'threads.create'>;
beforeEach(async () => {
h = await startTestCore({ settings: { warmProcessMinutes: 0 } });
const client = await h.connect();
const project = await client.call('projects.drafts', {});
const account = (await client.call('accounts.list', {})).find(account => account.providerId === 'echo')!;
choice = { projectId: project.id, providerId: 'echo', accountId: account.id, title: 'Queued while away' };
client.close();
});
afterEach(async () => { await h.stop(); });

const titled = async (client: CoreClient, title: string) => (await client.call('threads.list', {})).filter(thread => thread.title === title);

test('a creation asked again under its request id, on a new connection, answers with the conversation it made', async () => {
const first = await h.connect();
const made = await first.call('threads.create', { ...choice, clientRequestId: 'req_queued_1' });
// The answer was lost with the link: the device reconnects and asks again.
first.close();
const second = await h.connect();
const again = await second.call('threads.create', { ...choice, clientRequestId: 'req_queued_1' });
expect(again.id).toBe(made.id);
expect(await titled(second, 'Queued while away')).toHaveLength(1);
// No mode is the default mode, as the core stores it.
const explicit = await second.call('threads.create', { ...choice, permissionMode: 'default', clientRequestId: 'req_queued_1' });
expect(explicit.id).toBe(made.id);

// Asked twice at once, it is still one conversation.
const [a, b] = await Promise.all([
second.call('threads.create', { ...choice, title: 'Twice at once', clientRequestId: 'req_queued_2' }),
second.call('threads.create', { ...choice, title: 'Twice at once', clientRequestId: 'req_queued_2' })
]);
expect(a.id).toBe(b.id);
expect(await titled(second, 'Twice at once')).toHaveLength(1);

// Without an id, or under a new one, every call makes its own.
await second.call('threads.create', { ...choice, title: 'Plain' });
await second.call('threads.create', { ...choice, title: 'Plain' });
expect(await titled(second, 'Plain')).toHaveLength(2);
second.close();
});

test('a request id names one conversation: reused for another it is refused, and a refused creation may try again', async () => {
const client = await h.connect();
await client.call('threads.create', { ...choice, clientRequestId: 'req_once' });
const reused = await rejection(client.call('threads.create', { ...choice, title: 'Something else', clientRequestId: 'req_once' }));
expect((reused.reason as { rpc: unknown }).rpc).toMatchObject({ code: RpcErrorCode.Refused, data: { field: 'clientRequestId' } });
// Every parameter names the conversation, the mode and the effort included.
const otherMode = await rejection(client.call('threads.create', { ...choice, permissionMode: 'bypassPermissions', clientRequestId: 'req_once' }));
expect((otherMode.reason as { rpc: unknown }).rpc).toMatchObject({ code: RpcErrorCode.Refused, data: { field: 'clientRequestId' } });
const otherEffort = await rejection(client.call('threads.create', { ...choice, effort: 'low', clientRequestId: 'req_once' }));
expect((otherEffort.reason as { rpc: unknown }).rpc).toMatchObject({ code: RpcErrorCode.Refused, data: { field: 'clientRequestId' } });

const missing = await rejection(client.call('threads.create', { ...choice, accountId: 'acc_missing', clientRequestId: 'req_retry' }));
expect(missing.reason).toBeDefined();
// Nothing was made under that id: the corrected call takes it.
const retried = await client.call('threads.create', { ...choice, title: 'After a refusal', clientRequestId: 'req_retry' });
expect(retried.title).toBe('After a refusal');

const bad = await rejection(client.call('threads.create', { ...choice, clientRequestId: 'x'.repeat(129) }));
expect((bad.reason as { rpc: unknown }).rpc).toMatchObject({ code: RpcErrorCode.InvalidParams, data: { field: 'clientRequestId' } });
client.close();
});
Loading
Loading