fix(member-work-sync): recover sleeping teammates

This commit is contained in:
777genius 2026-06-01 21:51:08 +03:00
parent 5d05434bbb
commit 544f4576d4
17 changed files with 631 additions and 43 deletions

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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 } : {}),

View file

@ -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) {

View file

@ -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
)
) {

View file

@ -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 } : {}),

View file

@ -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) {

View file

@ -5,6 +5,7 @@ import type { TeamTaskWithKanban } from '@shared/types';
export {
getTeamTaskWorkflowColumn,
isTeamTaskActivelyWorked,
isTeamTaskBlockedByUnfinishedDependency,
isTeamTaskFinalForCompletionNotification,
isTeamTaskFinishedForDependency,
isTeamTaskNeedsFixActionable,

View file

@ -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;
}

View file

@ -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,

View file

@ -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();

View file

@ -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({

View file

@ -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({

View file

@ -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 () => ({

View file

@ -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],

View file

@ -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(

View file

@ -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);