Skip to content
Open
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
14 changes: 14 additions & 0 deletions apps/presentation/dashboard/smoke/bound-conversation-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ try {
assert.deepEqual(calls, [{path: `/api/chat/sessions?conversation_binding_id=${bindingId}`, method: "GET"}],
"A fresh check of the same unchanged binding reuses the complete verified history");

// The final source check can keep the same address while reporting a
// different canonical Session scope. Previously read pages cannot publish.
sourceChecks = 0;
globalThis.fetch = async (input, init) => {
const response = await routedRead(input, init);
if (!isBoundDiscovery(input) || ++sourceChecks !== 2) return response;
const changedSource = await response.json();
changedSource.sessions[0].channel_id = "unrelated-original-channel";
return new Response(JSON.stringify(changedSource), {status: response.status});
};
await assert.rejects(() => fetchChatHistory(options), /original Session scope changed during history read/);
assert.equal(sourceChecks, 2);
globalThis.fetch = routedRead;

// Current source revocation blocks recovery before another history page.
child.stdin.write("deny\n"); assert.equal((await next()).source_verified, false);
calls.length = 0;
Expand Down
22 changes: 20 additions & 2 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,7 @@ export type ChatSessionSnapshot = {
session: ChatSessionSummary;
messages: ChatVisibleMessage[];
active_turn: Record<string, unknown> | null;
selected_turn?: {turn_id: string; status: string; terminal: boolean};
// Derived from one complete, verified read. Never persisted or an authority grant.
verifiedHistoryBasis?: string;
message_page?: {
Expand Down Expand Up @@ -1092,6 +1093,20 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri
return readChatSessionHistory(session, signal, turnId);
}

export async function fetchConfirmedTaskTurn(
{goalId, todoId, sessionId, turnId}: {goalId: string; todoId: string; sessionId: string; turnId: string},
signal: AbortSignal,
) {
const history = await fetchChatSessionHistory(sessionId, signal, turnId);
const turn = history.selected_turn;
if (history.session.goal_id !== goalId || history.session.channel_id !== `task.${todoId}`
|| turn?.turn_id !== turnId || typeof turn.status !== "string" || !turn.status
|| typeof turn.terminal !== "boolean") {
throw new Error("The original Task Turn could not be verified.");
}
return {turn, messages: history.messages};
}

async function readChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string,
progress?: ChatHistoryReadProgress, checkpoint?: (progress: ChatHistoryReadProgress) => void): Promise<ChatSessionSnapshot> {
const sessionId = typeof session === "string" ? session : session.session_id;
Expand Down Expand Up @@ -1375,10 +1390,13 @@ export async function fetchChatHistory(options: {
// while it was in flight. Reuse the original source owner, never a fallback.
if (missing.length && results.some(result => result.status === "fulfilled")) {
if (options.bound) {
const read = results[0];
try {
await resolveBoundChatSession(options.bound, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS));
const confirmed = await resolveBoundChatSession(options.bound, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS));
if (read?.status === "fulfilled" && !sameHistorySource(confirmed, read.value.session)) {
throw new Error("The original Session scope changed during history read.");
}
} catch (error) {
const read = results[0];
if (options.managed || read?.status !== "fulfilled" || !historyTransportUnavailable(error)) throw error;
return {
messages: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,18 @@ export function ChannelTimeline({
}
if (item.kind === "proposal") {
const appliedTeamPlan = item.proposal.actionKind === "team.plan" && item.proposal.status === "applied";
const taskReceipt = item.proposal.reviewPlan?.taskExecution;
const pendingOperation = item.proposal.reviewPlan?.operationFrame?.kind === "pending";
return (
<Fragment key={item.id}><button className={`personal-proposal-row is-${item.proposal.status}`} data-action-kind={item.proposal.actionKind} onClick={() => onSelect({ item: item.proposal, kind: "proposal" })} type="button">
<span><Sparkles size={17} /></span>
<span><small>{pendingOperation ? t(`proposal.kind.${item.proposal.actionKind}`)
: taskReceipt ? t("drawer.taskLaunchReceipt")
: appliedTeamPlan ? (locale === "zh-CN" ? "团队分配 · 已记录" : "Team assignment · Recorded")
: `${t(`proposal.kind.${item.proposal.actionKind}`)} · ${t(`proposal.status.${item.proposal.status}`)}`}</small><strong>{item.proposal.title}</strong>{item.proposal.impact ? <p>{item.proposal.impact}</p> : null}</span>
<b>{item.proposal.status === "gated" && item.proposal.actionKind !== "operation.execute" ? t("timeline.review") : item.proposal.primaryLabel ?? t("timeline.reviewAndConfirm")}</b>
<b>{taskReceipt ? t("feedback.taskStartRequested")
: item.proposal.status === "gated" && item.proposal.actionKind !== "operation.execute" ? t("timeline.review")
: item.proposal.primaryLabel ?? t("timeline.reviewAndConfirm")}</b>
</button>{showManagerTeamResults && onOpenGoalEvidence && appliedTeamPlan
&& item.proposal.goalId && item.proposal.teamPlanTodoIds?.length
? <ManagerTeamResult goalId={item.proposal.goalId} todoIds={item.proposal.teamPlanTodoIds} zh={locale === "zh-CN"} onOpenGoalEvidence={onOpenGoalEvidence}/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {useEffect, useId, useState} from "react";
import {fetchConfirmedTaskTurn} from "../../data/chat";
import {visibleAgentMessage} from "./answer-text";
import {localizedSessionStatus, useWorkspaceI18n} from "./i18n";
import {MarkdownText} from "./markdown";

type TaskReceipt = Parameters<typeof fetchConfirmedTaskTurn>[0];
type Result = Awaited<ReturnType<typeof fetchConfirmedTaskTurn>>;

export function ConfirmedTaskResult({receipt}: {receipt: TaskReceipt}) {
const {t} = useWorkspaceI18n();
const panelId = useId();
const [open, setOpen] = useState(false);
const [read, setRead] = useState<Result | Error | null>(null);
const [retry, setRetry] = useState(0);
const {goalId, todoId, sessionId, turnId} = receipt;
useEffect(() => {
if (!open) return;
const controller = new AbortController();
let next: ReturnType<typeof setTimeout> | undefined;
async function load() {
try {
const result = await fetchConfirmedTaskTurn({goalId, todoId, sessionId, turnId}, controller.signal);
if (controller.signal.aborted) return;
setRead(result);
if (!result.turn.terminal) next = setTimeout(load, 2_500);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 在终止状态先于错误消息写入时保留回执重读。 原任务已提交;执行器失败,Turn 的终止状态刚提交,错误消息仍在写入,用户此时打开原回执。ChatRuntimeController._fail_turn 先 update_turn(status=failed),随后 append_message(role=error);project_chat_session_snapshot 只按 status 推出 terminal,ConfirmedTaskResult 收到 terminal 后停读,空结果分支也没有重试。 打开的回执若先读到 failed、后收到该轮错误消息,会一直显示“本次执行已结束,未返回结果”;真正的原因只在收起再打开后出现。 让已有精确 Turn 读取区分终止状态与结果已可读;在失败、超时或中断的可见结果尚未写入时,允许同一 Turn 有界重读或给出就地重试。不要重启任务,也不要切到 Session 的新 Turn。 在打包 Chat 的真实 SQLite 路径暂停失败消息 append 前的 IO,用 Enter 打开原回执;确认错误写入后,同一打开面板可显示原因或就地重读,协议 turn/start 次数不增加。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 原任务回执在 Chat 重启后持续等待。 隔离 wheel 上真实终止并重启 Chat 服务,连续三次原回执查询和重放确认均保留 running。普通会话读取将同一轮次变为 failed,原 provider 调用仍一次。让回执通过现有执行恢复 owner 核对所选的 Session 和 Turn。读到已退出的 worker 时显示原轮次的终态与恢复说明。保留无关轮次和纯历史读取的边界。 增加仅打开原回执的真实重启回归。

} catch {
if (!controller.signal.aborted) setRead(new Error());
}
}
void load();
return () => { controller.abort(); clearTimeout(next); };
}, [open, goalId, todoId, sessionId, turnId, retry]);

const result = read && !(read instanceof Error) ? read : null;
const messages = result?.turn.terminal
? result.messages.filter(message => ["agent", "assistant", "error"].includes(message.role))
.map(message => ({...message, text: visibleAgentMessage(message.text)})).filter(message => message.text)
: [];
return <>
<button aria-controls={panelId} aria-expanded={open} className="personal-primary-action"
onClick={() => {setRead(null); setOpen(!open);}} type="button">{t(open ? "drawer.taskReceiptHideResult" : "drawer.taskReceiptShowResult")}</button>
<section aria-label={t("drawer.taskReceiptResult")} className="personal-detail-card personal-task-receipt-result"
id={panelId} hidden={!open}>
<h3>{t("drawer.taskReceiptResult")}</h3>
{read instanceof Error ? <>
<p role="status">{t("drawer.taskReceiptMissing")}</p>
<button className="personal-secondary-action" onClick={() => {setRead(null); setRetry(value => value + 1);}}
type="button">{t("proposal.readbackRetry")}</button>
</> : !result ? <p role="status">{t("drawer.taskReceiptReading")}</p>
: !result.turn.terminal ? <p role="status">{t("drawer.taskReceiptPending")}</p>
: <>
<p role="status">{result.turn.status === "timed_out" ? t("drawer.taskReceiptTimedOut")
: localizedSessionStatus(result.turn.status, t)}</p>
{messages.length ? <div className="personal-task-receipt-answer">
{messages.map(message => <MarkdownText key={message.message_id} headingLevel={4} text={message.text} />)}
</div>
: <p>{t("drawer.taskReceiptNoResult")}</p>}
</>}
</section>
</>;
}
Loading
Loading