= {
+ id: 'task-1',
+ title: 'OpenAPI sync',
+ status: 'running',
+ roundTripCount: 1,
+ updatedAt: '2026-04-28T02:01:00.000Z',
+ currentTurn: {
+ turnId: 'turn-1',
+ role: 'owner',
+ intentKind: 'implementation',
+ state: 'running',
+ attemptNo: 1,
+ executorServiceId: 'svc-1',
+ executorAgentType: 'codex',
+ activeRunId: 'run-site-only',
+ createdAt: '2026-04-28T02:00:00.000Z',
+ updatedAt: '2026-04-28T02:01:00.000Z',
+ completedAt: null,
+ lastError: null,
+ progressText: null,
+ progressUpdatedAt: null,
+ },
+ outputs: [],
+ };
+
+ const collapsed = renderToStaticMarkup(
+ createElement(RoomCardV2, {
+ activity: activity({ pairedTask }),
+ activityLoading: false,
+ busy: false,
+ draft: '',
+ entry: { ...entry, status: 'processing' },
+ expanded: false,
+ inboxItems: [],
+ locale: 'ko',
+ onDraftChange: () => {},
+ onSendMessage: () => {},
+ onToggle: () => {},
+ pinned: false,
+ t,
+ ...formatters,
+ }),
+ );
+ const expanded = renderToStaticMarkup(
+ createElement(RoomCardV2, {
+ activity: activity({ pairedTask }),
+ activityLoading: false,
+ busy: false,
+ draft: '',
+ entry: { ...entry, status: 'processing' },
+ expanded: true,
+ inboxItems: [],
+ locale: 'ko',
+ onDraftChange: () => {},
+ onSendMessage: () => {},
+ onToggle: () => {},
+ pinned: false,
+ t,
+ ...formatters,
+ }),
+ );
+
+ expect(collapsed).not.toContain('class="room-live"');
+ expect(expanded).not.toContain('room-timeline-live');
+ expect(expanded).not.toContain(t.rooms.loadingActivity);
+ expect(expanded).toContain(t.rooms.noActivity);
+ });
it('renders message attachments as dashboard images', () => {
const html = renderToStaticMarkup(
diff --git a/apps/dashboard/src/RoomCardV2.tsx b/apps/dashboard/src/RoomCardV2.tsx
index 6fe7da4..f7ce7fb 100644
--- a/apps/dashboard/src/RoomCardV2.tsx
+++ b/apps/dashboard/src/RoomCardV2.tsx
@@ -227,6 +227,7 @@ export function RoomCardV2({
expanded={expanded}
formatDate={formatDate}
isProcessing={isProcessing}
+ liveProgressDisplay={liveProgressDisplay}
locale={locale}
senderRoleClass={senderRoleClass}
t={t}
@@ -488,6 +489,7 @@ function CollapsedLiveTurn({
expanded,
formatDate,
isProcessing,
+ liveProgressDisplay,
locale,
senderRoleClass,
t,
@@ -496,12 +498,13 @@ function CollapsedLiveTurn({
expanded: boolean;
formatDate: RoomCardFormatters['formatDate'];
isProcessing: boolean;
+ liveProgressDisplay: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
t: Messages;
turn: RoomTurn | null;
}) {
- if (expanded || !isProcessing || !turn) return null;
+ if (expanded || !isProcessing || !turn || !liveProgressDisplay) return null;
return (
@@ -524,11 +527,7 @@ function CollapsedLiveTurn({
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
- {turn.progressText && turn.progressText.trim() ? (
-
- ) : turn.activeRunId ? (
- {turn.activeRunId}
- ) : null}
+
);
}
@@ -702,15 +701,18 @@ function RoomThreadSection({
t: Messages;
turn: RoomTurn | null;
}) {
- const showLiveItem =
- turn && turn.completedAt === null && (isProcessing || liveProgressDisplay);
+ const showLiveItem = !!(
+ turn &&
+ turn.completedAt === null &&
+ liveProgressDisplay
+ );
return (
{t.rooms.activity}
{agentMessages.length}
- {agentMessages.length === 0 && !(isProcessing && turn) ? (
+ {agentMessages.length === 0 && !showLiveItem ? (
{t.rooms.noActivity}
) : (
diff --git a/quality/code-quality-budgets.json b/quality/code-quality-budgets.json
index b812b6b..9b7f93d 100644
--- a/quality/code-quality-budgets.json
+++ b/quality/code-quality-budgets.json
@@ -141,10 +141,6 @@
"maxFunctionLines": 239,
"owner": "message executor hotspot"
},
- "src/message-runtime-handoffs.ts": {
- "maxFunctionLines": 207,
- "owner": "message runtime handoffs hotspot"
- },
"src/message-runtime-queue.test.ts": {
"maxFunctionLines": 366,
"owner": "message runtime queue test hotspot"
diff --git a/src/message-runtime-handoffs.ts b/src/message-runtime-handoffs.ts
index 20784bd..7d81156 100644
--- a/src/message-runtime-handoffs.ts
+++ b/src/message-runtime-handoffs.ts
@@ -19,7 +19,12 @@ import {
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import type { ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import type { ExecuteTurnFn } from './message-runtime-types.js';
-import type { Channel, PairedTask, RegisteredGroup } from './types.js';
+import type {
+ Channel,
+ PairedRoomRole,
+ PairedTask,
+ RegisteredGroup,
+} from './types.js';
export function enqueuePendingHandoffs(args: {
enqueueTask: (
@@ -158,6 +163,61 @@ function failClaimedHandoff(args: {
requeueFailedClaimedPairedTurn(args);
}
+function resolveHandoffDeliveryChannel(args: {
+ handoff: ServiceHandoff;
+ handoffRole: PairedRoomRole;
+ fallbackChannel: Channel;
+ channels: Channel[];
+ getPairedTaskById?:
+ | ((
+ id: string,
+ ) =>
+ | Pick
+ | undefined)
+ | undefined;
+ enqueueMessageCheck?: ((chatJid: string) => void) | undefined;
+}): Channel | undefined {
+ const { handoff, handoffRole } = args;
+ if (handoffRole === 'owner') {
+ return args.fallbackChannel;
+ }
+
+ const roleChannel = findChannelByName(
+ args.channels,
+ getFixedRoleChannelName(handoffRole),
+ );
+ if (!roleChannel) {
+ failClaimedHandoff({
+ handoff,
+ error: getMissingRoleChannelMessage(handoffRole),
+ getPairedTaskById: args.getPairedTaskById,
+ enqueueMessageCheck: args.enqueueMessageCheck,
+ });
+ return undefined;
+ }
+ return roleChannel;
+}
+
+function buildHandoffPairedTurnIdentity(
+ handoff: ServiceHandoff,
+ handoffRole: PairedRoomRole,
+) {
+ if (
+ !handoff.paired_task_id ||
+ !handoff.paired_task_updated_at ||
+ !handoff.turn_intent_kind
+ ) {
+ return undefined;
+ }
+ return buildPairedTurnIdentity({
+ taskId: handoff.paired_task_id,
+ taskUpdatedAt: handoff.paired_task_updated_at,
+ intentKind: handoff.turn_intent_kind,
+ role: handoff.turn_role ?? handoffRole,
+ turnId: handoff.turn_id,
+ });
+}
+
export async function processClaimedHandoff(args: {
handoff: ServiceHandoff;
getRoomBindings: () => Record;
@@ -239,52 +299,23 @@ export async function processClaimedHandoff(args: {
return;
}
- let handoffChannel = channel;
- if (handoffRole === 'reviewer') {
- const reviewerChannel = findChannelByName(
- args.channels,
- getFixedRoleChannelName('reviewer'),
- );
- if (!reviewerChannel) {
- failClaimedHandoff({
- handoff,
- error: getMissingRoleChannelMessage('reviewer'),
- getPairedTaskById: args.getPairedTaskById,
- enqueueMessageCheck: args.enqueueMessageCheck,
- });
- return;
- }
- handoffChannel = reviewerChannel;
- } else if (handoffRole === 'arbiter') {
- const arbiterChannel = findChannelByName(
- args.channels,
- getFixedRoleChannelName('arbiter'),
- );
- if (!arbiterChannel) {
- failClaimedHandoff({
- handoff,
- error: getMissingRoleChannelMessage('arbiter'),
- getPairedTaskById: args.getPairedTaskById,
- enqueueMessageCheck: args.enqueueMessageCheck,
- });
- return;
- }
- handoffChannel = arbiterChannel;
+ const handoffChannel = resolveHandoffDeliveryChannel({
+ handoff,
+ handoffRole,
+ fallbackChannel: channel,
+ channels: args.channels,
+ getPairedTaskById: args.getPairedTaskById,
+ enqueueMessageCheck: args.enqueueMessageCheck,
+ });
+ if (!handoffChannel) {
+ return;
}
const runId = `handoff-${handoff.id}`;
- const pairedTurnIdentity =
- handoff.paired_task_id &&
- handoff.paired_task_updated_at &&
- handoff.turn_intent_kind
- ? buildPairedTurnIdentity({
- taskId: handoff.paired_task_id,
- taskUpdatedAt: handoff.paired_task_updated_at,
- intentKind: handoff.turn_intent_kind,
- role: handoff.turn_role ?? handoffRole,
- turnId: handoff.turn_id,
- })
- : undefined;
+ const pairedTurnIdentity = buildHandoffPairedTurnIdentity(
+ handoff,
+ handoffRole,
+ );
try {
logger.info(
{
diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts
index 35e1cd2..1b2e826 100644
--- a/src/web-dashboard-data.test.ts
+++ b/src/web-dashboard-data.test.ts
@@ -236,7 +236,9 @@ describe('web dashboard data', () => {
);
expect(sanitized.promptPreview).not.toContain('plain-secret-value');
});
+});
+describe('web dashboard room activity data', () => {
it('builds redacted room activity from messages and paired turn output', () => {
const longMessageTail = 'TAIL_MESSAGE_VISIBLE';
const longOutputTail = 'TAIL_OUTPUT_VISIBLE';
@@ -354,7 +356,7 @@ describe('web dashboard data', () => {
]);
});
- it('uses Discord live progress messages without exposing them as recent chat', () => {
+ it('uses canonical turn progress without exposing status messages as recent chat', () => {
const task = makePairedTask({
id: 'paired-room-progress',
chat_jid: 'dc:ops',
@@ -375,6 +377,8 @@ describe('web dashboard data', () => {
updated_at: '2026-04-26T06:10:00.000Z',
completed_at: null,
last_error: null,
+ progress_text: 'building mobile parity',
+ progress_updated_at: '2026-04-26T06:07:00.000Z',
};
const statusPrefix = '';
const messages: NewMessage[] = [
@@ -389,25 +393,12 @@ describe('web dashboard data', () => {
is_bot_message: false,
message_source_kind: 'human',
},
- ];
- const progressMessages: NewMessage[] = [
{
- id: 'msg-stale-progress',
+ id: 'msg-status-progress',
chat_jid: 'dc:ops',
sender: 'bot-1',
sender_name: '오너',
- content: `${statusPrefix}stale progress`,
- timestamp: '2026-04-26T05:59:00.000Z',
- is_from_me: true,
- is_bot_message: true,
- message_source_kind: 'bot',
- },
- {
- id: 'msg-live-progress',
- chat_jid: 'dc:ops',
- sender: 'bot-1',
- sender_name: '오너',
- content: `${statusPrefix}building mobile parity\n\n12s`,
+ content: `${statusPrefix}status noise that belongs to display only\n\n12s`,
timestamp: '2026-04-26T06:08:00.000Z',
is_from_me: true,
is_bot_message: true,
@@ -432,12 +423,11 @@ describe('web dashboard data', () => {
attempts: [],
outputs: [],
messages,
- progressMessages,
});
expect(activity.pairedTask?.currentTurn).toMatchObject({
progressText: 'building mobile parity',
- progressUpdatedAt: '2026-04-26T06:08:00.000Z',
+ progressUpdatedAt: '2026-04-26T06:07:00.000Z',
});
expect(activity.messages).toEqual([
expect.objectContaining({
@@ -447,6 +437,58 @@ describe('web dashboard data', () => {
]);
});
+ it('does not show active turn placeholders when canonical progress is absent', () => {
+ const task = makePairedTask({
+ id: 'paired-progress-internal',
+ chat_jid: 'dc:ops',
+ status: 'active',
+ updated_at: '2026-04-26T06:10:00.000Z',
+ });
+ const turn: PairedTurnRecord = {
+ turn_id: 'turn-progress-internal',
+ task_id: task.id,
+ task_updated_at: task.updated_at,
+ role: 'owner',
+ intent_kind: 'owner-turn',
+ state: 'running',
+ executor_service_id: 'codex-main',
+ executor_agent_type: 'codex',
+ attempt_no: 1,
+ created_at: '2026-04-26T06:00:00.000Z',
+ updated_at: '2026-04-26T06:10:00.000Z',
+ completed_at: null,
+ last_error: null,
+ progress_text: null,
+ progress_updated_at: null,
+ };
+
+ const activity = buildWebDashboardRoomActivity({
+ serviceId: 'codex-main',
+ entry: {
+ jid: 'dc:ops',
+ name: '#ops',
+ folder: 'ops',
+ agentType: 'codex',
+ status: 'processing',
+ elapsedMs: 20_000,
+ pendingMessages: false,
+ pendingTasks: 0,
+ },
+ pairedTask: task,
+ turns: [turn],
+ attempts: [],
+ outputs: [],
+ messages: [],
+ });
+
+ expect(activity.pairedTask?.currentTurn).toMatchObject({
+ progressText: null,
+ progressUpdatedAt: null,
+ });
+ });
+});
+
+describe('web dashboard inbox data', () => {
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => {
const snapshots: StatusSnapshot[] = [
{
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
index 71f5e45..f4ff650 100644
--- a/src/web-dashboard-data.ts
+++ b/src/web-dashboard-data.ts
@@ -492,73 +492,26 @@ function sanitizeRoomMessage(
};
}
-/**
- * SSOT for live progress: scan the `messages` table for the most recent
- * TASK_STATUS_MESSAGE_PREFIX-prefixed message that belongs to the current
- * turn (sender role match + timestamp >= turn.created_at). The Discord
- * bridge ingests every edit of the bot's live message back into messages,
- * so this single source replaces the legacy paired_turns.progress_text
- * column for derivation.
- */
const TASK_STATUS_PREFIX = '';
function isTaskStatusMessage(message: NewMessage): boolean {
return (message.content ?? '').startsWith(TASK_STATUS_PREFIX);
}
-function turnRoleFromSenderName(name: string | null | undefined): string {
- const v = (name ?? '').toLowerCase();
- if (v.includes('오너') || v.includes('owner')) return 'owner';
- if (v.includes('리뷰어') || v.includes('reviewer')) return 'reviewer';
- if (v.includes('중재자') || v.includes('arbiter')) return 'arbiter';
- return 'human';
-}
-
-function deriveLiveProgress(
- turnRole: string,
- turnCreatedAt: string,
- messages: NewMessage[],
-): { progressText: string | null; progressUpdatedAt: string | null } {
- const startMs = new Date(turnCreatedAt).getTime();
- const targetRole = turnRoleFromSenderName(turnRole);
- for (let i = messages.length - 1; i >= 0; i--) {
- const m = messages[i];
- const content = m.content ?? '';
- if (!content.startsWith(TASK_STATUS_PREFIX)) continue;
- const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0;
- if (Number.isFinite(startMs) && ts < startMs) continue;
- const role = turnRoleFromSenderName(m.sender_name);
- if (role !== targetRole) continue;
- const body = content.slice(TASK_STATUS_PREFIX.length);
- const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
- if (!stripped) continue;
- return { progressText: stripped, progressUpdatedAt: m.timestamp };
- }
- return { progressText: null, progressUpdatedAt: null };
-}
-
function sanitizeRoomTurn(
turn: PairedTurnRecord,
attempt: PairedTurnAttemptRecord | null,
- messages: NewMessage[] = [],
): WebDashboardRoomTurn {
const role = attempt?.role ?? turn.role;
const createdAt = attempt?.created_at ?? turn.created_at;
const completedAt = attempt?.completed_at ?? turn.completed_at;
-
- // Read paired_turns.progress_text first (written by recordTurnProgress
- // when the controller is in the loop). If that's empty AND the turn is
- // active, fall back to scanning the messages table for the most recent
- // TASK_STATUS-prefixed message — same content the Discord bridge ingests.
- let progressText = turn.progress_text ?? null;
- let progressUpdatedAt = turn.progress_updated_at ?? null;
- if ((!progressText || !progressText.trim()) && completedAt === null) {
- const live = deriveLiveProgress(role, createdAt, messages);
- if (live.progressText) {
- progressText = live.progressText;
- progressUpdatedAt = live.progressUpdatedAt;
- }
- }
+ const canonicalProgress =
+ completedAt === null && turn.progress_text?.trim()
+ ? {
+ progressText: buildRoomBody(turn.progress_text),
+ progressUpdatedAt: turn.progress_updated_at ?? turn.updated_at,
+ }
+ : { progressText: null, progressUpdatedAt: null };
return {
turnId: turn.turn_id,
@@ -576,8 +529,8 @@ function sanitizeRoomTurn(
(attempt?.last_error ?? turn.last_error)
? buildRoomBody(attempt?.last_error ?? turn.last_error ?? '')
: null,
- progressText,
- progressUpdatedAt,
+ progressText: canonicalProgress.progressText,
+ progressUpdatedAt: canonicalProgress.progressUpdatedAt,
};
}
@@ -606,7 +559,6 @@ export function buildWebDashboardRoomActivity(args: {
attempts: PairedTurnAttemptRecord[];
outputs: PairedTurnOutput[];
messages: NewMessage[];
- progressMessages?: NewMessage[];
outputLimit?: number;
}): WebDashboardRoomActivity {
const latestAttemptByTurnId = new Map();
@@ -649,11 +601,7 @@ export function buildWebDashboardRoomActivity(args: {
roundTripCount: args.pairedTask.round_trip_count,
updatedAt: args.pairedTask.updated_at,
currentTurn: currentTurn
- ? sanitizeRoomTurn(
- currentTurn,
- currentAttempt,
- args.progressMessages ?? args.messages,
- )
+ ? sanitizeRoomTurn(currentTurn, currentAttempt)
: null,
outputs: args.outputs
.slice(-outputLimit)
diff --git a/src/web-dashboard-room-routes.test.ts b/src/web-dashboard-room-routes.test.ts
index 7066c71..f74b60c 100644
--- a/src/web-dashboard-room-routes.test.ts
+++ b/src/web-dashboard-room-routes.test.ts
@@ -106,6 +106,8 @@ describe('web dashboard room routes', () => {
updated_at: '2026-04-26T05:21:00.000Z',
completed_at: null,
last_error: null,
+ progress_text: 'checking current output',
+ progress_updated_at: '2026-04-26T05:20:00.000Z',
},
];
const attempts: PairedTurnAttemptRecord[] = [
@@ -154,20 +156,6 @@ describe('web dashboard room routes', () => {
message_source_kind: 'human',
},
];
- const progressMessages: NewMessage[] = [
- ...messages,
- {
- id: 'msg-progress',
- chat_jid: 'dc:ops',
- sender: 'reviewer-bot',
- sender_name: '리뷰어',
- content: 'checking current output\n\n9s',
- timestamp: '2026-04-26T05:20:00.000Z',
- is_from_me: true,
- is_bot_message: true,
- message_source_kind: 'bot',
- },
- ];
const requestedMessageLimits: number[] = [];
const response = handleRoomTimelineRoute({
url: new URL(
@@ -182,7 +170,7 @@ describe('web dashboard room routes', () => {
loadPairedTurnOutputs: () => outputs,
loadRecentChatMessages: (_jid, limit) => {
requestedMessageLimits.push(limit ?? 20);
- return limit && limit > 8 ? progressMessages : messages;
+ return messages;
},
}),
});
@@ -202,7 +190,7 @@ describe('web dashboard room routes', () => {
expect(body.pairedTask.outputs).toMatchObject([
{ outputText: 'owner final output' },
]);
- expect(requestedMessageLimits).toEqual([8, 40]);
+ expect(requestedMessageLimits).toEqual([8]);
expect(body.messages[0]).toMatchObject({
content: expect.stringContaining('BOT_TOKEN='),
senderName: '눈쟁이',
diff --git a/src/web-dashboard-room-routes.ts b/src/web-dashboard-room-routes.ts
index 8e98b47..f0b7027 100644
--- a/src/web-dashboard-room-routes.ts
+++ b/src/web-dashboard-room-routes.ts
@@ -98,7 +98,6 @@ function buildRoomActivity(
attempts,
outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [],
messages: deps.loadRecentChatMessages(entry.jid, 8),
- progressMessages: deps.loadRecentChatMessages(entry.jid, 40),
});
}
@@ -129,9 +128,6 @@ export function buildRoomsTimelineResult(
for (const [jid, { snapshot, entry }] of uniqueByJid) {
const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null;
const messages = deps.loadRecentChatMessages(jid, 8);
- const progressMessages = pairedTask
- ? deps.loadRecentChatMessages(jid, 40)
- : messages;
const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8);
if (!pairedTask && messages.length === 0 && outputs.length === 0) continue;
@@ -147,7 +143,6 @@ export function buildRoomsTimelineResult(
attempts: [],
outputs,
messages,
- progressMessages,
outputLimit: 8,
});
}
diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts
index b1ff908..a882465 100644
--- a/src/web-dashboard-server.test.ts
+++ b/src/web-dashboard-server.test.ts
@@ -337,7 +337,7 @@ describe('web dashboard server handler', () => {
id: 'paired-room-1',
roundTripCount: 2,
});
- expect(requestedMessageLimits).toEqual([8, 40]);
+ expect(requestedMessageLimits).toEqual([8]);
expect(body.messages).toMatchObject([
{ content: '진행 어디까지야?', senderName: '눈쟁이' },
]);