Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

This commit is contained in:
ejclaw
2026-04-23 13:06:26 +09:00
parent 3b0875e80d
commit afd9c7a538
18 changed files with 574 additions and 31 deletions

View File

@@ -34,6 +34,7 @@ You may receive reference opinions from external models appended to your prompt.
## Rules ## Rules
- Base your verdict on evidence (code, test output, logs), not on who said what first - Base your verdict on evidence (code, test output, logs), not on who said what first
- When reading owner/reviewer summaries, treat **TASK_DONE** as full task completion, **STEP_DONE** as intermediate progress that should keep the owner flow alive, and **DONE** as a legacy alias for **TASK_DONE**
- Distinguish reviewer snapshot limits from real product bugs. Reviewer workspaces may intentionally omit heavy artifacts like `node_modules`, `dist`, and `build`; inability to run direct local test/typecheck/build/lint there is not, by itself, a blocker if dedicated verification evidence exists - Distinguish reviewer snapshot limits from real product bugs. Reviewer workspaces may intentionally omit heavy artifacts like `node_modules`, `dist`, and `build`; inability to run direct local test/typecheck/build/lint there is not, by itself, a blocker if dedicated verification evidence exists
- When verification evidence exists from the dedicated verification path, judge that evidence on its merits instead of requiring the reviewer to reproduce the same result from the lightweight reviewer snapshot - When verification evidence exists from the dedicated verification path, judge that evidence on its merits instead of requiring the reviewer to reproduce the same result from the lightweight reviewer snapshot
- Your verdict is final for this deadlock cycle — after it, work resumes normally - Your verdict is final for this deadlock cycle — after it, work resumes normally

View File

@@ -22,9 +22,11 @@ If you see a materially better design, debugging path, or scoping choice, propos
## Completion status ## Completion status
**Start your first line** with one of these four statuses. This is required. **Start your first line** with one of these six statuses. This is required.
- **DONE** — Approved. The owner's response is correct and complete. Include the evidence - **STEP_DONE** — The current step is acceptable, but the original requested task still has remaining work. Send the task back to the owner without escalating to the arbiter
- **TASK_DONE** — Approved. The owner's work satisfies the full requested task. Include the evidence
- **DONE** — Legacy alias for **TASK_DONE**. Prefer **TASK_DONE** for new turns
- **DONE_WITH_CONCERNS** — Approved with concerns. List specific actions the owner must take. If the same concerns repeat for 2+ turns, escalate to BLOCKED - **DONE_WITH_CONCERNS** — Approved with concerns. List specific actions the owner must take. If the same concerns repeat for 2+ turns, escalate to BLOCKED
- **BLOCKED** — Cannot proceed without user decision - **BLOCKED** — Cannot proceed without user decision
- **NEEDS_CONTEXT** — Missing information from user - **NEEDS_CONTEXT** — Missing information from user

View File

@@ -18,16 +18,19 @@ Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, sc
## Completion status ## Completion status
**Start your first line** with one of these four statuses. This is required. **Start your first line** with one of these six statuses. This is required.
- **DONE** — All steps completed. Include the evidence (test output, build log, diff) - **STEP_DONE** — A meaningful intermediate step is complete, but the original task still has remaining work. This keeps the task active and continues the owner flow without reviewer or arbiter intervention
- **TASK_DONE** — The original requested task is complete. Include the evidence (test output, build log, diff)
- **DONE** — Legacy alias for **TASK_DONE**. Prefer **TASK_DONE** for new turns
- **DONE_WITH_CONCERNS** — Completed, but there are issues worth flagging. If the reviewer raises the same concerns again, fix them or escalate to BLOCKED - **DONE_WITH_CONCERNS** — Completed, but there are issues worth flagging. If the reviewer raises the same concerns again, fix them or escalate to BLOCKED
- **BLOCKED** — Cannot proceed. State what is stopping you - **BLOCKED** — Cannot proceed. State what is stopping you
- **NEEDS_CONTEXT** — Missing information needed to continue - **NEEDS_CONTEXT** — Missing information needed to continue
### Finalize semantics ### Finalize semantics
- When the reviewer already approved and you are finalizing, **DONE** closes the paired turn - When the reviewer already approved and you are finalizing, **TASK_DONE** closes the paired turn
- In that same finalize step, **STEP_DONE** keeps the task active and resumes the owner flow because the original request still has remaining work
- In that same finalize step, **DONE_WITH_CONCERNS** does not close the turn — it intentionally reopens review - In that same finalize step, **DONE_WITH_CONCERNS** does not close the turn — it intentionally reopens review
- Use **DONE_WITH_CONCERNS** on finalize only when you are explicitly asking the reviewer loop to resume - Use **DONE_WITH_CONCERNS** on finalize only when you are explicitly asking the reviewer loop to resume

View File

@@ -14,6 +14,7 @@ import {
completePairedExecutionContext, completePairedExecutionContext,
type PreparedPairedExecutionContext, type PreparedPairedExecutionContext,
} from './paired-execution-context.js'; } from './paired-execution-context.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js'; import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js'; import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
import type { PairedTurnIdentity } from './paired-turn-identity.js'; import type { PairedTurnIdentity } from './paired-turn-identity.js';
@@ -438,6 +439,7 @@ export function createPairedExecutionLifecycle(args: {
executionStatus: effectiveStatus, executionStatus: effectiveStatus,
sawOutput: sawOutputForFollowUp, sawOutput: sawOutputForFollowUp,
taskStatus: finishedTask?.status ?? null, taskStatus: finishedTask?.status ?? null,
outputSummary: pairedExecutionSummary,
}); });
if (queueAction !== 'pending' || !finishedTask) { if (queueAction !== 'pending' || !finishedTask) {
return; return;
@@ -452,6 +454,10 @@ export function createPairedExecutionLifecycle(args: {
executionStatus: effectiveStatus, executionStatus: effectiveStatus,
sawOutput: sawOutputForFollowUp, sawOutput: sawOutputForFollowUp,
fallbackLastTurnOutputRole: sawOutputForFollowUp ? completedRole : null, fallbackLastTurnOutputRole: sawOutputForFollowUp ? completedRole : null,
fallbackLastTurnOutputVerdict:
sawOutputForFollowUp && pairedExecutionSummary
? parseVisibleVerdict(pairedExecutionSummary)
: null,
enqueueMessageCheck, enqueueMessageCheck,
}); });
if (followUpResult.kind !== 'paired-follow-up') { if (followUpResult.kind !== 'paired-follow-up') {

View File

@@ -216,6 +216,18 @@ describe('message-agent-executor-rules', () => {
).toBe('none'); ).toBe('none');
}); });
it('does not request a duplicate executor-side follow-up after successful owner STEP_DONE output', () => {
expect(
resolvePairedFollowUpQueueAction({
completedRole: 'owner',
executionStatus: 'succeeded',
sawOutput: true,
taskStatus: 'active',
outputSummary: 'STEP_DONE\nkeep going',
}),
).toBe('none');
});
it('returns none after successful output when no next-turn action is needed', () => { it('returns none after successful output when no next-turn action is needed', () => {
expect( expect(
resolvePairedFollowUpQueueAction({ resolvePairedFollowUpQueueAction({

View File

@@ -2,6 +2,7 @@ import {
resolveFollowUpDispatch, resolveFollowUpDispatch,
resolveNextTurnAction, resolveNextTurnAction,
} from './message-runtime-rules.js'; } from './message-runtime-rules.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
import type { PairedRoomRole, PairedTaskStatus } from './types.js'; import type { PairedRoomRole, PairedTaskStatus } from './types.js';
export { export {
isRetryableClaudeSessionFailureAttempt, isRetryableClaudeSessionFailureAttempt,
@@ -20,10 +21,15 @@ export function resolvePairedFollowUpQueueAction(args: {
executionStatus: 'succeeded' | 'failed'; executionStatus: 'succeeded' | 'failed';
sawOutput: boolean; sawOutput: boolean;
taskStatus: PairedTaskStatus | null; taskStatus: PairedTaskStatus | null;
outputSummary?: string | null;
}): PairedFollowUpQueueAction { }): PairedFollowUpQueueAction {
const nextTurnAction = resolveNextTurnAction({ const nextTurnAction = resolveNextTurnAction({
taskStatus: args.taskStatus, taskStatus: args.taskStatus,
lastTurnOutputRole: args.sawOutput ? args.completedRole : null, lastTurnOutputRole: args.sawOutput ? args.completedRole : null,
lastTurnOutputVerdict:
args.sawOutput && args.outputSummary
? parseVisibleVerdict(args.outputSummary)
: null,
}); });
const dispatch = resolveFollowUpDispatch({ const dispatch = resolveFollowUpDispatch({
source: 'executor-recovery', source: 'executor-recovery',

View File

@@ -29,6 +29,7 @@ import {
type ScheduledPairedFollowUpIntentKind, type ScheduledPairedFollowUpIntentKind,
} from './paired-follow-up-scheduler.js'; } from './paired-follow-up-scheduler.js';
import { hasReviewerLease } from './service-routing.js'; import { hasReviewerLease } from './service-routing.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
import type { import type {
Channel, Channel,
NewMessage, NewMessage,
@@ -127,6 +128,9 @@ export function buildPendingPairedTurn(args: {
const nextTurnAction = resolveNextTurnAction({ const nextTurnAction = resolveNextTurnAction({
taskStatus, taskStatus,
lastTurnOutputRole: lastTurnOutput?.role ?? null, lastTurnOutputRole: lastTurnOutput?.role ?? null,
lastTurnOutputVerdict: lastTurnOutput?.output_text
? parseVisibleVerdict(lastTurnOutput.output_text)
: null,
}); });
const recentMessages = getRecentChatMessages(chatJid, 20); const recentMessages = getRecentChatMessages(chatJid, 20);
const lastHumanMessage = getLastHumanMessageContent(chatJid); const lastHumanMessage = getLastHumanMessageContent(chatJid);

View File

@@ -83,6 +83,35 @@ describe('message-runtime-follow-up', () => {
expect(enqueue).toHaveBeenCalledTimes(1); expect(enqueue).toHaveBeenCalledTimes(1);
}); });
it('uses the fallback STEP_DONE verdict to schedule owner follow-ups when the owner keeps the task active', () => {
const enqueue = vi.fn();
const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test',
runId: 'run-owner-step-done',
task: {
id: 'task-owner-step-done',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'owner-delivery-success',
completedRole: 'owner',
fallbackLastTurnOutputRole: 'owner',
fallbackLastTurnOutputVerdict: 'step_done',
enqueue,
});
expect(result).toMatchObject({
kind: 'paired-follow-up',
intentKind: 'owner-follow-up',
scheduled: true,
taskStatus: 'active',
lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done',
});
expect(enqueue).toHaveBeenCalledTimes(1);
});
it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => { it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => {
const enqueue = vi.fn(); const enqueue = vi.fn();
vi.mocked(getPairedTurnOutputs).mockReturnValue([ vi.mocked(getPairedTurnOutputs).mockReturnValue([

View File

@@ -1,4 +1,8 @@
import { getPairedTurnOutputs } from './db.js'; import { getPairedTurnOutputs } from './db.js';
import {
parseVisibleVerdict,
type VisibleVerdict,
} from './paired-execution-context-shared.js';
import { import {
matchesExpectedPairedFollowUpIntent, matchesExpectedPairedFollowUpIntent,
resolveFollowUpDispatch, resolveFollowUpDispatch,
@@ -20,6 +24,7 @@ export interface PairedFollowUpDecision {
taskId: string | null; taskId: string | null;
taskStatus: PairedTaskStatus | null; taskStatus: PairedTaskStatus | null;
lastTurnOutputRole: PairedRoomRole | null; lastTurnOutputRole: PairedRoomRole | null;
lastTurnOutputVerdict: VisibleVerdict | null;
nextTurnAction: NextTurnAction; nextTurnAction: NextTurnAction;
dispatch: FollowUpDispatch; dispatch: FollowUpDispatch;
} }
@@ -37,15 +42,23 @@ export type PairedFollowUpDispatchResult =
scheduled: boolean; scheduled: boolean;
}); });
export function resolveLatestPairedTurnOutputRole(args: { export function resolveLatestPairedTurnOutputContext(args: {
task: Pick<PairedTask, 'id'> | null | undefined; task: Pick<PairedTask, 'id'> | null | undefined;
fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputRole?: PairedRoomRole | null;
}): PairedRoomRole | null { fallbackLastTurnOutputVerdict?: VisibleVerdict | null;
return ( }): {
(args.task ? getPairedTurnOutputs(args.task.id).at(-1)?.role : null) ?? role: PairedRoomRole | null;
args.fallbackLastTurnOutputRole ?? verdict: VisibleVerdict | null;
null } {
); const latestOutput = args.task
? getPairedTurnOutputs(args.task.id).at(-1)
: null;
return {
role: latestOutput?.role ?? args.fallbackLastTurnOutputRole ?? null,
verdict: latestOutput?.output_text
? parseVisibleVerdict(latestOutput.output_text)
: (args.fallbackLastTurnOutputVerdict ?? null),
};
} }
export function resolvePairedFollowUpDecision(args: { export function resolvePairedFollowUpDecision(args: {
@@ -55,14 +68,18 @@ export function resolvePairedFollowUpDecision(args: {
executionStatus?: 'succeeded' | 'failed'; executionStatus?: 'succeeded' | 'failed';
sawOutput?: boolean; sawOutput?: boolean;
fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputRole?: PairedRoomRole | null;
fallbackLastTurnOutputVerdict?: VisibleVerdict | null;
}): PairedFollowUpDecision { }): PairedFollowUpDecision {
const lastTurnOutputRole = resolveLatestPairedTurnOutputRole({ const { role: lastTurnOutputRole, verdict: lastTurnOutputVerdict } =
task: args.task, resolveLatestPairedTurnOutputContext({
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, task: args.task,
}); fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole,
fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict,
});
const nextTurnAction = resolveNextTurnAction({ const nextTurnAction = resolveNextTurnAction({
taskStatus: args.task?.status ?? null, taskStatus: args.task?.status ?? null,
lastTurnOutputRole, lastTurnOutputRole,
lastTurnOutputVerdict,
}); });
const dispatch = resolveFollowUpDispatch({ const dispatch = resolveFollowUpDispatch({
source: args.source, source: args.source,
@@ -76,6 +93,7 @@ export function resolvePairedFollowUpDecision(args: {
taskId: args.task?.id ?? null, taskId: args.task?.id ?? null,
taskStatus: args.task?.status ?? null, taskStatus: args.task?.status ?? null,
lastTurnOutputRole, lastTurnOutputRole,
lastTurnOutputVerdict,
nextTurnAction, nextTurnAction,
dispatch, dispatch,
}; };
@@ -91,23 +109,31 @@ export function schedulePairedFollowUpIntent(args: {
intentKind: ScheduledPairedFollowUpIntentKind; intentKind: ScheduledPairedFollowUpIntentKind;
enqueue: () => void; enqueue: () => void;
fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputRole?: PairedRoomRole | null;
fallbackLastTurnOutputVerdict?: VisibleVerdict | null;
lastTurnOutputRole?: PairedRoomRole | null; lastTurnOutputRole?: PairedRoomRole | null;
lastTurnOutputVerdict?: VisibleVerdict | null;
}): boolean { }): boolean {
if (!args.task) { if (!args.task) {
return false; return false;
} }
const lastTurnOutputRole = const latestOutputContext =
args.lastTurnOutputRole ?? args.lastTurnOutputRole != null || args.lastTurnOutputVerdict != null
resolveLatestPairedTurnOutputRole({ ? {
task: args.task, role: args.lastTurnOutputRole ?? null,
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, verdict: args.lastTurnOutputVerdict ?? null,
}); }
: resolveLatestPairedTurnOutputContext({
task: args.task,
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole,
fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict,
});
if ( if (
!matchesExpectedPairedFollowUpIntent({ !matchesExpectedPairedFollowUpIntent({
taskStatus: args.task.status, taskStatus: args.task.status,
lastTurnOutputRole, lastTurnOutputRole: latestOutputContext.role,
lastTurnOutputVerdict: latestOutputContext.verdict,
intentKind: args.intentKind, intentKind: args.intentKind,
}) })
) { ) {
@@ -133,7 +159,9 @@ export function schedulePairedFollowUpWithMessageCheck(args: {
intentKind: ScheduledPairedFollowUpIntentKind; intentKind: ScheduledPairedFollowUpIntentKind;
enqueueMessageCheck: () => void; enqueueMessageCheck: () => void;
fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputRole?: PairedRoomRole | null;
fallbackLastTurnOutputVerdict?: VisibleVerdict | null;
lastTurnOutputRole?: PairedRoomRole | null; lastTurnOutputRole?: PairedRoomRole | null;
lastTurnOutputVerdict?: VisibleVerdict | null;
}): boolean { }): boolean {
return schedulePairedFollowUpIntent({ return schedulePairedFollowUpIntent({
chatJid: args.chatJid, chatJid: args.chatJid,
@@ -142,7 +170,9 @@ export function schedulePairedFollowUpWithMessageCheck(args: {
intentKind: args.intentKind, intentKind: args.intentKind,
enqueue: args.enqueueMessageCheck, enqueue: args.enqueueMessageCheck,
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole,
fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict,
lastTurnOutputRole: args.lastTurnOutputRole, lastTurnOutputRole: args.lastTurnOutputRole,
lastTurnOutputVerdict: args.lastTurnOutputVerdict,
}); });
} }
@@ -158,6 +188,7 @@ export function dispatchPairedFollowUpForEvent(args: {
executionStatus?: 'succeeded' | 'failed'; executionStatus?: 'succeeded' | 'failed';
sawOutput?: boolean; sawOutput?: boolean;
fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputRole?: PairedRoomRole | null;
fallbackLastTurnOutputVerdict?: VisibleVerdict | null;
enqueue: () => void; enqueue: () => void;
enqueueMessageCheck?: () => void; enqueueMessageCheck?: () => void;
}): PairedFollowUpDispatchResult { }): PairedFollowUpDispatchResult {
@@ -168,6 +199,7 @@ export function dispatchPairedFollowUpForEvent(args: {
executionStatus: args.executionStatus, executionStatus: args.executionStatus,
sawOutput: args.sawOutput, sawOutput: args.sawOutput,
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole,
fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict,
}); });
if ( if (
@@ -193,6 +225,7 @@ export function dispatchPairedFollowUpForEvent(args: {
intentKind: decision.nextTurnAction.kind, intentKind: decision.nextTurnAction.kind,
enqueue: args.enqueue, enqueue: args.enqueue,
lastTurnOutputRole: decision.lastTurnOutputRole, lastTurnOutputRole: decision.lastTurnOutputRole,
lastTurnOutputVerdict: decision.lastTurnOutputVerdict,
}); });
return { return {
@@ -221,6 +254,7 @@ export function enqueuePairedFollowUpAfterEvent(args: {
executionStatus?: 'succeeded' | 'failed'; executionStatus?: 'succeeded' | 'failed';
sawOutput?: boolean; sawOutput?: boolean;
fallbackLastTurnOutputRole?: PairedRoomRole | null; fallbackLastTurnOutputRole?: PairedRoomRole | null;
fallbackLastTurnOutputVerdict?: VisibleVerdict | null;
enqueueMessageCheck: () => void; enqueueMessageCheck: () => void;
}): PairedFollowUpDispatchResult { }): PairedFollowUpDispatchResult {
return dispatchPairedFollowUpForEvent({ return dispatchPairedFollowUpForEvent({
@@ -232,6 +266,7 @@ export function enqueuePairedFollowUpAfterEvent(args: {
executionStatus: args.executionStatus, executionStatus: args.executionStatus,
sawOutput: args.sawOutput, sawOutput: args.sawOutput,
fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole, fallbackLastTurnOutputRole: args.fallbackLastTurnOutputRole,
fallbackLastTurnOutputVerdict: args.fallbackLastTurnOutputVerdict,
enqueue: args.enqueueMessageCheck, enqueue: args.enqueueMessageCheck,
enqueueMessageCheck: args.enqueueMessageCheck, enqueueMessageCheck: args.enqueueMessageCheck,
}); });

View File

@@ -212,7 +212,8 @@ export function buildFinalizePendingPrompt(args: {
? `\n\nReviewer's final assessment:\n${lastReviewerOutput.output_text.slice(0, 2000)}` ? `\n\nReviewer's final assessment:\n${lastReviewerOutput.output_text.slice(0, 2000)}`
: ''; : '';
return `The reviewer approved your work (DONE). Finalize and report the result. return `The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result.
If you intend to close this paired turn now, your first line must be DONE. If you intend to close this paired turn now, your first line must be TASK_DONE.
If the original request still has remaining work and the owner flow should continue, your first line may be STEP_DONE.
If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.${reviewerSummary}`; If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.${reviewerSummary}`;
} }

View File

@@ -11,6 +11,7 @@ import {
isBotOnlyPairedRoomTurn, isBotOnlyPairedRoomTurn,
} from './message-runtime-flow.js'; } from './message-runtime-flow.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js';
import { parseVisibleVerdict } from './paired-execution-context-shared.js';
import { import {
advanceLastAgentCursor, advanceLastAgentCursor,
resolveActiveRole, resolveActiveRole,
@@ -183,6 +184,11 @@ export async function runQueuedGroupTurn(args: {
const lastTurnOutputRole = currentTask const lastTurnOutputRole = currentTask
? (turnOutputs.at(-1)?.role ?? null) ? (turnOutputs.at(-1)?.role ?? null)
: null; : null;
const lastTurnOutputVerdict = currentTask
? turnOutputs.at(-1)?.output_text
? parseVisibleVerdict(turnOutputs.at(-1)?.output_text)
: null
: null;
const turnRole = currentTask const turnRole = currentTask
? hasHumanMsg ? hasHumanMsg
? resolveQueuedTurnRole({ ? resolveQueuedTurnRole({
@@ -193,6 +199,7 @@ export async function runQueuedGroupTurn(args: {
taskStatus, taskStatus,
hasHumanMessage: false, hasHumanMessage: false,
lastTurnOutputRole, lastTurnOutputRole,
lastTurnOutputVerdict,
}) })
: 'owner'; : 'owner';
if (!turnRole) { if (!turnRole) {

View File

@@ -113,6 +113,14 @@ describe('message-runtime-rules', () => {
intentKind: 'owner-follow-up', intentKind: 'owner-follow-up',
}), }),
).toBe(true); ).toBe(true);
expect(
matchesExpectedPairedFollowUpIntent({
taskStatus: 'active',
lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done',
intentKind: 'owner-follow-up',
}),
).toBe(true);
}); });
it('maps active tasks with reviewer output to an owner follow-up', () => { it('maps active tasks with reviewer output to an owner follow-up', () => {
@@ -124,6 +132,16 @@ describe('message-runtime-rules', () => {
).toEqual({ kind: 'owner-follow-up' }); ).toEqual({ kind: 'owner-follow-up' });
}); });
it('maps active tasks with owner STEP_DONE output to an owner follow-up', () => {
expect(
resolveNextTurnAction({
taskStatus: 'active',
lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done',
}),
).toEqual({ kind: 'owner-follow-up' });
});
it('returns none when an active task has no reviewer or arbiter handoff output', () => { it('returns none when an active task has no reviewer or arbiter handoff output', () => {
expect( expect(
resolveNextTurnAction({ resolveNextTurnAction({
@@ -152,6 +170,15 @@ describe('message-runtime-rules', () => {
kind: 'enqueue', kind: 'enqueue',
queueKind: 'paired-follow-up', queueKind: 'paired-follow-up',
}); });
expect(
resolveFollowUpDispatch({
source: 'owner-delivery-success',
nextTurnAction: { kind: 'owner-follow-up' },
}),
).toEqual({
kind: 'enqueue',
queueKind: 'paired-follow-up',
});
expect( expect(
resolveFollowUpDispatch({ resolveFollowUpDispatch({
source: 'owner-delivery-success', source: 'owner-delivery-success',
@@ -295,6 +322,14 @@ describe('message-runtime-rules', () => {
lastTurnOutputRole: 'owner', lastTurnOutputRole: 'owner',
}), }),
).toBe('reviewer'); ).toBe('reviewer');
expect(
resolveQueuedPairedTurnRole({
taskStatus: 'active',
hasHumanMessage: false,
lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done',
}),
).toBe('owner');
}); });
it('resolves reviewer execution target from review_ready task status', () => { it('resolves reviewer execution target from review_ready task status', () => {

View File

@@ -4,6 +4,7 @@ import { normalizeStoredSeqCursor } from './message-cursor.js';
import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js'; import { isTriggerAllowed, loadSenderAllowlist } from './sender-allowlist.js';
import { isTaskStatusControlMessage } from './task-watch-status.js'; import { isTaskStatusControlMessage } from './task-watch-status.js';
import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from './config.js'; import { ARBITER_AGENT_TYPE, REVIEWER_AGENT_TYPE } from './config.js';
import type { VisibleVerdict } from './paired-execution-context-shared.js';
import { import {
hasReviewerLease, hasReviewerLease,
resolveLeaseServiceId, resolveLeaseServiceId,
@@ -79,6 +80,7 @@ export function resolveQueuedPairedTurnRole(args: {
taskStatus?: PairedTaskStatus | null; taskStatus?: PairedTaskStatus | null;
hasHumanMessage: boolean; hasHumanMessage: boolean;
lastTurnOutputRole?: PairedRoomRole | null; lastTurnOutputRole?: PairedRoomRole | null;
lastTurnOutputVerdict?: VisibleVerdict | null;
}): 'owner' | 'reviewer' | 'arbiter' | null { }): 'owner' | 'reviewer' | 'arbiter' | null {
if (args.hasHumanMessage) { if (args.hasHumanMessage) {
return resolveQueuedTurnRole({ return resolveQueuedTurnRole({
@@ -90,6 +92,7 @@ export function resolveQueuedPairedTurnRole(args: {
const nextTurnAction = resolveNextTurnAction({ const nextTurnAction = resolveNextTurnAction({
taskStatus: args.taskStatus, taskStatus: args.taskStatus,
lastTurnOutputRole: args.lastTurnOutputRole, lastTurnOutputRole: args.lastTurnOutputRole,
lastTurnOutputVerdict: args.lastTurnOutputVerdict,
}); });
switch (nextTurnAction.kind) { switch (nextTurnAction.kind) {
@@ -125,6 +128,7 @@ export type FollowUpDispatch =
export function resolveNextTurnAction(args: { export function resolveNextTurnAction(args: {
taskStatus?: PairedTaskStatus | null; taskStatus?: PairedTaskStatus | null;
lastTurnOutputRole?: PairedRoomRole | null; lastTurnOutputRole?: PairedRoomRole | null;
lastTurnOutputVerdict?: VisibleVerdict | null;
}): NextTurnAction { }): NextTurnAction {
switch (args.taskStatus) { switch (args.taskStatus) {
case 'review_ready': case 'review_ready':
@@ -142,7 +146,8 @@ export function resolveNextTurnAction(args: {
? { kind: 'none' } ? { kind: 'none' }
: { kind: 'finalize-owner-turn' }; : { kind: 'finalize-owner-turn' };
case 'active': case 'active':
return args.lastTurnOutputRole === 'reviewer' || return args.lastTurnOutputVerdict === 'step_done' ||
args.lastTurnOutputRole === 'reviewer' ||
args.lastTurnOutputRole === 'arbiter' args.lastTurnOutputRole === 'arbiter'
? { kind: 'owner-follow-up' } ? { kind: 'owner-follow-up' }
: { kind: 'none' }; : { kind: 'none' };
@@ -154,12 +159,14 @@ export function resolveNextTurnAction(args: {
export function matchesExpectedPairedFollowUpIntent(args: { export function matchesExpectedPairedFollowUpIntent(args: {
taskStatus?: PairedTaskStatus | null; taskStatus?: PairedTaskStatus | null;
lastTurnOutputRole?: PairedRoomRole | null; lastTurnOutputRole?: PairedRoomRole | null;
lastTurnOutputVerdict?: VisibleVerdict | null;
intentKind: ScheduledNextTurnActionKind; intentKind: ScheduledNextTurnActionKind;
}): boolean { }): boolean {
return ( return (
resolveNextTurnAction({ resolveNextTurnAction({
taskStatus: args.taskStatus, taskStatus: args.taskStatus,
lastTurnOutputRole: args.lastTurnOutputRole, lastTurnOutputRole: args.lastTurnOutputRole,
lastTurnOutputVerdict: args.lastTurnOutputVerdict,
}).kind === args.intentKind }).kind === args.intentKind
); );
} }
@@ -184,7 +191,8 @@ export function resolveFollowUpDispatch(args: {
args.completedRole === 'owner' args.completedRole === 'owner'
) { ) {
return args.nextTurnAction.kind === 'reviewer-turn' || return args.nextTurnAction.kind === 'reviewer-turn' ||
args.nextTurnAction.kind === 'arbiter-turn' args.nextTurnAction.kind === 'arbiter-turn' ||
args.nextTurnAction.kind === 'owner-follow-up'
? { kind: 'enqueue', queueKind: 'paired-follow-up' } ? { kind: 'enqueue', queueKind: 'paired-follow-up' }
: { kind: 'none' }; : { kind: 'none' };
} }
@@ -219,6 +227,7 @@ export function resolveFollowUpDispatch(args: {
return { kind: 'none' }; return { kind: 'none' };
} }
return args.nextTurnAction.kind === 'reviewer-turn' || return args.nextTurnAction.kind === 'reviewer-turn' ||
args.nextTurnAction.kind === 'owner-follow-up' ||
args.nextTurnAction.kind === 'arbiter-turn' || args.nextTurnAction.kind === 'arbiter-turn' ||
args.nextTurnAction.kind === 'finalize-owner-turn' args.nextTurnAction.kind === 'finalize-owner-turn'
? { kind: 'enqueue', queueKind: 'paired-follow-up' } ? { kind: 'enqueue', queueKind: 'paired-follow-up' }

View File

@@ -1632,8 +1632,9 @@ describe('createMessageRuntime', () => {
vi.mocked(agentRunner.runAgentProcess).mockImplementation( vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => { async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toBe( expect(input.prompt).toBe(
`The reviewer approved your work (DONE). Finalize and report the result. `The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result.
If you intend to close this paired turn now, your first line must be DONE. If you intend to close this paired turn now, your first line must be TASK_DONE.
If the original request still has remaining work and the owner flow should continue, your first line may be STEP_DONE.
If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n${truncatedReviewerOutput}`, If your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n${truncatedReviewerOutput}`,
); );
expect(input.prompt).not.toContain(longReviewerOutput); expect(input.prompt).not.toContain(longReviewerOutput);
@@ -1741,7 +1742,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
vi.mocked(agentRunner.runAgentProcess).mockImplementation( vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, input, _onProcess, onOutput) => { async (_group, input, _onProcess, onOutput) => {
expect(input.prompt).toBe( expect(input.prompt).toBe(
"The reviewer approved your work (DONE). Finalize and report the result.\nIf you intend to close this paired turn now, your first line must be DONE.\nIf your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n리뷰 승인 요약", "The reviewer approved the current task scope (TASK_DONE / legacy DONE). Finalize and report the result.\nIf you intend to close this paired turn now, your first line must be TASK_DONE.\nIf the original request still has remaining work and the owner flow should continue, your first line may be STEP_DONE.\nIf your first line is DONE_WITH_CONCERNS, the system will reopen review instead of finishing.\n\nReviewer's final assessment:\n리뷰 승인 요약",
); );
expect(input.prompt).not.toContain('DONE\n승인합니다.'); expect(input.prompt).not.toContain('DONE\n승인합니다.');
await onOutput?.({ await onOutput?.({
@@ -2004,6 +2005,110 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
expect(pending?.prompt).toContain('이전 reviewer 피드백'); expect(pending?.prompt).toContain('이전 reviewer 피드백');
}); });
it('builds an owner follow-up pending turn when the latest owner output is STEP_DONE on an active task', () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const task = {
id: 'task-owner-step-done-pending',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
round_trip_count: 1,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:10.000Z',
updated_at: '2026-03-30T00:00:10.000Z',
} as any;
vi.mocked(db.getLatestPreviousPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: task.id,
turn_number: 1,
role: 'owner',
output_text: 'STEP_DONE\n1단계는 끝났고 2단계로 이어가야 함',
created_at: '2026-03-30T00:00:11.000Z',
},
] as any);
const pending = buildPendingPairedTurn({
chatJid,
timezone: 'UTC',
task,
rawMissedMessages: [{ seq: 42, timestamp: '2026-03-30T00:00:12.000Z' }],
recentHumanMessages: [],
labeledRecentMessages: [],
resolveChannel: () => makeChannel(chatJid),
});
expect(pending).not.toBeNull();
expect(pending).toMatchObject({
role: 'owner',
intentKind: 'owner-follow-up',
taskId: task.id,
});
});
it('builds a reviewer pending turn when a review_ready task follows owner TASK_DONE output', () => {
const chatJid = 'group@test';
const group = makeGroup('claude-code');
const task = {
id: 'task-owner-task-done-pending',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: '2026-03-30T00:00:10.000Z',
round_trip_count: 1,
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:10.000Z',
updated_at: '2026-03-30T00:00:10.000Z',
} as any;
vi.mocked(db.getLatestPreviousPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: task.id,
turn_number: 1,
role: 'owner',
output_text: 'TASK_DONE\n요청 범위 전체 완료',
created_at: '2026-03-30T00:00:11.000Z',
},
] as any);
const pending = buildPendingPairedTurn({
chatJid,
timezone: 'UTC',
task,
rawMissedMessages: [{ seq: 42, timestamp: '2026-03-30T00:00:12.000Z' }],
recentHumanMessages: [],
labeledRecentMessages: [],
resolveChannel: () => makeChannel(chatJid, 'discord-review', false),
});
expect(pending).not.toBeNull();
expect(pending).toMatchObject({
role: 'reviewer',
intentKind: 'reviewer-turn',
taskId: task.id,
});
});
it('re-enqueues reviewer after a successful owner delivery moves the task to review_ready', async () => { it('re-enqueues reviewer after a successful owner delivery moves the task to review_ready', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');
@@ -2109,6 +2214,133 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
); );
}); });
it('re-enqueues owner follow-up after a successful owner STEP_DONE delivery keeps the task active', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
const ownerChannel: Channel = {
...makeChannel(chatJid),
isOwnMessage: vi.fn((msg) => msg.sender === 'owner-bot@test'),
};
const reviewerChannel = makeChannel(chatJid, 'discord-review', false);
const enqueueMessageCheck = vi.fn();
const pairedTask = {
id: 'task-owner-step-done-follow-up',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
round_trip_count: 1,
owner_failure_count: 2,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-30T00:00:00.000Z',
updated_at: '2026-03-30T00:00:00.000Z',
} as any;
let turnOutputs: any[] = [];
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
vi.mocked(db.getLatestOpenPairedTaskForChat).mockImplementation(
() => pairedTask,
);
vi.mocked(db.getPairedTurnOutputs).mockImplementation((taskId: string) =>
taskId === pairedTask.id ? turnOutputs : [],
);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'human-step-done-1',
chat_jid: chatJid,
sender: 'user@test',
sender_name: 'User',
content: '이 리팩토링 이어서 해줘',
timestamp: '2026-03-30T00:00:00.000Z',
seq: 1,
is_bot_message: false,
} as any,
]);
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
pairedTask.status = 'active';
pairedTask.owner_failure_count = 0;
turnOutputs = [
{
id: 1,
task_id: pairedTask.id,
turn_number: 1,
role: 'owner',
output_text: 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행',
created_at: '2026-03-30T00:00:01.000Z',
},
];
await onOutput?.({
status: 'success',
phase: 'final',
result: 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행',
newSessionId: 'session-owner-step-done-follow-up',
});
return {
status: 'success',
result: 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행',
newSessionId: 'session-owner-step-done-follow-up',
};
},
);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [ownerChannel, reviewerChannel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
enqueueMessageCheck,
} as any,
getRoomBindings: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => ({}),
saveState: vi.fn(),
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-owner-step-done-follow-up',
reason: 'messages',
});
expect(result).toBe(true);
expect(ownerChannel.sendMessage).toHaveBeenCalledWith(
chatJid,
'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행',
);
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid);
expect(pairedTask.status).toBe('active');
expect(pairedTask.round_trip_count).toBe(1);
expect(pairedTask.owner_failure_count).toBe(0);
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
chatJid,
runId: 'run-owner-step-done-follow-up',
completedRole: 'owner',
taskId: 'task-owner-step-done-follow-up',
taskStatus: 'active',
intentKind: 'owner-follow-up',
}),
'Queued paired follow-up after successful owner delivery',
);
});
it('defers reviewer enqueue after owner delivery when a CI watcher is still active', async () => { it('defers reviewer enqueue after owner delivery when a CI watcher is still active', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');

View File

@@ -20,7 +20,7 @@ import {
} from './paired-execution-context-shared.js'; } from './paired-execution-context-shared.js';
import type { PairedTask } from './types.js'; import type { PairedTask } from './types.js';
type OwnerFinalizeOutcome = 'stop' | 're_review'; type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner';
const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; const OWNER_FAILURE_ESCALATION_THRESHOLD = 2;
export function handleFailedOwnerExecution(args: { export function handleFailedOwnerExecution(args: {
@@ -170,6 +170,28 @@ function handleOwnerFinalizeCompletion(args: {
return 're_review'; return 're_review';
} }
if (signal.kind === 'request_owner_continue') {
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus: 'active',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_failure_count: 0,
},
});
logger.info(
{
taskId,
ownerVerdict,
summary: summary?.slice(0, 100),
},
'Owner marked finalize output as an intermediate step — task returned to active without re-review',
);
return 'continue_owner';
}
transitionPairedTaskStatus({ transitionPairedTaskStatus({
taskId, taskId,
currentStatus: task.status, currentStatus: task.status,
@@ -290,6 +312,22 @@ export function handleOwnerCompletion(args: {
return; return;
} }
if (signal.kind === 'request_owner_continue') {
applyPairedTaskPatch({
taskId,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_failure_count: 0,
},
});
logger.info(
{ taskId, ownerVerdict, summary: summary?.slice(0, 100) },
'Owner marked the current output as an intermediate completed step — keeping task active for follow-up',
);
return;
}
maybeAutoTriggerReviewerAfterOwnerCompletion({ maybeAutoTriggerReviewerAfterOwnerCompletion({
task, task,
taskId, taskId,

View File

@@ -9,6 +9,8 @@ import {
describe('paired execution context shared verdict helpers', () => { describe('paired execution context shared verdict helpers', () => {
it('parses visible verdicts from the first summary line only', () => { it('parses visible verdicts from the first summary line only', () => {
expect(parseVisibleVerdict('STEP_DONE\nmore to do')).toBe('step_done');
expect(parseVisibleVerdict('TASK_DONE\nall done')).toBe('task_done');
expect( expect(
parseVisibleVerdict( parseVisibleVerdict(
'DONE_WITH_CONCERNS\n\nfollow-up detail that should not affect parsing', 'DONE_WITH_CONCERNS\n\nfollow-up detail that should not affect parsing',
@@ -19,6 +21,15 @@ describe('paired execution context shared verdict helpers', () => {
}); });
it('maps normal owner completion verdicts to reviewer or arbiter signals', () => { it('maps normal owner completion verdicts to reviewer or arbiter signals', () => {
expect(
resolveOwnerCompletionSignal({
phase: 'normal',
visibleVerdict: 'step_done',
}),
).toEqual({
kind: 'request_owner_continue',
resetStatusToActive: false,
});
expect( expect(
resolveOwnerCompletionSignal({ resolveOwnerCompletionSignal({
phase: 'normal', phase: 'normal',
@@ -37,6 +48,18 @@ describe('paired execution context shared verdict helpers', () => {
}); });
it('maps finalize owner outcomes to complete, re-review, or arbiter', () => { it('maps finalize owner outcomes to complete, re-review, or arbiter', () => {
expect(
resolveOwnerCompletionSignal({
phase: 'finalize',
visibleVerdict: 'step_done',
hasChangesSinceApproval: false,
roundTripCount: 0,
deadlockThreshold: 2,
}),
).toEqual({
kind: 'request_owner_continue',
resetStatusToActive: true,
});
expect( expect(
resolveOwnerCompletionSignal({ resolveOwnerCompletionSignal({
phase: 'finalize', phase: 'finalize',
@@ -70,6 +93,20 @@ describe('paired execution context shared verdict helpers', () => {
}); });
it('maps reviewer completion verdicts to finalize, owner changes, or arbiter', () => { it('maps reviewer completion verdicts to finalize, owner changes, or arbiter', () => {
expect(
resolveReviewerCompletionSignal({
visibleVerdict: 'task_done',
roundTripCount: 0,
deadlockThreshold: 3,
}),
).toEqual({ kind: 'request_owner_finalize' });
expect(
resolveReviewerCompletionSignal({
visibleVerdict: 'step_done',
roundTripCount: 0,
deadlockThreshold: 3,
}),
).toEqual({ kind: 'request_owner_changes' });
expect( expect(
resolveReviewerCompletionSignal({ resolveReviewerCompletionSignal({
visibleVerdict: 'done', visibleVerdict: 'done',
@@ -94,6 +131,11 @@ describe('paired execution context shared verdict helpers', () => {
}); });
it('maps reviewer failure verdicts to explicit failure signals', () => { it('maps reviewer failure verdicts to explicit failure signals', () => {
expect(
resolveReviewerFailureSignal({
visibleVerdict: 'task_done',
}),
).toEqual({ kind: 'request_owner_finalize' });
expect( expect(
resolveReviewerFailureSignal({ resolveReviewerFailureSignal({
visibleVerdict: 'done', visibleVerdict: 'done',

View File

@@ -6,6 +6,8 @@ import { logger } from './logger.js';
import type { PairedTaskStatus } from './types.js'; import type { PairedTaskStatus } from './types.js';
export type VisibleVerdict = export type VisibleVerdict =
| 'step_done'
| 'task_done'
| 'done' | 'done'
| 'done_with_concerns' | 'done_with_concerns'
| 'blocked' | 'blocked'
@@ -15,6 +17,7 @@ export type VisibleVerdict =
export type CompletionSignal = export type CompletionSignal =
| { kind: 'request_reviewer'; resetStatusToActive: boolean } | { kind: 'request_reviewer'; resetStatusToActive: boolean }
| { kind: 'request_owner_finalize' } | { kind: 'request_owner_finalize' }
| { kind: 'request_owner_continue'; resetStatusToActive: boolean }
| { kind: 'request_owner_changes' } | { kind: 'request_owner_changes' }
| { kind: 'request_arbiter' } | { kind: 'request_arbiter' }
| { kind: 'complete'; completionReason: 'done' | 'escalated' } | { kind: 'complete'; completionReason: 'done' | 'escalated' }
@@ -29,6 +32,8 @@ export function parseVisibleVerdict(
const firstLine = cleaned.split('\n')[0].trim(); const firstLine = cleaned.split('\n')[0].trim();
if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked';
if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context';
if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done';
if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done';
if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine))
return 'done_with_concerns'; return 'done_with_concerns';
if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done';
@@ -57,12 +62,25 @@ export function resolveOwnerCompletionSignal(args: {
} }
if (phase === 'normal') { if (phase === 'normal') {
if (visibleVerdict === 'step_done') {
return {
kind: 'request_owner_continue',
resetStatusToActive: false,
};
}
return { return {
kind: 'request_reviewer', kind: 'request_reviewer',
resetStatusToActive: false, resetStatusToActive: false,
}; };
} }
if (visibleVerdict === 'step_done') {
return {
kind: 'request_owner_continue',
resetStatusToActive: true,
};
}
const needsReReview = const needsReReview =
visibleVerdict === 'done_with_concerns' || hasChangesSinceApproval === true; visibleVerdict === 'done_with_concerns' || hasChangesSinceApproval === true;
@@ -90,8 +108,11 @@ export function resolveReviewerCompletionSignal(args: {
const { visibleVerdict, roundTripCount, deadlockThreshold } = args; const { visibleVerdict, roundTripCount, deadlockThreshold } = args;
switch (visibleVerdict) { switch (visibleVerdict) {
case 'task_done':
case 'done': case 'done':
return { kind: 'request_owner_finalize' }; return { kind: 'request_owner_finalize' };
case 'step_done':
return { kind: 'request_owner_changes' };
case 'blocked': case 'blocked':
case 'needs_context': case 'needs_context':
return { kind: 'request_arbiter' }; return { kind: 'request_arbiter' };
@@ -111,6 +132,7 @@ export function resolveReviewerFailureSignal(args: {
const { visibleVerdict } = args; const { visibleVerdict } = args;
switch (visibleVerdict) { switch (visibleVerdict) {
case 'task_done':
case 'done': case 'done':
return { kind: 'request_owner_finalize' }; return { kind: 'request_owner_finalize' };
case 'blocked': case 'blocked':

View File

@@ -742,6 +742,65 @@ describe('paired execution context', () => {
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled();
}); });
it('keeps an active task in owner follow-up mode when the owner reports STEP_DONE', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
round_trip_count: 1,
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n1단계 완료, 후속 작업 계속',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
owner_failure_count: 0,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
}),
);
});
it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
round_trip_count: 1,
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n남은 범위가 있어서 계속 진행',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
owner_failure_count: 0,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it('re-triggers review once when owner reports DONE_WITH_CONCERNS during finalize', () => { it('re-triggers review once when owner reports DONE_WITH_CONCERNS during finalize', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue( vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({ buildPairedTask({