Skip to content
Merged
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
5 changes: 1 addition & 4 deletions packages/cli/src/repl/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2922,10 +2922,7 @@ function sidekickReadingOf(
// current association, which is what a person who has not chosen is reading.
const reading = selected ?? active;
return Object.freeze({
recovery:
read.value.recoveryPending ||
(read.value.recoveries.length === 0 &&
read.value.unestablished.some((one) => one.conversation === active)),
recovery: owner.recoveryPending,
chat: sidekickChatRows(read.value, owner.previewing),
// What the replies of this history actually did to this execution's
// entries, as the coordinates their records hold. Detached from the chat
Expand Down
7 changes: 1 addition & 6 deletions packages/cli/src/repl/sidekick/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,7 @@ export function* useSidekickDispatch(options: {
if (!reading.ok) {
return reading;
}
if (
!eligible(head) &&
(reading.value.recoveryPending ||
(reading.value.recoveries.length === 0 &&
reading.value.unestablished.some((one) => one.conversation === reading.value.active)))
) {
if (!eligible(head) && owner.recoveryPending) {
return Err(
new Error("This conversation holds feedback whose acceptance cannot be established."),
);
Expand Down
57 changes: 51 additions & 6 deletions packages/cli/src/repl/sidekick/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,19 @@ function slot(conversation: string, response: string, at: number): string {
* the nearest readable shape would show somebody a conversation they never had.
*/
export function projectSidekick(records: readonly SidekickRecord[]): Result<SidekickReading> {
const projected = projectHistory(records);
return projected.ok ? Ok(projected.value.reading) : projected;
}

/** A reading, with where each of its unestablished feedback facts was read from. */
export interface ProjectedHistory {
readonly reading: SidekickReading;
/** The record position of each `reading.unestablished` entry, index for index. */
readonly unestablishedAt: readonly number[];
}

/** {@link projectSidekick}, keeping the record positions a live owner's exception needs. */
export function projectHistory(records: readonly SidekickRecord[]): Result<ProjectedHistory> {
// The request boundaries first, by the same rules the live owner reads them
// by. Everything below that associates a message, a reply or an orientation
// with a saved turn asks these rather than comparing numbers.
Expand Down Expand Up @@ -1060,7 +1073,7 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result<Side
const observed = new Set<string>();
let preferences: SidekickPreferences | undefined;
const delivered = new Map<string, number>();
const unestablished: { conversation: string; turn: number | null }[] = [];
const unestablished: { conversation: string; turn: number | null; at: number }[] = [];
/**
* Feedback text this history retained and never acknowledged, in record order.
*
Expand Down Expand Up @@ -1335,7 +1348,7 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result<Side
if (conversations.get(record.conversation)!.completed.get(record.turn) === record.text) {
delivered.set(record.conversation, record.turn);
} else {
unestablished.push({ conversation: record.conversation, turn: record.turn });
unestablished.push({ conversation: record.conversation, turn: record.turn, at: index });
}
break;
}
Expand Down Expand Up @@ -1371,7 +1384,7 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result<Side
}
}
if (owner === undefined) {
unestablished.push({ conversation: feedback.conversation, turn: null });
unestablished.push({ conversation: feedback.conversation, turn: null, at: feedback.at });
continue;
}
delivered.set(feedback.conversation, owner);
Expand Down Expand Up @@ -1471,8 +1484,8 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result<Side
});
});

return Ok(
Object.freeze({
return Ok({
reading: Object.freeze({
conversations: Object.freeze(
order.map((id): SidekickConversation => {
const held = conversations.get(id)!;
Expand Down Expand Up @@ -1529,8 +1542,40 @@ export function projectSidekick(records: readonly SidekickRecord[]): Result<Side
delivered: Object.freeze(
[...delivered].map(([conversation, turn]) => Object.freeze({ conversation, turn })),
),
unestablished: Object.freeze(unestablished.map((held) => Object.freeze(held))),
unestablished: Object.freeze(
unestablished.map((held) =>
Object.freeze({ conversation: held.conversation, turn: held.turn }),
),
),
answers: Object.freeze(answers.map((held) => Object.freeze(held))),
}),
unestablishedAt: Object.freeze(unestablished.map((held) => held.at)),
});
}

/**
* Whether the active conversation needs recovery before anything else is sent.
*
* `live` names the one feedback record a live owner is still asking the
* provider about. Only that exact record is set aside, and only by its position:
* earlier feedback with identical text, a mismatched acknowledgement and an
* unused recovery decision each keep the answer `true`.
*/
export function recoveryRequired(
records: readonly SidekickRecord[],
history: ProjectedHistory,
live: number | undefined,
): boolean {
const { reading } = history;
const active = reading.active;
if (active === undefined) {
return false;
}
return (
recoveryBlocked(records, reading.requests, reading.recoveries, active, undefined, live) ||
(reading.recoveries.length === 0 &&
reading.unestablished.some(
(one, index) => one.conversation === active && history.unestablishedAt[index] !== live,
))
);
}
61 changes: 57 additions & 4 deletions packages/cli/src/repl/sidekick/owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,14 @@ import { ReplHost } from "../host.ts";
import { API } from "@executablemd/runtime";
import { conversationStream, failedOutcome } from "./history.ts";
import type { SidekickRecord, SidekickWriter } from "./history.ts";
import { applicableHumanStop, projectSidekick, requestsFromHistory } from "./model.ts";
import type { SidekickReading } from "./model.ts";
import {
applicableHumanStop,
projectHistory,
projectSidekick,
recoveryRequired,
requestsFromHistory,
} from "./model.ts";
import type { ProjectedHistory, SidekickReading } from "./model.ts";
import { controlJournal } from "./admission.ts";
import {
responseSurface,
Expand Down Expand Up @@ -505,6 +511,15 @@ export interface SidekickOwner {
* holds one is the one answer that cannot be corrected later.
*/
readonly reading: Result<SidekickReading>;
/**
* Whether the active conversation needs recovery before anything else is sent.
*
* {@link SidekickOwner.reading} with one exception: the feedback record this
* owner's running exchange is still asking the provider about. That record
* stays unresolved in the reading, and stops being excepted once its
* exchange ends, fails or is stopped. An unreadable history answers `true`.
*/
readonly recoveryPending: boolean;
/** Whether a cycle is running, so a second one refuses rather than queueing. */
readonly busy: boolean;
/**
Expand Down Expand Up @@ -796,6 +811,29 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
| undefined;
let reading: Result<SidekickReading> = Ok(EMPTY);
let applicable: HumanStopFact | undefined;
/**
* The feedback record this owner's running exchange is asking about, by position.
*
* Set before the refresh that first shows the record and cleared by its
* own exchange, by identity. Nothing durable holds it, so a reopened owner
* has none.
*/
let inFlight: { readonly at: number } | undefined;
let recovering = false;
/** What the last refresh read, kept so a change to `inFlight` needs no reread. */
let projected: { records: readonly SidekickRecord[]; history: ProjectedHistory } | undefined;
function reckon(): void {
recovering =
projected === undefined ||
recoveryRequired(projected.records, projected.history, inFlight?.at);
}
function relinquish(held: { readonly at: number } | undefined): void {
if (held !== undefined && inFlight === held) {
inFlight = undefined;
reckon();
moved();
}
}
/**
* The response whose generation this process is watching, where one is.
*
Expand Down Expand Up @@ -1020,7 +1058,10 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
*/
function* refresh(): Operation<void> {
const records = storage === undefined ? [] : yield* storage.writer.readAll();
reading = projectSidekick(records);
const history = projectHistory(records);
reading = history.ok ? Ok(history.value.reading) : history;
projected = history.ok ? { records, history: history.value } : undefined;
reckon();
applicable = reading.ok ? applicableHumanStop(reading.value, records) : undefined;
// Every re-read is a change somebody may be looking at: a message landing,
// a reply, a failed turn and a configuration all come through here.
Expand Down Expand Up @@ -2461,6 +2502,7 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
// and a turn that picked up a setting after Core had its request would be
// running under one thing while the history says another.
const deliveringInput = sending.kind === "feedback" ? sending.of : carried;
let asked: { readonly at: number } | undefined;
const preparing = {
at,
*prepare(text: string): Operation<void> {
Expand All @@ -2473,8 +2515,13 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
turn: null,
text,
});
const written = (yield* paired.writer.readAll()).length - 1;
if (sending.kind === "feedback") {
began = (yield* paired.writer.readAll()).length - 1;
began = written;
}
if (sending.kind === "feedback" || sending.finished !== undefined) {
asked = { at: written };
inFlight = asked;
}
yield* refresh();
}
Expand All @@ -2485,6 +2532,7 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
if (preparingInput === preparing) {
preparingInput = undefined;
}
relinquish(asked);
});
const todo = yield* Todo.get();
let confirmed = false;
Expand Down Expand Up @@ -2513,6 +2561,7 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
// It also ends the exchange. A provider that failed is not a reply to
// correct, so the next move is a person's.
yield* refresh();
relinquish(asked);
const attempted = yield* turnForRequest(paired, began, conversation);
// Kept as an attempt that did not finish, told which turn this
// request's own records say it became. With none, it claims no turn, no
Expand Down Expand Up @@ -2640,6 +2689,7 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
output,
});
yield* refresh();
relinquish(asked);
// The acknowledged reply is the item now. Taken down after the refresh,
// so the row that replaces this one is already in the reading a screen
// would draw — there is no frame in between showing neither.
Expand Down Expand Up @@ -2677,6 +2727,9 @@ export function useSidekickOwner(input: SidekickOwnerInput): Operation<SidekickO
get reading(): Result<SidekickReading> {
return reading;
},
get recoveryPending(): boolean {
return recovering;
},
get busy(): boolean {
return busy;
},
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/repl/sidekick/recovery-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export function recoveryBlocked(
dispositions: readonly RecoveryDisposition[],
conversation: string,
permission?: string,
live?: number,
): boolean {
const retired = new Set(
dispositions
Expand All @@ -271,5 +272,7 @@ export function recoveryBlocked(
) {
return true;
}
return uncertainRequests(records, requests, conversation).some((at) => !retired.has(at));
return uncertainRequests(records, requests, conversation).some(
(at) => at !== live && !retired.has(at),
);
}
Loading
Loading