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
3 changes: 3 additions & 0 deletions changelog.d/pi-branch-return-outcome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- A command sent from Chat to a pi session now shows its result when the session moved to another
branch and `/tree` came back before the result was written, instead of ending as unknown.
10 changes: 8 additions & 2 deletions docs/agent-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,13 @@ Recognized Hermes informational result cards remain available.
the exact text; a `model_change`, `thinking_level_change` or `compaction` record in pi, omp, gjc
and OmO sessions (omp's `/handoff` writes a compaction with method `handoff`); a `compacted`
record for Codex `/compact`. Records are claimed in send order, so two equal sends never share
one, and a rewritten transcript drops what it no longer holds.
one, and a rewritten transcript drops what it no longer holds. Known limit: a pi-family send the
agent dropped without a record takes the record of a later equal send on the same branch, and
that later send ends as unknown. In pi, only the session's active
branch counts: a send stays with the branch it was sent on, also after the session goes to
another branch and `/tree` comes back to it, unless meanwhile something was written under that
branch's last entry or a record a result could be was written elsewhere (`resumedBranch` in
[`server/conversation.ts`](../server/conversation.ts)); then the send ends as unknown.
- `dialog`: a card answered from Chat, counted only for the send that raised that card.
- `recognized-result`: a reader of kind `result` matched a complete, recorded layout that appeared
after this send's own echo and was not on screen when it was sent. Hermes `/help`, `/status`,
Expand All @@ -214,7 +220,7 @@ program name), it stays with the next session herdr reports in the same terminal
agent, or under the label herdr gave the pane at the send (herdr names an OmO pane `pi`). A send to
a restarted agent while herdr still shows the session of the process before waits up to a minute
(`STALE_SESSION_LAG_MS`) for the agent's own session. Known limits, all of which hide a row rather
than show a wrong result, except the last:
than show a wrong result, except the last two:

- That minute is counted when Chat next asks. A session that appeared within it while no Chat was
open is taken for a later one, and the row is hidden.
Expand Down
67 changes: 52 additions & 15 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import { CLAUDE_IMAGE_REF, claudeImageType, claudeResultText, createClaudeTransc
import { forgetSubagents, lineNotifications, subagentDetails, type SubagentDetail } from "./claude-subagents.ts";
import { launchedAgents } from "./launched-agents.ts";
import { readsStartScreen, startScreenMetadata } from "./start-screen.ts";
import { commandTranscriptRecords, type CommandTranscriptRecord } from "./outcome.ts";
import { commandTranscriptRecords, PI_EVIDENCE_TYPES, type CommandTranscriptRecord } from "./outcome.ts";
import { type ClaudeBranch, claudeForks, followClaudeBranch, type PromptChain, turnStarts } from "./transcript-turns.ts";
import { devinSessionForPane, isDevinProcess } from "./devin-session.ts";

Expand Down Expand Up @@ -1589,7 +1589,11 @@ const commandTranscripts = new Map<string, {
partialStart: number; records: CommandTranscriptRecord[]; identity: string;
/** pi: the active branch's name and its last node, which an append that runs on from it keeps */
branch: string; leaf: string | null; answer: CommandTranscript | null;
/** pi: the last node each recent branch was read at (id and byte offset), newest last, so a session that comes back
* to one keeps its name */
branches: Map<string, { id: string; offset: number }>;
}>();
const REMEMBERED_BRANCHES = 8;

/** `look`: the snapshot and herdr's `pane.process_info` the caller's poll already read, so neither is asked again. */
export async function readCommandTranscript(paneId: string, codexHome?: string, look: { snapshot?: SessionSnapshot; processes?: KnownProcesses } = {}): Promise<CommandTranscript | null> {
Expand Down Expand Up @@ -1633,6 +1637,7 @@ export async function readCommandTranscript(paneId: string, codexHome?: string,
const identity = append ? cached.identity : `${source}\0${path}\0${file}:${stat.size}:${stat.mtimeMs}`;
let branch = "";
let leaf: string | null = null;
let branches = new Map<string, { id: string; offset: number }>();
if (source === "pi-transcript") {
const nodes = current.filter(record => typeof record.entry.id === "string");
const byId = new Map(nodes.map((record, index) => [String(record.entry.id), { record, index }]));
Expand All @@ -1657,29 +1662,61 @@ export async function readCommandTranscript(paneId: string, codexHome?: string,
// An unexplained missing ancestor or ambiguous index cannot prove branch ownership.
current = rooted ? active.reverse() : [];
leaf = rooted && current.length > 0 ? String(current.at(-1)!.entry.id) : null;
// An append that runs on from the branch read before is that branch: the forks in the window change as it
// slides (its first node always looks like one), and the sends bound to the branch must keep seeing it (#1487).
// Any other append, a branch switch or a fork made since among them, names its branch afresh.
const continued = append && cached.leaf !== null && cached.branch !== "unresolved" && rooted
&& runsOnFrom(cached.leaf, current, forks, nodes[0]);
branch = !rooted ? "unresolved" : continued ? cached.branch
: createHash("sha256").update(forks.map(({ entry }) => String(entry.id)).join("\0")).digest("hex");
// An append that runs on from the node a branch was last read at is that branch: the forks in the window change
// as it slides (its first node always looks like one), and the session can go to another branch and /tree back,
// yet the sends bound to the branch must keep seeing it (#1487, #1879). Any other path names its branch afresh.
if (append) branches = new Map(cached.branches);
const resumed = rooted ? resumedBranch(branches, nodes, current) : null;
const continued = resumed === null && append && cached.leaf !== null && cached.branch !== "unresolved" && rooted
&& runsOnFromCut(cached.leaf, current, forks, nodes[0]);
branch = !rooted ? "unresolved" : resumed ?? (continued ? cached.branch
: createHash("sha256").update(forks.map(({ entry }) => String(entry.id)).join("\0")).digest("hex"));
if (branch !== "unresolved" && leaf !== null) {
branches.delete(branch);
branches.set(branch, { id: leaf, offset: current.at(-1)!.offset });
if (branches.size > REMEMBERED_BRANCHES) branches.delete(branches.keys().next().value!);
}
}
const cleared = current.reduce((last, { entry }, index) => isContextClear(entry, source) ? index : last, 0);
const answer = branch === "unresolved" ? null
: { identity: `${identity}\0${branch}`, records: cleared > 0 ? current.slice(cleared) : current };
remember(commandTranscripts, path, { signature, file, start, end: stat.size, digests, partialStart, records, identity, branch, leaf, answer }, 4);
remember(commandTranscripts, path, { signature, file, start, end: stat.size, digests, partialStart, records, identity, branch, leaf, answer, branches }, 4);
return answer;
}

/**
* Whether a pi path read after an append runs on from `leaf`, the last node of the path read before, with no fork
* after it. `leaf` is on the path, or was cut away with the window's start and is the parent of its first node (the
* first node read after it, as appends follow it), whose own fork mark only says its predecessor was cut away.
* The remembered pi branch (`branches`: name to the node it was last read at) that `path` runs on from. Its node is on
* the path, the same entry at the same offset (a later entry may reuse its id), and of what was written since (that node was then the file's last), nothing off the path is under it, and
* nothing off the path is a record a send's result could be (`PI_EVIDENCE_TYPES`): a result written on the branch the
* session went to must not leave a send waiting on its old branch for a later identical send's. `nodes` are the
* window's nodes in file order.
*/
function resumedBranch(branches: ReadonlyMap<string, { id: string; offset: number }>, nodes: readonly CommandTranscriptRecord[], path: readonly CommandTranscriptRecord[]): string | null {
const onPath = new Map(path.map((record) => [String(record.entry.id), record]));
for (const [name, leaf] of branches) {
const at = onPath.get(leaf.id);
if (!at || at.offset !== leaf.offset) continue;
const under = new Set([leaf.id]);
const alone = nodes.every(({ entry, offset }) => {
if (offset <= at.offset) return true;
const id = String(entry.id);
if (typeof entry.parentId === "string" && under.has(entry.parentId)) {
under.add(id);
return onPath.has(id);
}
return onPath.has(id) || !PI_EVIDENCE_TYPES.has(String(entry.type));
});
if (alone) return name;
}
return null;
}

/**
* Whether a pi path read after an append runs on from `leaf`, the last node of the path read before, which was cut
* away with the window's start: it is the parent of the path's first node (the first node read after it, as appends
* follow it), whose own fork mark only says its predecessor was cut away, and no other fork follows.
*/
function runsOnFrom(leaf: string, path: readonly CommandTranscriptRecord[], forks: readonly CommandTranscriptRecord[], first: CommandTranscriptRecord | undefined): boolean {
const kept = path.find(({ entry }) => entry.id === leaf);
if (kept) return forks.every((fork) => fork.offset <= kept.offset);
function runsOnFromCut(leaf: string, path: readonly CommandTranscriptRecord[], forks: readonly CommandTranscriptRecord[], first: CommandTranscriptRecord | undefined): boolean {
const head = path[0];
return head !== undefined && head === first && head.entry.parentId === leaf && forks.every((fork) => fork === head);
}
Expand Down
Loading
Loading