fix(member-work-sync): recover sleeping teammates
This commit is contained in:
parent
5d05434bbb
commit
544f4576d4
17 changed files with 631 additions and 43 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
getTeamTaskWorkflowColumn,
|
getTeamTaskWorkflowColumn,
|
||||||
isTeamTaskDeleted,
|
isTeamTaskBlockedByUnfinishedDependency,
|
||||||
isTeamTaskFinishedForDependency,
|
|
||||||
isTeamTaskNeedsFixActionable,
|
isTeamTaskNeedsFixActionable,
|
||||||
} from '@shared/utils/teamTaskState';
|
} from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
|
|
@ -43,13 +42,5 @@ export function isTaskBlocked(
|
||||||
task: TaskBlockInput,
|
task: TaskBlockInput,
|
||||||
taskStateById: ReadonlyMap<string, TaskBlockState>
|
taskStateById: ReadonlyMap<string, TaskBlockState>
|
||||||
): boolean {
|
): boolean {
|
||||||
const blockedBy = task.blockedBy?.filter((taskId) => taskId.length > 0) ?? [];
|
return isTeamTaskBlockedByUnfinishedDependency(task, taskStateById);
|
||||||
if (blockedBy.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return blockedBy.some((taskId) => {
|
|
||||||
const blocker = taskStateById.get(taskId);
|
|
||||||
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,29 @@ function parseTime(value: string | undefined): number | null {
|
||||||
return Number.isFinite(time) ? time : null;
|
return Number.isFinite(time) ? time : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveAcceptedWorkLease(status: MemberWorkSyncStatus): boolean {
|
||||||
|
const report = status.report;
|
||||||
|
if (
|
||||||
|
report?.accepted !== true ||
|
||||||
|
report.agendaFingerprint !== status.agenda.fingerprint ||
|
||||||
|
(report.state !== 'still_working' && report.state !== 'blocked')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluatedAtMs = parseTime(status.evaluatedAt);
|
||||||
|
const expiresAtMs = parseTime(report.expiresAt);
|
||||||
|
return evaluatedAtMs != null && expiresAtMs != null && expiresAtMs > evaluatedAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasNoCurrentAcceptedWorkProof(status: MemberWorkSyncStatus): boolean {
|
||||||
|
return (
|
||||||
|
status.diagnostics.includes('no_current_report') ||
|
||||||
|
status.diagnostics.includes('report_lease_expired') ||
|
||||||
|
status.diagnostics.includes('report_fingerprint_stale')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function eventsForMember(
|
function eventsForMember(
|
||||||
status: MemberWorkSyncStatus,
|
status: MemberWorkSyncStatus,
|
||||||
metrics: MemberWorkSyncTeamMetrics
|
metrics: MemberWorkSyncTeamMetrics
|
||||||
|
|
@ -69,16 +92,6 @@ function eventsForMember(
|
||||||
.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
|
.sort((left, right) => left.recordedAt.localeCompare(right.recordedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasAcceptedReportForCurrentFingerprint(
|
|
||||||
status: MemberWorkSyncStatus,
|
|
||||||
metrics: MemberWorkSyncTeamMetrics
|
|
||||||
): boolean {
|
|
||||||
return eventsForMember(status, metrics).some(
|
|
||||||
(event) =>
|
|
||||||
event.kind === 'report_accepted' && event.agendaFingerprint === status.agenda.fingerprint
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDifferentFingerprintBoundary(
|
function isDifferentFingerprintBoundary(
|
||||||
event: MemberWorkSyncMetricEvent,
|
event: MemberWorkSyncMetricEvent,
|
||||||
currentFingerprint: string
|
currentFingerprint: string
|
||||||
|
|
@ -104,11 +117,19 @@ function getCurrentFingerprintStableSinceMs(
|
||||||
return recordedAt != null && recordedAt <= nowMs;
|
return recordedAt != null && recordedAt <= nowMs;
|
||||||
});
|
});
|
||||||
let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY;
|
let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY;
|
||||||
|
let latestAcceptedReportMs = Number.NEGATIVE_INFINITY;
|
||||||
for (const event of memberEvents) {
|
for (const event of memberEvents) {
|
||||||
const recordedAt = parseTime(event.recordedAt);
|
const recordedAt = parseTime(event.recordedAt);
|
||||||
if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) {
|
if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) {
|
||||||
latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt);
|
latestDifferentFingerprintMs = Math.max(latestDifferentFingerprintMs, recordedAt);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
recordedAt != null &&
|
||||||
|
event.kind === 'report_accepted' &&
|
||||||
|
event.agendaFingerprint === currentFingerprint
|
||||||
|
) {
|
||||||
|
latestAcceptedReportMs = Math.max(latestAcceptedReportMs, recordedAt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => {
|
const currentNeedsSyncEventTimes = memberEvents.flatMap((event) => {
|
||||||
|
|
@ -117,7 +138,8 @@ function getCurrentFingerprintStableSinceMs(
|
||||||
event.state === 'needs_sync' &&
|
event.state === 'needs_sync' &&
|
||||||
event.agendaFingerprint === currentFingerprint &&
|
event.agendaFingerprint === currentFingerprint &&
|
||||||
recordedAt != null &&
|
recordedAt != null &&
|
||||||
recordedAt >= latestDifferentFingerprintMs
|
recordedAt >= latestDifferentFingerprintMs &&
|
||||||
|
recordedAt > latestAcceptedReportMs
|
||||||
? [recordedAt]
|
? [recordedAt]
|
||||||
: [];
|
: [];
|
||||||
});
|
});
|
||||||
|
|
@ -133,12 +155,12 @@ function isNativeStaleInProgressCandidate(input: {
|
||||||
if (
|
if (
|
||||||
status.state !== 'needs_sync' ||
|
status.state !== 'needs_sync' ||
|
||||||
status.shadow?.wouldNudge !== true ||
|
status.shadow?.wouldNudge !== true ||
|
||||||
!status.diagnostics.includes('no_current_report') ||
|
!hasNoCurrentAcceptedWorkProof(status) ||
|
||||||
!status.providerId ||
|
!status.providerId ||
|
||||||
!NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) ||
|
!NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) ||
|
||||||
isLeadLikeMemberName(status.memberName) ||
|
isLeadLikeMemberName(status.memberName) ||
|
||||||
status.agenda.items.length !== 1 ||
|
status.agenda.items.length !== 1 ||
|
||||||
hasAcceptedReportForCurrentFingerprint(status, metrics)
|
hasActiveAcceptedWorkLease(status)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -419,8 +419,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
||||||
inactive: source.inactive || !teamActive,
|
inactive: source.inactive || !teamActive,
|
||||||
});
|
});
|
||||||
const providerId = source.providerId ?? previous.providerId;
|
const providerId = source.providerId ?? previous.providerId;
|
||||||
|
const { report: _previousReport, ...previousWithoutReport } = previous;
|
||||||
const revalidatedStatus: MemberWorkSyncStatus = {
|
const revalidatedStatus: MemberWorkSyncStatus = {
|
||||||
...previous,
|
...previousWithoutReport,
|
||||||
state: decision.state,
|
state: decision.state,
|
||||||
agenda,
|
agenda,
|
||||||
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
|
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
|
||||||
|
|
|
||||||
|
|
@ -126,8 +126,12 @@ export function buildActionableWorkAgenda(
|
||||||
|
|
||||||
const owner = normalizeMemberName(task.owner);
|
const owner = normalizeMemberName(task.owner);
|
||||||
const base = buildBaseItem(task, memberName);
|
const base = buildBaseItem(task, memberName);
|
||||||
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
|
const blockedBy = [
|
||||||
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
|
...new Set((task.blockedBy ?? []).map((id) => id.trim()).filter(Boolean)),
|
||||||
|
].sort();
|
||||||
|
const blocks = [
|
||||||
|
...new Set((task.blocks ?? []).map((id) => id.trim()).filter(Boolean)),
|
||||||
|
].sort();
|
||||||
const brokenDependencyIds: string[] = [];
|
const brokenDependencyIds: string[] = [];
|
||||||
const waitingDependencyIds: string[] = [];
|
const waitingDependencyIds: string[] = [];
|
||||||
for (const dependencyId of blockedBy) {
|
for (const dependencyId of blockedBy) {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ function taskReferenceKeys(task: Pick<TeamTask, 'id' | 'displayId'>): string[] {
|
||||||
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedTaskReferences(values: readonly string[] | undefined): string[] {
|
||||||
|
return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
function findLeadMemberName(activeMembers: string[]): string | null {
|
function findLeadMemberName(activeMembers: string[]): string | null {
|
||||||
return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null;
|
return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null;
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +183,7 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
|
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const brokenDependencies = (task.blockedBy ?? []).filter((dependencyId) => {
|
const brokenDependencies = normalizedTaskReferences(task.blockedBy).filter((dependencyId) => {
|
||||||
const dependency = tasksByReference.get(dependencyId);
|
const dependency = tasksByReference.get(dependencyId);
|
||||||
return !dependency || isDeletedTask(dependency);
|
return !dependency || isDeletedTask(dependency);
|
||||||
});
|
});
|
||||||
|
|
@ -200,7 +204,7 @@ export class MemberWorkSyncTaskImpactResolver {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(candidate.blockedBy ?? []).some(
|
normalizedTaskReferences(candidate.blockedBy).some(
|
||||||
(dependencyId) => tasksByReference.get(dependencyId) === task
|
(dependencyId) => tasksByReference.get(dependencyId) === task
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,29 @@ import type { TeamChangeEvent } from '@shared/types';
|
||||||
const STALE_STATUS_MAX_AGE_MS = 2 * 60_000;
|
const STALE_STATUS_MAX_AGE_MS = 2 * 60_000;
|
||||||
const PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS = 10 * 60_000;
|
const PROOF_MISSING_RECOVERY_RECENT_WINDOW_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
function statusNeedsBackgroundRefresh(status: MemberWorkSyncStatus, nowMs: number): boolean {
|
||||||
|
if (status.agenda.items.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluatedAtMs = Date.parse(status.evaluatedAt);
|
||||||
|
if (!Number.isFinite(evaluatedAtMs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.state === 'needs_sync' && nowMs - evaluatedAtMs > STALE_STATUS_MAX_AGE_MS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportExpiresAtMs = Date.parse(status.report?.expiresAt ?? '');
|
||||||
|
return (
|
||||||
|
status.report?.accepted === true &&
|
||||||
|
Number.isFinite(reportExpiresAtMs) &&
|
||||||
|
reportExpiresAtMs <= nowMs &&
|
||||||
|
(status.state === 'still_working' || status.state === 'blocked')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] {
|
function getStatusStalenessDiagnostics(status: MemberWorkSyncStatus, nowMs: number): string[] {
|
||||||
const diagnostics: string[] = [];
|
const diagnostics: string[] = [];
|
||||||
const evaluatedAtMs = Date.parse(status.evaluatedAt);
|
const evaluatedAtMs = Date.parse(status.evaluatedAt);
|
||||||
|
|
@ -290,14 +313,61 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
);
|
);
|
||||||
return readiness.filter((item) => item.ready).map((item) => item.teamName);
|
return readiness.filter((item) => item.ready).map((item) => item.teamName);
|
||||||
};
|
};
|
||||||
|
const refreshBackgroundStaleStatuses = async (teamNames: string[]): Promise<void> => {
|
||||||
|
const nowMs = clock.now().getTime();
|
||||||
|
let refreshed = 0;
|
||||||
|
for (const teamName of teamNames) {
|
||||||
|
let memberNames: string[];
|
||||||
|
try {
|
||||||
|
memberNames = await agendaSource.loadActiveMemberNames(teamName);
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger?.warn('member work sync background refresh member scan failed', {
|
||||||
|
teamName,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const memberName of memberNames) {
|
||||||
|
try {
|
||||||
|
const status = await store.read({ teamName, memberName });
|
||||||
|
if (status && !statusNeedsBackgroundRefresh(status, nowMs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await reconciler.execute(
|
||||||
|
{ teamName, memberName },
|
||||||
|
{
|
||||||
|
reconciledBy: 'queue',
|
||||||
|
triggerReasons: [status ? 'manual_refresh' : 'startup_scan'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
refreshed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger?.warn('member work sync background refresh failed', {
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshed > 0) {
|
||||||
|
deps.logger?.debug('member work sync background stale refresh completed', { refreshed });
|
||||||
|
}
|
||||||
|
};
|
||||||
const dispatchNudgesForReadyTeams = async (
|
const dispatchNudgesForReadyTeams = async (
|
||||||
teamNames: string[],
|
teamNames: string[],
|
||||||
claimedBy: string
|
claimedBy: string,
|
||||||
|
options: { refreshBackgroundStaleStatuses?: boolean } = {}
|
||||||
): Promise<MemberWorkSyncNudgeDispatchSummary> => {
|
): Promise<MemberWorkSyncNudgeDispatchSummary> => {
|
||||||
const readyTeamNames = await filterNudgeDispatchReadyTeamNames(teamNames);
|
const readyTeamNames = await filterNudgeDispatchReadyTeamNames(teamNames);
|
||||||
if (readyTeamNames.length === 0) {
|
if (readyTeamNames.length === 0) {
|
||||||
return emptyNudgeDispatchSummary();
|
return emptyNudgeDispatchSummary();
|
||||||
}
|
}
|
||||||
|
if (options.refreshBackgroundStaleStatuses !== false) {
|
||||||
|
await refreshBackgroundStaleStatuses(readyTeamNames);
|
||||||
|
}
|
||||||
return nudgeDispatcher.dispatchDue({
|
return nudgeDispatcher.dispatchDue({
|
||||||
teamNames: readyTeamNames,
|
teamNames: readyTeamNames,
|
||||||
claimedBy,
|
claimedBy,
|
||||||
|
|
@ -306,7 +376,9 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
const queue = new MemberWorkSyncEventQueue({
|
const queue = new MemberWorkSyncEventQueue({
|
||||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||||
await reconciler.execute(request, context);
|
await reconciler.execute(request, context);
|
||||||
await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`);
|
await dispatchNudgesForReadyTeams([request.teamName], `member-work-sync:${process.pid}`, {
|
||||||
|
refreshBackgroundStaleStatuses: false,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
isTeamActive: deps.isTeamActive ?? (() => true),
|
isTeamActive: deps.isTeamActive ?? (() => true),
|
||||||
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { isTeamTaskBlockedByUnfinishedDependency } from '@shared/utils/teamTaskState';
|
||||||
|
|
||||||
import { getOpenCodeWeakStartStallThresholdMs } from './featureGates';
|
import { getOpenCodeWeakStartStallThresholdMs } from './featureGates';
|
||||||
import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier';
|
import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier';
|
||||||
|
|
||||||
|
|
@ -374,7 +376,7 @@ export class TeamTaskStallPolicy {
|
||||||
if (task.reviewState === 'review') {
|
if (task.reviewState === 'review') {
|
||||||
return skip(task.id, 'Task is currently under review', 'review_active');
|
return skip(task.id, 'Task is currently under review', 'review_active');
|
||||||
}
|
}
|
||||||
if (task.blockedBy?.length) {
|
if (isTeamTaskBlockedByUnfinishedDependency(task, snapshot.allTasksById)) {
|
||||||
return skip(task.id, 'Task is blocked', 'task_blocked');
|
return skip(task.id, 'Task is blocked', 'task_blocked');
|
||||||
}
|
}
|
||||||
if (task.needsClarification) {
|
if (task.needsClarification) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
export {
|
export {
|
||||||
getTeamTaskWorkflowColumn,
|
getTeamTaskWorkflowColumn,
|
||||||
isTeamTaskActivelyWorked,
|
isTeamTaskActivelyWorked,
|
||||||
|
isTeamTaskBlockedByUnfinishedDependency,
|
||||||
isTeamTaskFinalForCompletionNotification,
|
isTeamTaskFinalForCompletionNotification,
|
||||||
isTeamTaskFinishedForDependency,
|
isTeamTaskFinishedForDependency,
|
||||||
isTeamTaskNeedsFixActionable,
|
isTeamTaskNeedsFixActionable,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ export interface TeamTaskStateLike {
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamTaskBlockerLike {
|
||||||
|
blockedBy?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type TeamTaskWorkflowColumn = 'review' | 'approved';
|
export type TeamTaskWorkflowColumn = 'review' | 'approved';
|
||||||
|
|
||||||
interface CachedTeamTaskState {
|
interface CachedTeamTaskState {
|
||||||
|
|
@ -122,6 +126,22 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea
|
||||||
return getCachedTeamTaskState(task).finishedForDependency;
|
return getCachedTeamTaskState(task).finishedForDependency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTeamTaskBlockedByUnfinishedDependency(
|
||||||
|
task: TeamTaskBlockerLike,
|
||||||
|
taskStateById: ReadonlyMap<string, TeamTaskStateLike>
|
||||||
|
): boolean {
|
||||||
|
const blockedBy =
|
||||||
|
task.blockedBy?.map((taskId) => taskId.trim()).filter((taskId) => taskId.length > 0) ?? [];
|
||||||
|
if (blockedBy.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockedBy.some((taskId) => {
|
||||||
|
const blocker = taskStateById.get(taskId);
|
||||||
|
return !blocker || (!isTeamTaskFinishedForDependency(blocker) && !isTeamTaskDeleted(blocker));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean {
|
export function isTeamTaskTerminalForActionableWork(task: TeamTaskStateLike): boolean {
|
||||||
return getCachedTeamTaskState(task).terminalForActionableWork;
|
return getCachedTeamTaskState(task).terminalForActionableWork;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain';
|
import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const hash = (value: string) => `h${value.length}`;
|
const hash = (value: string) => `h${value.length}`;
|
||||||
|
|
||||||
|
|
@ -151,7 +150,7 @@ describe('buildActionableWorkAgenda', () => {
|
||||||
subject: 'Depends on approved task',
|
subject: 'Depends on approved task',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
owner: 'jack',
|
owner: 'jack',
|
||||||
blockedBy: ['task-approved'],
|
blockedBy: [' task-approved '],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hash,
|
hash,
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,13 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncActionableWorkItem,
|
MemberWorkSyncActionableWorkItem,
|
||||||
|
MemberWorkSyncMetricEvent,
|
||||||
MemberWorkSyncOutboxEnsureInput,
|
MemberWorkSyncOutboxEnsureInput,
|
||||||
MemberWorkSyncOutboxItem,
|
MemberWorkSyncOutboxItem,
|
||||||
MemberWorkSyncOutboxMarkDeliveredInput,
|
MemberWorkSyncOutboxMarkDeliveredInput,
|
||||||
MemberWorkSyncOutboxMarkFailedInput,
|
MemberWorkSyncOutboxMarkFailedInput,
|
||||||
MemberWorkSyncOutboxMarkSupersededInput,
|
MemberWorkSyncOutboxMarkSupersededInput,
|
||||||
|
MemberWorkSyncPhase2ReadinessReason,
|
||||||
MemberWorkSyncPhase2ReadinessState,
|
MemberWorkSyncPhase2ReadinessState,
|
||||||
MemberWorkSyncReportIntent,
|
MemberWorkSyncReportIntent,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
|
|
@ -94,6 +96,9 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
||||||
readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = [];
|
readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = [];
|
||||||
readonly pendingIntents = new Map<string, MemberWorkSyncReportIntent>();
|
readonly pendingIntents = new Map<string, MemberWorkSyncReportIntent>();
|
||||||
phase2ReadinessState: MemberWorkSyncPhase2ReadinessState = 'collecting_shadow_data';
|
phase2ReadinessState: MemberWorkSyncPhase2ReadinessState = 'collecting_shadow_data';
|
||||||
|
phase2ReadinessReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
|
||||||
|
metricsGeneratedAt = '2026-04-29T00:00:00.000Z';
|
||||||
|
recentEvents: MemberWorkSyncMetricEvent[] = [];
|
||||||
|
|
||||||
async read(): Promise<MemberWorkSyncStatus | null> {
|
async read(): Promise<MemberWorkSyncStatus | null> {
|
||||||
return this.writes.at(-1) ?? null;
|
return this.writes.at(-1) ?? null;
|
||||||
|
|
@ -129,7 +134,7 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
||||||
async readTeamMetrics(teamName: string): Promise<MemberWorkSyncTeamMetrics> {
|
async readTeamMetrics(teamName: string): Promise<MemberWorkSyncTeamMetrics> {
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
generatedAt: this.metricsGeneratedAt,
|
||||||
memberCount: 1,
|
memberCount: 1,
|
||||||
stateCounts: {
|
stateCounts: {
|
||||||
caught_up: 0,
|
caught_up: 0,
|
||||||
|
|
@ -144,10 +149,10 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
||||||
fingerprintChangeCount: 0,
|
fingerprintChangeCount: 0,
|
||||||
reportAcceptedCount: 0,
|
reportAcceptedCount: 0,
|
||||||
reportRejectedCount: 0,
|
reportRejectedCount: 0,
|
||||||
recentEvents: [],
|
recentEvents: this.recentEvents,
|
||||||
phase2Readiness: {
|
phase2Readiness: {
|
||||||
state: this.phase2ReadinessState,
|
state: this.phase2ReadinessState,
|
||||||
reasons: [],
|
reasons: this.phase2ReadinessReasons,
|
||||||
thresholds: {
|
thresholds: {
|
||||||
minObservedMembers: 1,
|
minObservedMembers: 1,
|
||||||
minStatusEvents: 20,
|
minStatusEvents: 20,
|
||||||
|
|
@ -1286,6 +1291,85 @@ describe('MemberWorkSync use cases', () => {
|
||||||
expect(revived).not.toHaveProperty('lastError');
|
expect(revived).not.toHaveProperty('lastError');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches native stale recovery after an attached still_working report expires', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
const inProgressItem: MemberWorkSyncActionableWorkItem = {
|
||||||
|
...workItem,
|
||||||
|
reason: 'owned_in_progress_task',
|
||||||
|
evidence: {
|
||||||
|
status: 'in_progress',
|
||||||
|
owner: 'bob',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { clock, deps, store } = createDeps({
|
||||||
|
items: [inProgressItem],
|
||||||
|
providerId: 'codex',
|
||||||
|
outboxStore: outbox,
|
||||||
|
inboxNudge: inbox,
|
||||||
|
});
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const reconciler = new MemberWorkSyncReconciler(deps);
|
||||||
|
const reporter = new MemberWorkSyncReporter(deps);
|
||||||
|
const current = await reconciler.execute(
|
||||||
|
{ teamName: 'team-a', memberName: 'bob' },
|
||||||
|
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
|
||||||
|
);
|
||||||
|
await reporter.execute({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
reportToken: current.reportToken,
|
||||||
|
taskIds: ['task-1'],
|
||||||
|
leaseTtlMs: 120_000,
|
||||||
|
source: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
clock.set('2026-04-29T00:10:00.000Z');
|
||||||
|
store.phase2ReadinessState = 'blocked';
|
||||||
|
store.phase2ReadinessReasons = ['would_nudge_rate_high'];
|
||||||
|
store.metricsGeneratedAt = '2026-04-29T00:10:00.000Z';
|
||||||
|
store.recentEvents = [
|
||||||
|
{
|
||||||
|
id: 'old-report-accepted',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
kind: 'report_accepted',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
recordedAt: '2026-04-29T00:01:00.000Z',
|
||||||
|
actionableCount: 1,
|
||||||
|
providerId: 'codex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'needs-sync-after-lease-expired',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
kind: 'status_evaluated',
|
||||||
|
state: 'needs_sync',
|
||||||
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
recordedAt: '2026-04-29T00:04:00.000Z',
|
||||||
|
actionableCount: 1,
|
||||||
|
providerId: 'codex',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
|
||||||
|
teamNames: ['team-a'],
|
||||||
|
claimedBy: 'test-dispatcher',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({ claimed: 1, delivered: 1, retryable: 0 });
|
||||||
|
expect(inbox.inserted).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
|
||||||
|
).toMatchObject({
|
||||||
|
status: 'delivered',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rate-limits delivered nudges per member per hour', async () => {
|
it('rate-limits delivered nudges per member per hour', async () => {
|
||||||
const outbox = new InMemoryOutboxStore();
|
const outbox = new InMemoryOutboxStore();
|
||||||
const inbox = new InMemoryInboxNudge();
|
const inbox = new InMemoryInboxNudge();
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,77 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
||||||
).toEqual({ active: false, reason: 'blocking_metrics' });
|
).toEqual({ active: false, reason: 'blocking_metrics' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('activates stale native in-progress recovery when an old accepted report is followed by a new needs_sync window', () => {
|
||||||
|
expect(
|
||||||
|
decideMemberWorkSyncNudgeActivation({
|
||||||
|
status: nativeStaleInProgressStatus(),
|
||||||
|
metrics: staleMetrics({
|
||||||
|
generatedAt: '2026-05-06T00:10:01.000Z',
|
||||||
|
recentEvents: [
|
||||||
|
{
|
||||||
|
id: 'old-report-accepted',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'alice',
|
||||||
|
kind: 'report_accepted',
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: 'agenda:v1:native-stale',
|
||||||
|
recordedAt: '2026-05-06T00:01:00.000Z',
|
||||||
|
actionableCount: 1,
|
||||||
|
providerId: 'codex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'needs-sync-after-old-report',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'alice',
|
||||||
|
kind: 'status_evaluated',
|
||||||
|
state: 'needs_sync',
|
||||||
|
agendaFingerprint: 'agenda:v1:native-stale',
|
||||||
|
recordedAt: '2026-05-06T00:04:00.000Z',
|
||||||
|
actionableCount: 1,
|
||||||
|
providerId: 'codex',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).toEqual({ active: true, reason: 'native_stale_in_progress' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates stale native in-progress recovery when an attached accepted report is expired', () => {
|
||||||
|
expect(
|
||||||
|
decideMemberWorkSyncNudgeActivation({
|
||||||
|
status: nativeStaleInProgressStatus({
|
||||||
|
evaluatedAt: '2026-05-06T00:10:01.000Z',
|
||||||
|
diagnostics: ['report_lease_expired'],
|
||||||
|
report: {
|
||||||
|
accepted: true,
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: 'agenda:v1:native-stale',
|
||||||
|
memberName: 'alice',
|
||||||
|
teamName: 'team-a',
|
||||||
|
reportedAt: '2026-05-06T00:01:00.000Z',
|
||||||
|
expiresAt: '2026-05-06T00:02:00.000Z',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
metrics: staleMetrics({
|
||||||
|
generatedAt: '2026-05-06T00:10:01.000Z',
|
||||||
|
recentEvents: [
|
||||||
|
{
|
||||||
|
id: 'needs-sync-after-expired-report',
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'alice',
|
||||||
|
kind: 'status_evaluated',
|
||||||
|
state: 'needs_sync',
|
||||||
|
agendaFingerprint: 'agenda:v1:native-stale',
|
||||||
|
recordedAt: '2026-05-06T00:04:00.000Z',
|
||||||
|
actionableCount: 1,
|
||||||
|
providerId: 'codex',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).toEqual({ active: true, reason: 'native_stale_in_progress' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not activate stale native in-progress recovery when the accepted report state is still current', () => {
|
it('does not activate stale native in-progress recovery when the accepted report state is still current', () => {
|
||||||
expect(
|
expect(
|
||||||
decideMemberWorkSyncNudgeActivation({
|
decideMemberWorkSyncNudgeActivation({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver';
|
import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { TeamTask } from '@shared/types';
|
import type { TeamTask } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -112,7 +111,7 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
|
||||||
subject: 'Depends on display id',
|
subject: 'Depends on display id',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
owner: 'tom',
|
owner: 'tom',
|
||||||
blockedBy: ['11111111'],
|
blockedBy: [' 11111111 '],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const resolver = new MemberWorkSyncTaskImpactResolver({
|
const resolver = new MemberWorkSyncTaskImpactResolver({
|
||||||
|
|
|
||||||
|
|
@ -3153,6 +3153,157 @@ describe('createMemberWorkSyncFeature composition', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes an expired still_working lease during nudge dispatch without a status read', async () => {
|
||||||
|
const claudeRoot = makeTempRoot();
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
const teamName = 'team-a';
|
||||||
|
const memberName = 'bob';
|
||||||
|
const feature = createMemberWorkSyncFeature({
|
||||||
|
teamsBasePath,
|
||||||
|
configReader: {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: teamName,
|
||||||
|
members: [{ name: memberName, providerId: 'codex' }],
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
taskReader: {
|
||||||
|
getTasks: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
displayId: '11111111',
|
||||||
|
subject: 'Wake after lease expiry',
|
||||||
|
status: 'in_progress',
|
||||||
|
owner: memberName,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as never,
|
||||||
|
kanbanManager: {
|
||||||
|
getState: vi.fn(async () => ({
|
||||||
|
teamName,
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {},
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
membersMetaStore: {
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
isTeamActive: vi.fn(async () => true),
|
||||||
|
canDispatchNudges: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
||||||
|
const current = await feature.refreshStatus({ teamName, memberName });
|
||||||
|
expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
feature.report({
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
state: 'still_working',
|
||||||
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
reportToken: current.reportToken,
|
||||||
|
taskIds: ['task-1'],
|
||||||
|
source: 'test',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
accepted: true,
|
||||||
|
status: { state: 'still_working', report: { accepted: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
|
||||||
|
const acceptedStatus = await store.read({ teamName, memberName });
|
||||||
|
expect(acceptedStatus?.report?.accepted).toBe(true);
|
||||||
|
const expiredReportedAt = new Date(Date.now() - 7 * 60_000).toISOString();
|
||||||
|
const expiredAt = new Date(Date.now() - 6 * 60_000).toISOString();
|
||||||
|
await store.write({
|
||||||
|
...acceptedStatus!,
|
||||||
|
evaluatedAt: expiredReportedAt,
|
||||||
|
report: {
|
||||||
|
...acceptedStatus!.report!,
|
||||||
|
reportedAt: expiredReportedAt,
|
||||||
|
expiresAt: expiredAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
|
||||||
|
|
||||||
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
||||||
|
claimed: 1,
|
||||||
|
delivered: 1,
|
||||||
|
superseded: 0,
|
||||||
|
retryable: 0,
|
||||||
|
terminal: 0,
|
||||||
|
});
|
||||||
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('materializes a missing active-member status during nudge dispatch', async () => {
|
||||||
|
const claudeRoot = makeTempRoot();
|
||||||
|
setClaudeBasePathOverride(claudeRoot);
|
||||||
|
const teamsBasePath = getTeamsBasePath();
|
||||||
|
const teamName = 'team-a';
|
||||||
|
const memberName = 'bob';
|
||||||
|
const feature = createMemberWorkSyncFeature({
|
||||||
|
teamsBasePath,
|
||||||
|
configReader: {
|
||||||
|
getConfig: vi.fn(async () => ({
|
||||||
|
name: teamName,
|
||||||
|
members: [{ name: memberName, providerId: 'codex' }],
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
taskReader: {
|
||||||
|
getTasks: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
displayId: '11111111',
|
||||||
|
subject: 'Wake after app restart',
|
||||||
|
status: 'pending',
|
||||||
|
owner: memberName,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
} as never,
|
||||||
|
kanbanManager: {
|
||||||
|
getState: vi.fn(async () => ({
|
||||||
|
teamName,
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {},
|
||||||
|
})),
|
||||||
|
} as never,
|
||||||
|
membersMetaStore: {
|
||||||
|
getMembers: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
isTeamActive: vi.fn(async () => true),
|
||||||
|
canDispatchNudges: vi.fn(async () => true),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
|
||||||
|
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
|
||||||
|
claimed: 1,
|
||||||
|
delivered: 1,
|
||||||
|
superseded: 0,
|
||||||
|
retryable: 0,
|
||||||
|
terminal: 0,
|
||||||
|
});
|
||||||
|
expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toHaveLength(1);
|
||||||
|
await expect(
|
||||||
|
new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath)).read({
|
||||||
|
teamName,
|
||||||
|
memberName,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
state: 'needs_sync',
|
||||||
|
shadow: { triggerReasons: ['startup_scan'] },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('uses snapshot config reads for startup roster materialization', async () => {
|
it('uses snapshot config reads for startup roster materialization', async () => {
|
||||||
const getConfig = vi.fn(async () => ({ members: [] }));
|
const getConfig = vi.fn(async () => ({ members: [] }));
|
||||||
const getConfigSnapshot = vi.fn(async () => ({
|
const getConfigSnapshot = vi.fn(async () => ({
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,138 @@ describe('TeamTaskStallPolicy', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['completed', { status: 'completed' }],
|
||||||
|
['approved', { status: 'in_progress', reviewState: 'approved' }],
|
||||||
|
['soft-deleted', { status: 'in_progress', deletedAt: '2026-04-19T12:05:00.000Z' }],
|
||||||
|
] as const)('does not treat %s blockers as active stall blockers', (_label, blockerState) => {
|
||||||
|
const blocker: TeamTask = {
|
||||||
|
id: 'task-blocker',
|
||||||
|
displayId: 'block123',
|
||||||
|
subject: 'Finished dependency',
|
||||||
|
...blockerState,
|
||||||
|
};
|
||||||
|
const task: TeamTask = {
|
||||||
|
id: 'task-a',
|
||||||
|
displayId: 'abcd1234',
|
||||||
|
subject: 'Task A',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
blockedBy: [` ${blocker.id} `],
|
||||||
|
workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }],
|
||||||
|
};
|
||||||
|
const snapshot = createSnapshot({
|
||||||
|
activeTasks: [task],
|
||||||
|
allTasksById: new Map([
|
||||||
|
[task.id, task],
|
||||||
|
[blocker.id, blocker],
|
||||||
|
]),
|
||||||
|
inProgressTasks: [task],
|
||||||
|
recordsByTaskId: new Map([[task.id, [createRecord()]]]),
|
||||||
|
exactRowsByFilePath: new Map([
|
||||||
|
[
|
||||||
|
'/tmp/session.jsonl',
|
||||||
|
[
|
||||||
|
createExactRow({
|
||||||
|
messageUuid: 'msg-touch',
|
||||||
|
toolUseIds: ['tool-1'],
|
||||||
|
}),
|
||||||
|
createExactRow({
|
||||||
|
sourceOrder: 2,
|
||||||
|
messageUuid: 'msg-turn-end',
|
||||||
|
systemSubtype: 'turn_duration',
|
||||||
|
parsedMessage: createParsedMessage({
|
||||||
|
uuid: 'msg-turn-end',
|
||||||
|
type: 'system',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const evaluation = policy.evaluateWork({
|
||||||
|
now: new Date('2026-04-19T12:30:00.000Z'),
|
||||||
|
task,
|
||||||
|
snapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evaluation).toMatchObject({
|
||||||
|
status: 'alert',
|
||||||
|
taskId: 'task-a',
|
||||||
|
branch: 'work',
|
||||||
|
signal: 'turn_ended_after_touch',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['in-progress', { status: 'in_progress' }],
|
||||||
|
['completed in review', { status: 'completed', reviewState: 'review' }],
|
||||||
|
] as const)('still skips work tasks with %s blockers', (_label, blockerState) => {
|
||||||
|
const blocker: TeamTask = {
|
||||||
|
id: 'task-blocker',
|
||||||
|
displayId: 'block123',
|
||||||
|
subject: 'Unfinished dependency',
|
||||||
|
...blockerState,
|
||||||
|
};
|
||||||
|
const task: TeamTask = {
|
||||||
|
id: 'task-a',
|
||||||
|
displayId: 'abcd1234',
|
||||||
|
subject: 'Task A',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
blockedBy: [blocker.id],
|
||||||
|
workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluation = policy.evaluateWork({
|
||||||
|
now: new Date('2026-04-19T12:30:00.000Z'),
|
||||||
|
task,
|
||||||
|
snapshot: createSnapshot({
|
||||||
|
activeTasks: [task, blocker],
|
||||||
|
allTasksById: new Map([
|
||||||
|
[task.id, task],
|
||||||
|
[blocker.id, blocker],
|
||||||
|
]),
|
||||||
|
inProgressTasks: [task, blocker],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evaluation).toMatchObject({
|
||||||
|
status: 'skip',
|
||||||
|
taskId: 'task-a',
|
||||||
|
skipReason: 'task_blocked',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps work tasks blocked when a blocker id cannot be resolved', () => {
|
||||||
|
const task: TeamTask = {
|
||||||
|
id: 'task-a',
|
||||||
|
displayId: 'abcd1234',
|
||||||
|
subject: 'Task A',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
blockedBy: ['missing-blocker'],
|
||||||
|
workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluation = policy.evaluateWork({
|
||||||
|
now: new Date('2026-04-19T12:30:00.000Z'),
|
||||||
|
task,
|
||||||
|
snapshot: createSnapshot({
|
||||||
|
activeTasks: [task],
|
||||||
|
allTasksById: new Map([[task.id, task]]),
|
||||||
|
inProgressTasks: [task],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evaluation).toMatchObject({
|
||||||
|
status: 'skip',
|
||||||
|
taskId: 'task-a',
|
||||||
|
skipReason: 'task_blocked',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['turn_ended_after_touch', 4],
|
['turn_ended_after_touch', 4],
|
||||||
['touch_then_other_turns', 5],
|
['touch_then_other_turns', 5],
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
getTeamTaskWorkflowColumn,
|
getTeamTaskWorkflowColumn,
|
||||||
isTeamTaskActivelyWorked,
|
isTeamTaskActivelyWorked,
|
||||||
|
isTeamTaskBlockedByUnfinishedDependency,
|
||||||
isTeamTaskFinalForCompletionNotification,
|
isTeamTaskFinalForCompletionNotification,
|
||||||
isTeamTaskFinishedForDependency,
|
isTeamTaskFinishedForDependency,
|
||||||
isTeamTaskNeedsFixActionable,
|
isTeamTaskNeedsFixActionable,
|
||||||
|
|
@ -109,6 +110,40 @@ describe('isTeamTaskActivelyWorked', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isTeamTaskBlockedByUnfinishedDependency', () => {
|
||||||
|
it('uses dependency-finished semantics and trims persisted blocker ids', () => {
|
||||||
|
const taskStateById = new Map([
|
||||||
|
['completed', { status: 'completed' }],
|
||||||
|
['approved', { status: 'in_progress', reviewState: 'approved' }],
|
||||||
|
['soft-deleted', { status: 'in_progress', deletedAt: '2026-05-06T00:00:00.000Z' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isTeamTaskBlockedByUnfinishedDependency(
|
||||||
|
{ blockedBy: [' completed ', 'approved', 'soft-deleted'] },
|
||||||
|
taskStateById
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for missing or unfinished blockers', () => {
|
||||||
|
const taskStateById = new Map([
|
||||||
|
['in-progress', { status: 'in_progress' }],
|
||||||
|
['completed-review', { status: 'completed', reviewState: 'review' }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['in-progress'] }, taskStateById)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['completed-review'] }, taskStateById)
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isTeamTaskBlockedByUnfinishedDependency({ blockedBy: ['missing'] }, taskStateById)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getTeamTaskWorkflowColumn', () => {
|
describe('getTeamTaskWorkflowColumn', () => {
|
||||||
it('keeps stale in-progress approved overlay visible as approved', () => {
|
it('keeps stale in-progress approved overlay visible as approved', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isTaskBlocked,
|
isTaskBlocked,
|
||||||
resolveTaskGraphColumn,
|
resolveTaskGraphColumn,
|
||||||
} from '@features/agent-graph/core/domain/taskGraphSemantics';
|
} from '@features/agent-graph/core/domain/taskGraphSemantics';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -50,6 +49,7 @@ describe('taskGraphSemantics', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(isTaskBlocked({ blockedBy: ['completed'] }, taskStateById)).toBe(false);
|
expect(isTaskBlocked({ blockedBy: ['completed'] }, taskStateById)).toBe(false);
|
||||||
|
expect(isTaskBlocked({ blockedBy: [' completed '] }, taskStateById)).toBe(false);
|
||||||
expect(isTaskBlocked({ blockedBy: ['soft-deleted'] }, taskStateById)).toBe(false);
|
expect(isTaskBlocked({ blockedBy: ['soft-deleted'] }, taskStateById)).toBe(false);
|
||||||
expect(isTaskBlocked({ blockedBy: ['review-approved'] }, taskStateById)).toBe(false);
|
expect(isTaskBlocked({ blockedBy: ['review-approved'] }, taskStateById)).toBe(false);
|
||||||
expect(isTaskBlocked({ blockedBy: ['kanban-approved'] }, taskStateById)).toBe(false);
|
expect(isTaskBlocked({ blockedBy: ['kanban-approved'] }, taskStateById)).toBe(false);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue