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
242 changes: 209 additions & 33 deletions apps/web/src/components/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3929,6 +3929,35 @@ export function ProjectView({
let replayedContent = needsFullReplay ? '' : message.content;
let replayedEvents: AgentEvent[] = needsFullReplay ? [] : [...(message.events ?? [])];
let latestReattachRunStatus: ChatMessage['runStatus'] = status.status;
const updateReattachConversationLatestRun = (
nextStatus: NonNullable<ChatMessage['runStatus']>,
endedAt?: number,
) => {
setConversations((curr) =>
curr.map((conversation) => {
if (conversation.id !== reattachConversationId) return conversation;
const startedAt =
conversation.latestRun?.startedAt
?? status.createdAt
?? message.startedAt
?? message.createdAt;
return {
...conversation,
updatedAt: endedAt ?? conversation.updatedAt,
latestRun: {
status: nextStatus,
startedAt,
...(endedAt === undefined
? {}
: {
endedAt,
durationMs: Math.max(0, endedAt - startedAt),
}),
},
};
}),
);
};
const applyContentDelta = (delta: string) => {
for (const ev of parser.feed(delta)) {
if (ev.type === 'artifact:start') {
Expand Down Expand Up @@ -4074,6 +4103,10 @@ export function ProjectView({
? { telemetryFinalized: true }
: undefined,
);
updateReattachConversationLatestRun(
latestReattachRunStatus === 'canceled' ? 'canceled' : 'succeeded',
endedAt,
);
if (latestReattachRunStatus === 'canceled') return;
void (async () => {
const preTurn = message.preTurnFileNames;
Expand Down Expand Up @@ -4388,6 +4421,7 @@ export function ProjectView({
true,
);
latestReattachRunStatus = runStatus;
updateReattachConversationLatestRun(runStatus);
if (runStatus === 'canceled') {
textBuffer.cancel();
unregisterTextBuffer();
Expand Down Expand Up @@ -4975,6 +5009,8 @@ export function ProjectView({
// that just failed in the current session (the daemon status fetch is only
// needed on reload, not for runs that are already known to have failed).
let currentRunId: string | undefined = undefined;
let latestLiveDaemonRunStatus: ChatMessage['runStatus'] =
config.mode === 'daemon' ? 'running' : undefined;
const updateConversationLatestRun = (
status: NonNullable<ChatMessage['runStatus']>,
endedAt?: number,
Expand Down Expand Up @@ -5159,6 +5195,23 @@ export function ProjectView({
}
persistMessageById(assistantId, { keepalive: true });
};
const resolveLiveDaemonTerminalRun = async (
fallbackStatus?: TerminalRunStatus | null,
options?: { allowFallbackOnActiveProbe?: boolean },
): Promise<TerminalRunResolution | null> => {
if (!currentRunId) {
if (!fallbackStatus) return null;
return {
status: fallbackStatus,
endedAt: Date.now(),
authoritative: false,
};
}
return resolveDaemonTerminalRunCompletion(currentRunId, {
fallbackStatus,
allowFallbackOnActiveProbe: options?.allowFallbackOnActiveProbe,
});
};
const pushEvent = (ev: AgentEvent) => {
textBuffer.flush();
updateAssistant((prev) => ({ ...prev, events: [...(prev.events ?? []), ev] }));
Expand Down Expand Up @@ -5330,7 +5383,7 @@ export function ProjectView({
},
}));
},
onDone: (fullText = '') => {
onDone: async (fullText = '') => {
// The daemon delivers onDone even for a canceled run, so a run
// superseded by a "send now" interrupt can still land here and must
// not apply its completion side effects over the replacement. A run
Expand Down Expand Up @@ -5398,25 +5451,65 @@ export function ProjectView({
clearTraceTouchedFilePaths();
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

QA note: I ran both the targeted automation and a manual Open Design/AMR runtime smoke on this PR.

Automated checks passed:

  • ProjectView.run-cleanup.test.tsx targeted cases for live daemon onDone before terminal status and daemon terminal timestamp
  • ProjectView.reattach-restore.test.tsx
  • pnpm --filter @open-design/web typecheck

Manual smoke used Open Design / AMR (agentId=amr, deepseek-v4-flash) with prompt:
PR5325 手工验收 smoke:请生成一个极简登录页原型,标题写 PR5325 Smoke Test,包含邮箱输入框、密码输入框和蓝色登录按钮。

Evidence:

  • Project: 6b67924a-7f3d-443c-b023-088f38e378c6
  • Run: 9d14895b-2033-4a05-82ee-f01473685f55
  • Conversation: da66d764-3008-44d5-a6ef-fb449a491be3
  • Initial /api/runs and conversation latestRun were still running; I did not observe premature succeeded while daemon was active.
  • The run event log later showed status=succeeded, exit_code=0, and generated index.html.

One issue from the manual pass: after the successful run, local web/daemon ports became unreachable while tools-dev status still showed stale running pids. Daemon log included ReferenceError: scanRunEventsForUsageAnalytics is not defined at apps/daemon/src/server.ts:4000. I recovered the namespace with pnpm tools-dev restart web --namespace pr5325web; web then returned 200 OK and daemon /api/app-config returned agentId=amr.

Verdict: the targeted web status-boundary behavior looks covered and did not regress in the manual smoke, but I would not call the full runtime pass clean until the daemon cleanup error is confirmed unrelated or fixed.

}
const endedAt = Date.now();
let endedAt = Date.now();
let finalRunStatus: ChatMessage['runStatus'] = 'succeeded';
updateAssistant((prev) => {
finalRunStatus = resolveSucceededRunStatus(prev.runStatus);
return {
if (config.mode === 'daemon') {
const terminalFallbackStatus =
asTerminalRunStatus(latestLiveDaemonRunStatus)
?? asTerminalRunStatus(resolveSucceededRunStatus(latestLiveDaemonRunStatus));
const terminalRun = await resolveLiveDaemonTerminalRun(
terminalFallbackStatus,
{
allowFallbackOnActiveProbe: asTerminalRunStatus(latestLiveDaemonRunStatus) !== null,
},
);
if (!terminalRun) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new early return makes daemon onDone depend on an authoritative terminal run snapshot before any of the normal success completion side effects run. That behavior is valid only if every daemon completion path and its tests provide terminal status before onDone, but the current PR head does not keep the existing test harness in sync: the live Web workspace tests job fails six existing completion-side-effect tests (ProjectView.run-cleanup.test.tsx design-system audit cases and ProjectView.reattach-restore.test.tsx touched-file cases) because their daemon stream mocks call onDone after onRunCreated without a terminal onRunStatus/terminal fetchChatRunStatus, so this branch returns before audit/trace finalization. This blocks merge because required validation is red and those tests cover the side effects that should still happen after a real successful daemon run. Update the affected daemon-mode completion tests/mocks to emit the terminal daemon status shape that production streamViaDaemon emits before onDone (or adjust this resolver if completion should still proceed without that confirmation), then rerun the web workspace tests.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

const ownsCurrentRun = clearCurrentRunStreamingMarker(
runConversationId,
controller,
cancelController,
);
if (ownsCurrentRun) setRecoveryTick((t) => t + 1);
scheduleConversationMessageRefresh(runConversationId);
clearTraceTouchedFilePaths();
return;
}
endedAt = terminalRun.endedAt;
finalRunStatus = terminalRun.status;
latestLiveDaemonRunStatus = finalRunStatus;
if (finalRunStatus === 'canceled') setError(null);
updateAssistant((prev) => ({
...prev,
endedAt,
runStatus: finalRunStatus,
};
});
if (runCommentAttachments.length > 0) {
void patchAttachedStatuses(runCommentAttachments, 'needs_review');
...(terminalRun.resumable !== undefined
? { resumable: terminalRun.resumable }
: {}),
}));
} else {
updateAssistant((prev) => {
finalRunStatus = resolveSucceededRunStatus(prev.runStatus);
return {
...prev,
endedAt,
runStatus: finalRunStatus,
};
});
}
const ownsCurrentRun = clearCurrentRunStreamingMarker(
runConversationId,
controller,
cancelController,
);
if (ownsCurrentRun) updateConversationLatestRun(finalRunStatus ?? 'succeeded', endedAt);
if (finalRunStatus !== 'succeeded') {
scheduleConversationMessageRefresh(runConversationId);
clearTraceTouchedFilePaths();
return;
}
if (runCommentAttachments.length > 0) {
void patchAttachedStatuses(runCommentAttachments, 'needs_review');
}
// Refetch the file list directly (rather than just bumping the
// refresh signal) so we can diff against the pre-turn snapshot
// and attach the new files to the assistant message as download
Expand Down Expand Up @@ -5815,32 +5908,62 @@ export function ProjectView({
};
latestAssistantMsg = pinnedAssistant;
currentRunId = runId;
latestLiveDaemonRunStatus = 'queued';
// The view may already be on a different project/conversation;
// pin the daemon run to the original row so returning can reattach.
void saveMessage(project.id, runConversationId, pinnedAssistant);
updateMessageById(assistantId, (prev) => ({ ...prev, runId, runStatus: 'queued' }));
},
onRunStatus: (runStatus) => {
const endedAt = isTerminalRunStatus(runStatus) ? Date.now() : undefined;
const runMayFinalize =
!supersededRunsRef.current.has(controller);
updateMessageById(
assistantId,
(prev) => ({
...prev,
runStatus,
endedAt: endedAt === undefined ? prev.endedAt : prev.endedAt ?? endedAt,
}),
true,
runStatus === 'canceled' ? { telemetryFinalized: true } : undefined,
);
if (!runMayFinalize) return;
updateConversationLatestRun(runStatus, endedAt);
if (isTerminalRunStatus(runStatus)) {
latestLiveDaemonRunStatus = runStatus;
if (!isTerminalRunStatus(runStatus)) {
updateMessageById(
assistantId,
(prev) => ({
...prev,
runStatus,
endedAt: prev.endedAt,
}),
true,
);
if (!runMayFinalize) return;
updateConversationLatestRun(runStatus);
return;
}
void (async () => {
const fallbackStatus = asTerminalRunStatus(runStatus) ?? 'succeeded';
const terminalRun = await resolveLiveDaemonTerminalRun(fallbackStatus, {
allowFallbackOnActiveProbe: true,
}) ?? {
status: fallbackStatus,
endedAt: Date.now(),
authoritative: false,
};
latestLiveDaemonRunStatus = terminalRun.status;
if (terminalRun.status === 'canceled') setError(null);
updateMessageById(
assistantId,
(prev) => ({
...prev,
runStatus: terminalRun.status,
endedAt: terminalRun.endedAt,
...(terminalRun.resumable !== undefined
? { resumable: terminalRun.resumable }
: {}),
}),
true,
terminalRun.status === 'canceled'
? { telemetryFinalized: true }
: undefined,
);
if (!runMayFinalize) return;
updateConversationLatestRun(terminalRun.status, terminalRun.endedAt);
clearCurrentRunStreamingMarker(runConversationId, controller, cancelController);
scheduleConversationMessageRefresh(runConversationId);
if (runStatus !== 'succeeded') clearTraceTouchedFilePaths();
}
if (terminalRun.status !== 'succeeded') clearTraceTouchedFilePaths();
})();
},
onRunEventId: (lastRunEventId) => {
updateMessageById(assistantId, (prev) => ({ ...prev, lastRunEventId }));
Expand Down Expand Up @@ -9055,6 +9178,65 @@ function isActiveRunStatus(status: ChatMessage['runStatus']): boolean {

/** A daemon run-status snapshot, as returned by `fetchChatRunStatus`/`listActiveChatRuns`. */
type RunStatusSnapshot = Awaited<ReturnType<typeof fetchChatRunStatus>>;
type TerminalRunStatus = Extract<NonNullable<ChatMessage['runStatus']>, 'succeeded' | 'failed' | 'canceled'>;

type TerminalRunResolution = {
status: TerminalRunStatus;
endedAt: number;
authoritative: boolean;
resumable?: boolean;
};

function asTerminalRunStatus(status: ChatMessage['runStatus']): TerminalRunStatus | null {
if (status === 'succeeded' || status === 'failed' || status === 'canceled') {
return status;
}
return null;
}

async function resolveDaemonTerminalRunCompletion(
runId: string,
options: {
candidate?: RunStatusSnapshot | null;
fallbackStatus?: TerminalRunStatus | null;
allowFallbackOnActiveProbe?: boolean;
} = {},
): Promise<TerminalRunResolution | null> {
const { candidate, fallbackStatus, allowFallbackOnActiveProbe = false } = options;
const candidateStatus = candidate ? asTerminalRunStatus(candidate.status) : null;
if (candidate && candidateStatus) {
return {
status: candidateStatus,
endedAt: candidate.updatedAt,
authoritative: true,
...(candidate.resumable !== undefined ? { resumable: candidate.resumable } : {}),
};
}
let probed: RunStatusSnapshot | null = null;
try {
probed = await fetchChatRunStatus(runId);
} catch {
probed = null;
}
const probedStatus = probed ? asTerminalRunStatus(probed.status) : null;
if (probed && probedStatus) {
return {
status: probedStatus,
endedAt: probed.updatedAt,
authoritative: true,
...(probed.resumable !== undefined ? { resumable: probed.resumable } : {}),
};
}
if (!fallbackStatus) return null;
if (!probed || allowFallbackOnActiveProbe) {
return {
status: fallbackStatus,
endedAt: Date.now(),
authoritative: false,
};
}
return null;
}

/**
* Resolves the authoritative `endedAt` for a terminal-recovery branch.
Expand All @@ -9080,14 +9262,8 @@ async function resolveTerminalEndedAt(
runId: string,
candidate: RunStatusSnapshot | null | undefined,
): Promise<number> {
if (candidate && !isActiveRunStatus(candidate.status)) {
return candidate.updatedAt;
}
const probed = await fetchChatRunStatus(runId).catch(() => null);
if (probed && !isActiveRunStatus(probed.status)) {
return probed.updatedAt;
}
return Date.now();
const resolved = await resolveDaemonTerminalRunCompletion(runId, { candidate });
return resolved?.endedAt ?? Date.now();
}

function isProgrammaticBrandExtractionStatusMessage(
Expand Down
Loading
Loading