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 {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskDeleted,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskBlockedByUnfinishedDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
} from '@shared/utils/teamTaskState';
|
||||
|
||||
|
|
@ -43,13 +42,5 @@ export function isTaskBlocked(
|
|||
task: TaskBlockInput,
|
||||
taskStateById: ReadonlyMap<string, TaskBlockState>
|
||||
): boolean {
|
||||
const blockedBy = task.blockedBy?.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));
|
||||
});
|
||||
return isTeamTaskBlockedByUnfinishedDependency(task, taskStateById);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,29 @@ function parseTime(value: string | undefined): number | 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(
|
||||
status: MemberWorkSyncStatus,
|
||||
metrics: MemberWorkSyncTeamMetrics
|
||||
|
|
@ -69,16 +92,6 @@ function eventsForMember(
|
|||
.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(
|
||||
event: MemberWorkSyncMetricEvent,
|
||||
currentFingerprint: string
|
||||
|
|
@ -104,11 +117,19 @@ function getCurrentFingerprintStableSinceMs(
|
|||
return recordedAt != null && recordedAt <= nowMs;
|
||||
});
|
||||
let latestDifferentFingerprintMs = Number.NEGATIVE_INFINITY;
|
||||
let latestAcceptedReportMs = Number.NEGATIVE_INFINITY;
|
||||
for (const event of memberEvents) {
|
||||
const recordedAt = parseTime(event.recordedAt);
|
||||
if (recordedAt != null && isDifferentFingerprintBoundary(event, currentFingerprint)) {
|
||||
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) => {
|
||||
|
|
@ -117,7 +138,8 @@ function getCurrentFingerprintStableSinceMs(
|
|||
event.state === 'needs_sync' &&
|
||||
event.agendaFingerprint === currentFingerprint &&
|
||||
recordedAt != null &&
|
||||
recordedAt >= latestDifferentFingerprintMs
|
||||
recordedAt >= latestDifferentFingerprintMs &&
|
||||
recordedAt > latestAcceptedReportMs
|
||||
? [recordedAt]
|
||||
: [];
|
||||
});
|
||||
|
|
@ -133,12 +155,12 @@ function isNativeStaleInProgressCandidate(input: {
|
|||
if (
|
||||
status.state !== 'needs_sync' ||
|
||||
status.shadow?.wouldNudge !== true ||
|
||||
!status.diagnostics.includes('no_current_report') ||
|
||||
!hasNoCurrentAcceptedWorkProof(status) ||
|
||||
!status.providerId ||
|
||||
!NATIVE_STALE_IN_PROGRESS_PROVIDERS.has(status.providerId) ||
|
||||
isLeadLikeMemberName(status.memberName) ||
|
||||
status.agenda.items.length !== 1 ||
|
||||
hasAcceptedReportForCurrentFingerprint(status, metrics)
|
||||
hasActiveAcceptedWorkLease(status)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,8 +419,9 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
inactive: source.inactive || !teamActive,
|
||||
});
|
||||
const providerId = source.providerId ?? previous.providerId;
|
||||
const { report: _previousReport, ...previousWithoutReport } = previous;
|
||||
const revalidatedStatus: MemberWorkSyncStatus = {
|
||||
...previous,
|
||||
...previousWithoutReport,
|
||||
state: decision.state,
|
||||
agenda,
|
||||
...(decision.acceptedReport ? { report: decision.acceptedReport } : {}),
|
||||
|
|
|
|||
|
|
@ -126,8 +126,12 @@ export function buildActionableWorkAgenda(
|
|||
|
||||
const owner = normalizeMemberName(task.owner);
|
||||
const base = buildBaseItem(task, memberName);
|
||||
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
|
||||
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
|
||||
const blockedBy = [
|
||||
...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 waitingDependencyIds: string[] = [];
|
||||
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(/^#/, '')]))];
|
||||
}
|
||||
|
||||
function normalizedTaskReferences(values: readonly string[] | undefined): string[] {
|
||||
return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function findLeadMemberName(activeMembers: string[]): string | null {
|
||||
return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null;
|
||||
}
|
||||
|
|
@ -179,7 +183,7 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
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);
|
||||
return !dependency || isDeletedTask(dependency);
|
||||
});
|
||||
|
|
@ -200,7 +204,7 @@ export class MemberWorkSyncTaskImpactResolver {
|
|||
continue;
|
||||
}
|
||||
if (
|
||||
(candidate.blockedBy ?? []).some(
|
||||
normalizedTaskReferences(candidate.blockedBy).some(
|
||||
(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 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[] {
|
||||
const diagnostics: string[] = [];
|
||||
const evaluatedAtMs = Date.parse(status.evaluatedAt);
|
||||
|
|
@ -290,14 +313,61 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
);
|
||||
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 (
|
||||
teamNames: string[],
|
||||
claimedBy: string
|
||||
claimedBy: string,
|
||||
options: { refreshBackgroundStaleStatuses?: boolean } = {}
|
||||
): Promise<MemberWorkSyncNudgeDispatchSummary> => {
|
||||
const readyTeamNames = await filterNudgeDispatchReadyTeamNames(teamNames);
|
||||
if (readyTeamNames.length === 0) {
|
||||
return emptyNudgeDispatchSummary();
|
||||
}
|
||||
if (options.refreshBackgroundStaleStatuses !== false) {
|
||||
await refreshBackgroundStaleStatuses(readyTeamNames);
|
||||
}
|
||||
return nudgeDispatcher.dispatchDue({
|
||||
teamNames: readyTeamNames,
|
||||
claimedBy,
|
||||
|
|
@ -306,7 +376,9 @@ export function createMemberWorkSyncFeature(deps: {
|
|||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
|
||||
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),
|
||||
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { isTeamTaskBlockedByUnfinishedDependency } from '@shared/utils/teamTaskState';
|
||||
|
||||
import { getOpenCodeWeakStartStallThresholdMs } from './featureGates';
|
||||
import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier';
|
||||
|
||||
|
|
@ -374,7 +376,7 @@ export class TeamTaskStallPolicy {
|
|||
if (task.reviewState === 'review') {
|
||||
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');
|
||||
}
|
||||
if (task.needsClarification) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { TeamTaskWithKanban } from '@shared/types';
|
|||
export {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskBlockedByUnfinishedDependency,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
isTeamTaskFinishedForDependency,
|
||||
isTeamTaskNeedsFixActionable,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ export interface TeamTaskStateLike {
|
|||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamTaskBlockerLike {
|
||||
blockedBy?: string[] | null;
|
||||
}
|
||||
|
||||
export type TeamTaskWorkflowColumn = 'review' | 'approved';
|
||||
|
||||
interface CachedTeamTaskState {
|
||||
|
|
@ -122,6 +126,22 @@ export function isTeamTaskFinishedForDependency(task: TeamTaskStateLike): boolea
|
|||
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 {
|
||||
return getCachedTeamTaskState(task).terminalForActionableWork;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildActionableWorkAgenda } from '@features/member-work-sync/core/domain';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const hash = (value: string) => `h${value.length}`;
|
||||
|
||||
|
|
@ -151,7 +150,7 @@ describe('buildActionableWorkAgenda', () => {
|
|||
subject: 'Depends on approved task',
|
||||
status: 'in_progress',
|
||||
owner: 'jack',
|
||||
blockedBy: ['task-approved'],
|
||||
blockedBy: [' task-approved '],
|
||||
},
|
||||
],
|
||||
hash,
|
||||
|
|
|
|||
|
|
@ -17,11 +17,13 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import type {
|
||||
MemberWorkSyncActionableWorkItem,
|
||||
MemberWorkSyncMetricEvent,
|
||||
MemberWorkSyncOutboxEnsureInput,
|
||||
MemberWorkSyncOutboxItem,
|
||||
MemberWorkSyncOutboxMarkDeliveredInput,
|
||||
MemberWorkSyncOutboxMarkFailedInput,
|
||||
MemberWorkSyncOutboxMarkSupersededInput,
|
||||
MemberWorkSyncPhase2ReadinessReason,
|
||||
MemberWorkSyncPhase2ReadinessState,
|
||||
MemberWorkSyncReportIntent,
|
||||
MemberWorkSyncReportRequest,
|
||||
|
|
@ -94,6 +96,9 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
|||
readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = [];
|
||||
readonly pendingIntents = new Map<string, MemberWorkSyncReportIntent>();
|
||||
phase2ReadinessState: MemberWorkSyncPhase2ReadinessState = 'collecting_shadow_data';
|
||||
phase2ReadinessReasons: MemberWorkSyncPhase2ReadinessReason[] = [];
|
||||
metricsGeneratedAt = '2026-04-29T00:00:00.000Z';
|
||||
recentEvents: MemberWorkSyncMetricEvent[] = [];
|
||||
|
||||
async read(): Promise<MemberWorkSyncStatus | null> {
|
||||
return this.writes.at(-1) ?? null;
|
||||
|
|
@ -129,7 +134,7 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
|||
async readTeamMetrics(teamName: string): Promise<MemberWorkSyncTeamMetrics> {
|
||||
return {
|
||||
teamName,
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
generatedAt: this.metricsGeneratedAt,
|
||||
memberCount: 1,
|
||||
stateCounts: {
|
||||
caught_up: 0,
|
||||
|
|
@ -144,10 +149,10 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
|||
fingerprintChangeCount: 0,
|
||||
reportAcceptedCount: 0,
|
||||
reportRejectedCount: 0,
|
||||
recentEvents: [],
|
||||
recentEvents: this.recentEvents,
|
||||
phase2Readiness: {
|
||||
state: this.phase2ReadinessState,
|
||||
reasons: [],
|
||||
reasons: this.phase2ReadinessReasons,
|
||||
thresholds: {
|
||||
minObservedMembers: 1,
|
||||
minStatusEvents: 20,
|
||||
|
|
@ -1286,6 +1291,85 @@ describe('MemberWorkSync use cases', () => {
|
|||
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 () => {
|
||||
const outbox = new InMemoryOutboxStore();
|
||||
const inbox = new InMemoryInboxNudge();
|
||||
|
|
|
|||
|
|
@ -458,6 +458,77 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
|||
).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', () => {
|
||||
expect(
|
||||
decideMemberWorkSyncNudgeActivation({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TeamTask } from '@shared/types';
|
||||
|
||||
|
|
@ -112,7 +111,7 @@ describe('MemberWorkSyncTaskImpactResolver', () => {
|
|||
subject: 'Depends on display id',
|
||||
status: 'pending',
|
||||
owner: 'tom',
|
||||
blockedBy: ['11111111'],
|
||||
blockedBy: [' 11111111 '],
|
||||
},
|
||||
];
|
||||
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 () => {
|
||||
const getConfig = vi.fn(async () => ({ members: [] }));
|
||||
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([
|
||||
['turn_ended_after_touch', 4],
|
||||
['touch_then_other_turns', 5],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import {
|
||||
getTeamTaskWorkflowColumn,
|
||||
isTeamTaskActivelyWorked,
|
||||
isTeamTaskBlockedByUnfinishedDependency,
|
||||
isTeamTaskFinalForCompletionNotification,
|
||||
isTeamTaskFinishedForDependency,
|
||||
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', () => {
|
||||
it('keeps stale in-progress approved overlay visible as approved', () => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isTaskBlocked,
|
||||
resolveTaskGraphColumn,
|
||||
} from '@features/agent-graph/core/domain/taskGraphSemantics';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
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: ['soft-deleted'] }, taskStateById)).toBe(false);
|
||||
expect(isTaskBlocked({ blockedBy: ['review-approved'] }, taskStateById)).toBe(false);
|
||||
expect(isTaskBlocked({ blockedBy: ['kanban-approved'] }, taskStateById)).toBe(false);
|
||||
|
|
|
|||
Loading…
Reference in a new issue