pi-mono/apps/p2/plugins/chat/session-start.ts
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

114 lines
4.5 KiB
TypeScript

/**
* A new session's first messages, before the session exists: Send on the new-session screen shows them at once, as
* sending in a conversation does (outbox.ts), and the session is created whenever the page is connected. Once it
* exists, the messages move to its main conversation's outbox, which delivers them (uploads, template expansion,
* retries), and the page opens the session.
*
* One start per page, kept for the page's lifetime: messages sent while the session is being created join it. The page
* chooses the session's ID at the first Send (`Sessions.create` takes it), so a creation whose answer was lost with
* the connection is tried again with the same ID and answers the same session. A creation the server refuses (a
* directory that does not exist, an unknown model) puts the messages back into the composer; a failure of the
* connection is tried again, at most `GIVE_UP` times while connected.
*/
import type { ModelChoice } from "@p2/app/v1/web";
import { GIVE_UP, type Outgoing, outboxOf, retryDelay } from "./outbox.ts";
/**
* The session's ID, where and with which model it starts (as chosen at the first Send), and the messages it starts
* with.
*/
export type Start = {
readonly session: string;
readonly location: { readonly env: string | null; readonly cwd: string };
readonly choice: ModelChoice;
readonly messages: readonly Outgoing[];
};
/** How the connected new-session screen creates the session, and what it does with the outcome. */
export type Creator = {
/** Creates the session `start.session` (again, after a lost answer: the server answers the same row). */
create(start: Start): Promise<unknown>;
/** Whether `error` refuses the creation for good; anything else is retried. */
refused(error: unknown): boolean;
/** The session exists and its main conversation's outbox has the messages. */
created(session: string): void;
/** Puts the messages of a refused creation back into the composer, saying why. */
restore(messages: readonly Outgoing[], error: unknown): void;
};
export class SessionStart {
readonly #retryDelay: (attempt: number) => number;
#start: Start | undefined;
readonly #listeners = new Set<() => void>();
#creator: Creator | undefined;
#running = false;
#attempt = 0;
#retry: ReturnType<typeof setTimeout> | undefined;
/** Timing: the wait before retry `attempt` (from 0), as the outbox's by default. */
constructor(timing: { retryDelay?: (attempt: number) => number } = {}) {
this.#retryDelay = timing.retryDelay ?? retryDelay;
}
subscribe = (listener: () => void): (() => void) => {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
};
snapshot = (): Start | undefined => this.#start;
/** Adds `message`; the first one's `place` decides the session's ID, where and with which model it starts. */
add(message: Outgoing, place: Omit<Start, "messages">): void {
this.#set(
this.#start
? { ...this.#start, messages: [...this.#start.messages, message] }
: { ...place, messages: [message] },
);
void this.#run();
}
/** How to create the session now, or undefined while it cannot work (offline, the catalog not live). */
connect(creator: Creator | undefined): void {
this.#creator = creator;
if (creator) {
this.#attempt = 0;
void this.#run();
}
}
#set(start: Start | undefined): void {
this.#start = start;
for (const listener of [...this.#listeners]) listener();
}
/** Creates the session and hands the messages to its outbox; a failure that is not a refusal waits and tries again. */
async #run(): Promise<void> {
const creator = this.#creator;
const start = this.#start;
if (this.#running || !creator || !start) return;
this.#running = true;
clearTimeout(this.#retry);
try {
await creator.create(start);
const { session } = start;
this.#attempt = 0;
// Read after the creation: messages sent meanwhile go with the first.
const messages = this.#start?.messages ?? [];
this.#set(undefined);
const outbox = outboxOf(`${session}#1`);
for (const message of messages) outbox.add(message);
creator.created(session);
} catch (error) {
if (creator.refused(error) || this.#attempt + 1 >= GIVE_UP) {
this.#attempt = 0;
const messages = this.#start?.messages ?? [];
this.#set(undefined);
creator.restore(messages, error);
} else this.#retry = setTimeout(() => void this.#run(), this.#retryDelay(this.#attempt++));
} finally {
this.#running = false;
}
}
}
/** The page's one new-session start. */
export const sessionStart = new SessionStart();