p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test); sync-versions also updates apps' workspace pins.
8.6 KiB
Questions: input into running work
Status: design (target). A p2 service, not a Chord or Durable feature. Referenced from target-design.md §17a.
Problem
Running work sometimes needs an answer from a person:
- login: pi-ai's OAuth and API-key flows ask for a pasted code, a secret or an account selection (today a promise map
inside
plugins/login/logins.ts,prompt/answer); - tool approval: before the agent runs a dangerous tool, the owner allows or denies it (p2 has none today);
- a question tool: the model asks everyone present and needs all their answers.
All three are the same shape: work waits for an answer from one or more people, who may join or leave meanwhile.
Design
A shared store (a facet resource on the host where the asking work runs) plus per-principal views (Chord objects are per type, input and principal, so asker and viewer get different objects; both delegate to the one store).
export type Question =
| { kind: "text"; message: string; secret?: boolean; help?: string }
| { kind: "select"; message: string; options: readonly { id: string; label: string }[] }
| { kind: "confirm"; message: string; allow: string; deny: string };
type AnswerOf<Q> =
Q extends { kind: "confirm" } ? boolean :
Q extends { kind: "select"; options: readonly { id: infer I }[] } ? I :
string;
type Who = { principal: P2Principal; name: string };
type Policy = { mode: "first" } | { mode: "quorum"; count: number } | { mode: "all"; join?: "live" | "snapshot" };
type ResultOf<Q, P extends Policy> =
P extends { mode: "first" } ? { by: Who; answer: AnswerOf<Q> } :
{ answers: { by: Who; answer: AnswerOf<Q> }[]; missing: Who[] };
type Scope = { session: string } | { owner: P2Principal }; // typed, not a string
ask<const Q extends Question, const P extends Policy>(q: Q, opts: {
audience: Readable<readonly P2Principal[]> | readonly P2Principal[];
policy: P;
key?: string; // stable per asking flow (tool task id, login attempt): a re-ask reattaches
deadline?: number; // then resolve with what is there (`missing` names who did not answer)
}, ctx: Ctx): Promise<ResultOf<Q, P>>;
export default defineFacet((host, self) => {
const pending = self.resource("questions", {
version: 1,
open: () => new PendingQuestions(), // entries + resolvers + change events; data only
replace: (old) => old, // hand over on a container change (target-design §12)
close: (p) => p.rejectAll("store closed"),
});
host.serve(Questions, ({ scope }, principal, self) => {
if (isHost(principal)) {
return { variant: "asker", ask: (q, opts, ctx) => askQuestion(pending.value, scope, principal, q, opts, ctx) };
}
if (!mayView(principal, scope)) throw forbidden();
return {
variant: "viewer",
open: self.derive([pending.value.readable], (all) => openFor(all, scope, principal)),
answer: ({ id, value }) => answerQuestion(pending.value, id, principal, value),
};
});
});
Rules
- The store is the truth. Pending entries and resolvers live in the resource;
openis a readable over it, filtered per viewer; nothing is a document, so a reload or migration cannot leave a ghost question. The logic (policies, validation) is plain code around the container, so logic changes are code reloads. - One settlement point: register the waiter and the abort listener before publishing the question (and handle an already aborted signal); an answer, an abort or the deadline atomically takes the entry out before resolving. A late or second answer gets "no longer active". Answers are validated against the stored question (a select id from the options). Never await an answer inside a transaction.
- Policies over a live audience:
firstresolves with the first answer.quorum(n)resolves afternanswers.allresolves when every member of the audience has answered; withjoin: "live"(default) someone who joins becomes required and someone who leaves drops out (their answer, if given, stays); with"snapshot"the audience is fixed when asking. If the audience becomes empty before anyone answered,allwaits for the deadline. - Authorization: the asker is stamped by the host (a worker may ask only in its own session's scope; secret
questions only from server askers). Each question stores who may answer it (the audience); a guest's send access does
not imply approval rights. Viewing and answering are checked separately. The answering principal is the one Chord
stamped on the connection. Secret answers are never put into
open, documents or logs; redaction happens inanswer. - Link drops and crashes: a rejected
ask(link blip, store replaced, server restart) is not a denial. Askers retry with the samekeyuntil their own signal aborts; a re-ask with a known key reattaches to the pending entry. A pendingaskcounts as an active call (keep-alive, relocation fence); a deadline lets idle work end. - Where the store lives: on the host where the asking work runs, so asking crosses no link: session questions (tool
approval, the question tool) in the session's worker (placement
sessionHost), login questions on the server. UIs reach both through the usual relay.
Scenarios
Tool approval (first answer wins). A Durable hook gets Durable's Context, not a Chord ctx; it opens a Chord call
scope with host.root. The decision is stored in a Durable task memo, so a hook rerun before the consuming commit does
not ask again.
hook(ToolTask, {
beforeTool: async (call, api, context) => {
if (!needsApproval(call)) return undefined;
const decided = await api.memo<boolean>("approval", context);
if (decided !== undefined) return decided ? undefined : { block: "Denied" };
const allowed = await host.root(async (ctx) => {
const q = await ctx.get(Questions, { scope: { session } });
for (;;) {
try {
const r = await q.ask(
{ kind: "confirm", message: `Run ${call.name}?`, allow: "Allow", deny: "Deny" },
{ audience: approvers, policy: { mode: "first" }, key: api.taskId },
ctx,
);
return r.answer;
} catch (error) {
if (ctx.signal.aborted) throw error;
await backoff(ctx);
}
}
}, { signal: context.abortSignal });
await api.memo("approval", allowed, context);
return allowed ? undefined : { block: "Denied by the user" };
},
});
Durable runs beforeTool in the tool task's call phase; after the execute checkpoint a crash retries execution
without hooks. The approval therefore covers that retry, and it must cover the final arguments: an approval hook
runs after hooks that rewrite arguments.
Quorum (2 of 3).
const r = await q.ask(
{ kind: "confirm", message: "Deploy to production?", allow: "Deploy", deny: "Stop" },
{ audience: [owner, alice, bob], policy: { mode: "quorum", count: 2 } },
ctx,
);
const go = r.answers.every((a) => a.answer);
Question tool (everyone present answers).
const present = self.derive([host.references(Chat)], (refs) =>
refs.filter((r) => !r.lingering && r.input.conversation === conversation).map((r) => r.principal));
execute: async ({ question, options }, ctx) => {
const q = await ctx.get(Questions, { scope: { session } });
const r = await q.ask(
{ kind: "select", message: question, options },
{ audience: present, policy: { mode: "all" }, deadline: Date.now() + 5 * 60_000, key: ctx.toolCallId },
ctx,
);
return { answers: r.answers.map((a) => `${a.by.name}: ${a.answer}`), noAnswer: r.missing.map((w) => w.name) };
},
Login. The login flow is a job (target-design §10); pi-ai's prompt callback asks the owner-scoped store, which
replaces the promise map in Logins:
run: async ({ providerId, owner }, ctx) => {
const q = await ctx.get(Questions, { scope: { owner } });
await models.login(providerId, {
signal: ctx.signal,
prompt: (p) => q.ask(toQuestion(p), { audience: [owner], policy: { mode: "first" }, key: ctx.jobId }, ctx)
.then((r) => r.answer),
notify: (event) => ctx.progress({ event }), // auth URL, device code: notices, not questions
});
},
Not covered here
- Notices (auth URL, device code, progress) are one-way and go through the asking work's own progress state.
- Withdrawing one question without ending the asking flow (an OAuth callback that wins against a pasted-code
prompt): the asker aborts that
askthrough its own signal (oneAbortControllerper prompt). - Remote device-code auto-selection stays in the login flow.