508 lines
14 KiB
TypeScript
508 lines
14 KiB
TypeScript
import type {
|
|
ReviewTaskContext,
|
|
TaskStallBranch,
|
|
TaskStallEvaluation,
|
|
TaskStallSignal,
|
|
TeamTaskStallExactRow,
|
|
TeamTaskStallSnapshot,
|
|
WorkTaskContext,
|
|
} from './TeamTaskStallTypes';
|
|
import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord';
|
|
import type { TeamTask, TaskWorkInterval, TaskHistoryEvent } from '@shared/types';
|
|
|
|
const WORK_TOUCH_TOOLS = new Set(['task_start', 'task_add_comment', 'task_set_status']);
|
|
const REVIEW_TOUCH_TOOLS = new Set(['review_start', 'task_add_comment']);
|
|
|
|
const ONE_MINUTE_MS = 60_000;
|
|
const WORK_THRESHOLDS_MS: Record<TaskStallSignal, number> = {
|
|
turn_ended_after_touch: 8 * ONE_MINUTE_MS,
|
|
touch_then_other_turns: 10 * ONE_MINUTE_MS,
|
|
mid_turn_after_touch: 20 * ONE_MINUTE_MS,
|
|
};
|
|
const REVIEW_THRESHOLDS_MS: Record<TaskStallSignal, number> = {
|
|
turn_ended_after_touch: 10 * ONE_MINUTE_MS,
|
|
touch_then_other_turns: 10 * ONE_MINUTE_MS,
|
|
mid_turn_after_touch: 25 * ONE_MINUTE_MS,
|
|
};
|
|
|
|
function skip(
|
|
taskId: string,
|
|
reason: string,
|
|
skipReason: TaskStallEvaluation['skipReason']
|
|
): TaskStallEvaluation {
|
|
return {
|
|
status: 'skip',
|
|
taskId,
|
|
reason,
|
|
skipReason,
|
|
};
|
|
}
|
|
|
|
function isAfterOrEqual(timestamp: string, lowerBound: string): boolean {
|
|
return Date.parse(timestamp) >= Date.parse(lowerBound);
|
|
}
|
|
|
|
function getOpenWorkInterval(task: TeamTask): TaskWorkInterval | null {
|
|
const intervals = task.workIntervals ?? [];
|
|
for (let i = intervals.length - 1; i >= 0; i -= 1) {
|
|
const interval = intervals[i];
|
|
if (!interval.completedAt) {
|
|
return interval;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getOpenReviewWindowStart(task: TeamTask): string | null {
|
|
if (task.reviewState !== 'review' || !task.historyEvents?.length) {
|
|
return null;
|
|
}
|
|
|
|
for (let i = task.historyEvents.length - 1; i >= 0; i -= 1) {
|
|
const event = task.historyEvents[i];
|
|
if (event.type === 'review_started') {
|
|
return event.timestamp;
|
|
}
|
|
if (
|
|
event.type === 'review_approved' ||
|
|
event.type === 'review_changes_requested' ||
|
|
(event.type === 'status_changed' && event.to === 'in_progress')
|
|
) {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasReviewStartedByReviewer(
|
|
historyEvents: TaskHistoryEvent[] | undefined,
|
|
reviewer: string,
|
|
windowStartedAt: string
|
|
): boolean {
|
|
if (!historyEvents?.length) {
|
|
return false;
|
|
}
|
|
|
|
return historyEvents.some(
|
|
(event) =>
|
|
event.type === 'review_started' &&
|
|
event.actor === reviewer &&
|
|
isAfterOrEqual(event.timestamp, windowStartedAt)
|
|
);
|
|
}
|
|
|
|
function isStrongReviewTouch(
|
|
record: BoardTaskActivityRecord,
|
|
reviewer: string,
|
|
hasExplicitStartedReview: boolean,
|
|
windowStartedAt: string
|
|
): boolean {
|
|
if (
|
|
record.actor.memberName !== reviewer ||
|
|
!record.action?.canonicalToolName ||
|
|
!REVIEW_TOUCH_TOOLS.has(record.action.canonicalToolName) ||
|
|
!isAfterOrEqual(record.timestamp, windowStartedAt)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (record.action.canonicalToolName === 'review_start') {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
record.actorContext.relation === 'same_task' &&
|
|
record.actorContext.activePhase === 'review'
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return hasExplicitStartedReview;
|
|
}
|
|
|
|
function findLastMeaningfulWorkTouch(
|
|
records: BoardTaskActivityRecord[],
|
|
owner: string,
|
|
intervalStartedAt: string
|
|
): BoardTaskActivityRecord | null {
|
|
return (
|
|
[...records]
|
|
.filter((record) => record.actor.memberName === owner)
|
|
.filter((record) => isAfterOrEqual(record.timestamp, intervalStartedAt))
|
|
.filter((record) => WORK_TOUCH_TOOLS.has(record.action?.canonicalToolName ?? ''))
|
|
.at(-1) ?? null
|
|
);
|
|
}
|
|
|
|
function findLastMeaningfulReviewTouch(
|
|
records: BoardTaskActivityRecord[],
|
|
reviewer: string,
|
|
windowStartedAt: string,
|
|
hasExplicitStartedReview: boolean
|
|
): BoardTaskActivityRecord | null {
|
|
return (
|
|
[...records]
|
|
.filter((record) =>
|
|
isStrongReviewTouch(record, reviewer, hasExplicitStartedReview, windowStartedAt)
|
|
)
|
|
.at(-1) ?? null
|
|
);
|
|
}
|
|
|
|
function anchorEvidenceRank(row: TeamTaskStallExactRow, toolUseId: string | undefined): number {
|
|
if (!toolUseId || row.parsedMessage.type !== 'assistant') {
|
|
return 0;
|
|
}
|
|
if (row.toolUseIds.includes(toolUseId)) {
|
|
return 2;
|
|
}
|
|
if (row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function deduplicateAssistantRowsByRequestId(
|
|
rows: TeamTaskStallExactRow[],
|
|
toolUseId: string | undefined
|
|
): TeamTaskStallExactRow[] {
|
|
const preferredIndexByRequestId = new Map<string, number>();
|
|
for (let i = 0; i < rows.length; i += 1) {
|
|
const row = rows[i];
|
|
if (row.parsedMessage.type !== 'assistant' || !row.requestId) {
|
|
continue;
|
|
}
|
|
const existingIndex = preferredIndexByRequestId.get(row.requestId);
|
|
if (existingIndex === undefined) {
|
|
preferredIndexByRequestId.set(row.requestId, i);
|
|
continue;
|
|
}
|
|
const existingRank = anchorEvidenceRank(rows[existingIndex], toolUseId);
|
|
const nextRank = anchorEvidenceRank(row, toolUseId);
|
|
if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) {
|
|
preferredIndexByRequestId.set(row.requestId, i);
|
|
}
|
|
}
|
|
|
|
if (preferredIndexByRequestId.size === 0) {
|
|
return rows;
|
|
}
|
|
|
|
return rows.filter((row, index) => {
|
|
if (row.parsedMessage.type !== 'assistant' || !row.requestId) {
|
|
return true;
|
|
}
|
|
return preferredIndexByRequestId.get(row.requestId) === index;
|
|
});
|
|
}
|
|
|
|
function findAnchorRowIndex(
|
|
rows: TeamTaskStallExactRow[],
|
|
messageUuid: string,
|
|
toolUseId?: string
|
|
): number {
|
|
const candidates = rows
|
|
.map((row, index) => ({ row, index }))
|
|
.filter(({ row }) => row.messageUuid === messageUuid);
|
|
if (candidates.length === 0) {
|
|
return -1;
|
|
}
|
|
|
|
if (toolUseId) {
|
|
const explicitToolUse = candidates.filter(({ row }) => row.toolUseIds.includes(toolUseId));
|
|
if (explicitToolUse.length > 0) {
|
|
return explicitToolUse.at(-1)!.index;
|
|
}
|
|
|
|
const linkedRows = candidates.filter(
|
|
({ row }) => row.sourceToolUseId === toolUseId || row.toolResultIds.includes(toolUseId)
|
|
);
|
|
if (linkedRows.length > 0) {
|
|
return linkedRows.at(-1)!.index;
|
|
}
|
|
}
|
|
|
|
return candidates.at(-1)!.index;
|
|
}
|
|
|
|
function classifyPostTouchState(args: {
|
|
rows: TeamTaskStallExactRow[];
|
|
anchorMessageUuid: string;
|
|
anchorToolUseId?: string;
|
|
}): TaskStallSignal | 'ambiguous' {
|
|
const normalizedRows = deduplicateAssistantRowsByRequestId(args.rows, args.anchorToolUseId);
|
|
const anchorIndex = findAnchorRowIndex(
|
|
normalizedRows,
|
|
args.anchorMessageUuid,
|
|
args.anchorToolUseId
|
|
);
|
|
if (anchorIndex < 0) {
|
|
return 'ambiguous';
|
|
}
|
|
|
|
let sawTurnEnd = false;
|
|
let sawLaterRows = false;
|
|
|
|
for (let i = anchorIndex + 1; i < normalizedRows.length; i += 1) {
|
|
const row = normalizedRows[i];
|
|
if (row.systemSubtype === 'turn_duration') {
|
|
sawTurnEnd = true;
|
|
continue;
|
|
}
|
|
|
|
sawLaterRows = true;
|
|
if (sawTurnEnd) {
|
|
return 'touch_then_other_turns';
|
|
}
|
|
}
|
|
|
|
if (sawTurnEnd) {
|
|
return 'turn_ended_after_touch';
|
|
}
|
|
if (sawLaterRows) {
|
|
return 'mid_turn_after_touch';
|
|
}
|
|
return 'mid_turn_after_touch';
|
|
}
|
|
|
|
function buildEpochKey(
|
|
task: TeamTask,
|
|
branch: TaskStallBranch,
|
|
signal: TaskStallSignal,
|
|
touch: BoardTaskActivityRecord
|
|
): string {
|
|
return [
|
|
task.id,
|
|
branch,
|
|
signal,
|
|
touch.timestamp,
|
|
touch.source.filePath,
|
|
touch.source.messageUuid,
|
|
touch.source.toolUseId ?? 'ambient',
|
|
].join(':');
|
|
}
|
|
|
|
function buildAlertEvaluation(args: {
|
|
task: TeamTask;
|
|
branch: TaskStallBranch;
|
|
signal: TaskStallSignal;
|
|
touch: BoardTaskActivityRecord;
|
|
reason: string;
|
|
}): TaskStallEvaluation {
|
|
return {
|
|
status: 'alert',
|
|
taskId: args.task.id,
|
|
branch: args.branch,
|
|
signal: args.signal,
|
|
epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch),
|
|
reason: args.reason,
|
|
};
|
|
}
|
|
|
|
export class TeamTaskStallPolicy {
|
|
evaluateWork(args: {
|
|
now: Date;
|
|
task: TeamTask;
|
|
snapshot: TeamTaskStallSnapshot;
|
|
}): TaskStallEvaluation {
|
|
const { task, snapshot } = args;
|
|
|
|
if (!snapshot.activityReadsEnabled) {
|
|
return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled');
|
|
}
|
|
if (!snapshot.exactReadsEnabled) {
|
|
return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled');
|
|
}
|
|
if (task.status !== 'in_progress') {
|
|
return skip(task.id, 'Task is not in progress', 'task_not_in_progress');
|
|
}
|
|
if (!task.owner) {
|
|
return skip(task.id, 'Task has no owner', 'owner_missing');
|
|
}
|
|
if (task.owner === snapshot.leadName) {
|
|
return skip(task.id, 'Task owner is the lead', 'owner_is_lead');
|
|
}
|
|
if (task.reviewState === 'review') {
|
|
return skip(task.id, 'Task is currently under review', 'review_active');
|
|
}
|
|
if (task.blockedBy?.length) {
|
|
return skip(task.id, 'Task is blocked', 'task_blocked');
|
|
}
|
|
if (task.needsClarification) {
|
|
return skip(task.id, 'Task is waiting for clarification', 'needs_clarification');
|
|
}
|
|
|
|
const openWorkInterval = getOpenWorkInterval(task);
|
|
if (!openWorkInterval?.startedAt) {
|
|
return skip(task.id, 'Task has no open work interval', 'no_open_work_interval');
|
|
}
|
|
|
|
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
|
|
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
|
|
return skip(
|
|
task.id,
|
|
'Task run is not instrumented enough for stall evaluation',
|
|
'non_instrumented_run'
|
|
);
|
|
}
|
|
|
|
const workContext: WorkTaskContext | null = (() => {
|
|
const touch = findLastMeaningfulWorkTouch(records, task.owner!, openWorkInterval.startedAt);
|
|
if (!touch) {
|
|
return null;
|
|
}
|
|
return {
|
|
owner: task.owner!,
|
|
intervalStartedAt: openWorkInterval.startedAt,
|
|
lastMeaningfulTouch: touch,
|
|
lastMeaningfulTouchAt: touch.timestamp,
|
|
};
|
|
})();
|
|
|
|
if (!workContext) {
|
|
return skip(
|
|
task.id,
|
|
'No positive work touch found in current work interval',
|
|
'no_positive_touch'
|
|
);
|
|
}
|
|
|
|
const exactRows = snapshot.exactRowsByFilePath.get(
|
|
workContext.lastMeaningfulTouch.source.filePath
|
|
);
|
|
if (!exactRows?.length) {
|
|
return skip(task.id, 'Post-touch exact rows are unavailable', 'ambiguous_state');
|
|
}
|
|
|
|
const signal = classifyPostTouchState({
|
|
rows: exactRows,
|
|
anchorMessageUuid: workContext.lastMeaningfulTouch.source.messageUuid,
|
|
anchorToolUseId: workContext.lastMeaningfulTouch.source.toolUseId,
|
|
});
|
|
if (signal === 'ambiguous') {
|
|
return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state');
|
|
}
|
|
|
|
const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt);
|
|
const thresholdMs = WORK_THRESHOLDS_MS[signal];
|
|
if (elapsedMs < thresholdMs) {
|
|
return skip(
|
|
task.id,
|
|
'Work touch is still below the configured stall threshold',
|
|
'below_threshold'
|
|
);
|
|
}
|
|
|
|
return buildAlertEvaluation({
|
|
task,
|
|
branch: 'work',
|
|
signal,
|
|
touch: workContext.lastMeaningfulTouch,
|
|
reason: `Potential work stall after ${signal.replaceAll('_', ' ')}.`,
|
|
});
|
|
}
|
|
|
|
evaluateReview(args: {
|
|
now: Date;
|
|
task: TeamTask;
|
|
snapshot: TeamTaskStallSnapshot;
|
|
}): TaskStallEvaluation {
|
|
const { task, snapshot } = args;
|
|
|
|
if (!snapshot.activityReadsEnabled) {
|
|
return skip(task.id, 'Task activity reads are disabled', 'activity_reads_disabled');
|
|
}
|
|
if (!snapshot.exactReadsEnabled) {
|
|
return skip(task.id, 'Exact log reads are disabled', 'exact_reads_disabled');
|
|
}
|
|
if (task.reviewState !== 'review') {
|
|
return skip(task.id, 'Task is not in an open review window', 'review_terminal');
|
|
}
|
|
if (task.needsClarification) {
|
|
return skip(task.id, 'Task is waiting for clarification', 'needs_clarification');
|
|
}
|
|
|
|
const reviewWindowStartedAt = getOpenReviewWindowStart(task);
|
|
if (!reviewWindowStartedAt) {
|
|
return skip(task.id, 'Task has no open review window', 'no_open_review_window');
|
|
}
|
|
|
|
const resolvedReviewer = snapshot.resolvedReviewersByTaskId.get(task.id) ?? {
|
|
reviewer: null,
|
|
source: 'none',
|
|
};
|
|
if (!resolvedReviewer.reviewer) {
|
|
return skip(task.id, 'Reviewer could not be resolved safely', 'reviewer_unresolved');
|
|
}
|
|
|
|
const records = snapshot.recordsByTaskId.get(task.id) ?? [];
|
|
if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) {
|
|
return skip(
|
|
task.id,
|
|
'Review run is not instrumented enough for stall evaluation',
|
|
'non_instrumented_run'
|
|
);
|
|
}
|
|
|
|
const explicitReviewStarted = hasReviewStartedByReviewer(
|
|
task.historyEvents,
|
|
resolvedReviewer.reviewer,
|
|
reviewWindowStartedAt
|
|
);
|
|
const reviewContext: ReviewTaskContext | null = (() => {
|
|
const touch = findLastMeaningfulReviewTouch(
|
|
records,
|
|
resolvedReviewer.reviewer!,
|
|
reviewWindowStartedAt,
|
|
explicitReviewStarted
|
|
);
|
|
if (!touch) {
|
|
return null;
|
|
}
|
|
return {
|
|
resolvedReviewer,
|
|
reviewWindowStartedAt,
|
|
lastMeaningfulTouch: touch,
|
|
lastMeaningfulTouchAt: touch.timestamp,
|
|
};
|
|
})();
|
|
|
|
if (!reviewContext) {
|
|
return skip(task.id, 'No explicit started-review evidence was found', 'no_positive_touch');
|
|
}
|
|
|
|
const exactRows = snapshot.exactRowsByFilePath.get(
|
|
reviewContext.lastMeaningfulTouch.source.filePath
|
|
);
|
|
if (!exactRows?.length) {
|
|
return skip(task.id, 'Post-review exact rows are unavailable', 'ambiguous_state');
|
|
}
|
|
|
|
const signal = classifyPostTouchState({
|
|
rows: exactRows,
|
|
anchorMessageUuid: reviewContext.lastMeaningfulTouch.source.messageUuid,
|
|
anchorToolUseId: reviewContext.lastMeaningfulTouch.source.toolUseId,
|
|
});
|
|
if (signal === 'ambiguous') {
|
|
return skip(task.id, 'Post-review state is ambiguous', 'ambiguous_state');
|
|
}
|
|
|
|
const elapsedMs = args.now.getTime() - Date.parse(reviewContext.lastMeaningfulTouchAt);
|
|
const thresholdMs = REVIEW_THRESHOLDS_MS[signal];
|
|
if (elapsedMs < thresholdMs) {
|
|
return skip(
|
|
task.id,
|
|
'Review touch is still below the configured stall threshold',
|
|
'below_threshold'
|
|
);
|
|
}
|
|
|
|
return buildAlertEvaluation({
|
|
task,
|
|
branch: 'review',
|
|
signal,
|
|
touch: reviewContext.lastMeaningfulTouch,
|
|
reason: `Potential started-review stall after ${signal.replaceAll('_', ' ')}.`,
|
|
});
|
|
}
|
|
}
|